# `internal/academic/dualrun` —— AC-15 双跑 harness(阶段 1:课酬链) 本包只做**一条链路**的双跑比对: | 侧 | 实现 | 怎么跑 | |---|---|---| | **oracle** | legacy PHP(`CoursesRecordHelper::settlementExpenses`) | **真跑**:起一个 PHP 进程,事务内结算、`finally` 回滚,读**异常前已写入**的库值 | | **target** | Go 纯函数(`service.computeRecordReward` + `distributeAmount`) | 只算不写:输入取自 oracle 侧同一份观测,**不连 `yuansiwei_2026`** | 阶段 2~4 的写 SP、活动链、分账链**不在本包范围**。 --- ## 1. 两侧不对称(读报告前必看) 这不是"两边各跑一次入库": - **oracle 侧会写库**(写在事务里,结束必定回滚) - **target 侧只调纯函数**,一个字节都不写 理由是课酬属于"由输入算出一个金额"的逻辑,纯函数足以判定;target 库也没有对应历史数据, 真跑只会引入无关变量。**报告里必须原样保留这段说明**,否则读的人会以为两侧都真跑了。 ## 2. 数据源守卫(`guard.go`) 两道条件,缺一不可: 1. **host 白名单**:只允许 `127.0.0.1` / `localhost` / `::1` 2. **库白名单**:`SELECT DATABASE()` 必须 ∈ {`finance_bk`, `legacy_finance`, `legacy_oracle`, `yuansiwei_2026`} 违反时 **panic**(不返回 error)——这类错误一旦被忽略,会以"连到错误实例后的正常结果"形式污染整轮结论。 写库另有更严的允许表(阶段 1 只允许 `legacy_oracle` / `legacy_finance`)。 allowed-list 而非 deny-list:代码里**不出现任何具体远端地址**,日志里的 host 一律脱敏(`127.0.***.**`)。 ## 3. 启动静态断言 `AssertNoSysdate`:目标库所有 routine 定义里 `sysdate(` 出现数必须为 **0**,否则拒绝运行。 依据:`SET SESSION timestamp` 冻结 `NOW()` 但**不冻结 `SYSDATE()`**(P0 §6 组 1)。 一旦有 SP 用 `SYSDATE()`,冻结时钟会静默失效而结果看起来正常。 ## 4. 每个 case 新连接 oracle 侧**每个 case 起一个新 PHP 进程**(= 新连接)。依据(P0 §8.1 Q5): `MEMORY` 引擎临时表的行**不参与 ROLLBACK**,只靠回滚清不掉;`sp_user_expense_get_bill` 正是 12 张 MEMORY 临时表。 进程隔离同时解决三件事:临时表残留、表结构残留、会话变量(`timestamp` / `time_zone`)污染。 ## 5. A/B 重置接口 | 类 | 判据 | 重置方式 | |---|---|---| | **A** | 只有 DML,或只用 `TEMPORARY` 表 | `START TRANSACTION` + `ROLLBACK` | | **B** | 含 `TRUNCATE` / 持久表 DDL / `LOCK TABLES` | 快照恢复 | 阶段 1 的两个入口(`sp_course_payment_detail_get_teacher_detail`、`settlementExpenses`)都是 **A 类**, 当前由 PHP 探针内的事务 + `finally` 回滚实现;B 类接口位置见 `Case.Mutations` 的扩展点。 ## 6. 三步分层归因(`attribution.go`,顺序不可换) | 步 | 比什么 | 不等时 | |---|---|---| | 1 | `income`:legacy `round(Σcost,3)` vs 迁移后 `Σ ROUND(cost,2)` | 精度收敛,记 Δincome,**继续第 2 步** | | 2 | 规则:oracle 实返 `detail_id` vs runner 复刻的兜底判定 | **真缺陷**(规则选错 / 层级错) | | 3 | `teacher_reward` | `Δreward ≈ Δincome × pct/100` ⇒ 精度收敛;**不成比例 ⇒ 真缺陷** | 第 3 步的**成比例判据**是关键:没有它,所有带 3 位小数的记录都会红,然后有人会去改一个本来正确的算法。 ## 7. 路径证据(约束 17) 每条 case 记录:`detail_id` 是否命中、命中层级(`teacher-classify` / `teacher-grade-subject` / `cert-tier` / `generic-tier`)、是否退化、`isSupplement` 取值、`rewardType`。 **数值相等但路径不同 ⇒ `PATH-DIVERGENCE`**,交人工判。R4 就是"数值相等掩盖路径不同"的实例。 ## 8. 冻结时钟 - 两侧同源、**`Truncate(time.Second)`**;`SET SESSION timestamp=<整秒>` + `time_zone='+08:00'` - 教龄仍取 **PHP 侧 `time()`** 的结果 —— A22:PHP 时钟不受 `SET timestamp` 影响,不假装能冻 ## 9. 归因取值(只有六个) `MATCH` / `PRECISION-CONVERGENCE` / `DEFECT` / `ORACLE-DEGRADED` / `PATH-DIVERGENCE` / `ENV-FAILURE` 汇总必须给出六类计数,不能只给"通过率"。 ## 10. CLI ```bash dualrun stage1 --case-set real100 dualrun stage1 --case-set synthetic dualrun stage1 --all --out report.json --md report.md dualrun stage1 --dry-run dualrun stage1 --all --idempotency ``` 环境变量(口令只经环境变量,不落盘):`AC15_DB_HOST` / `AC15_DB_PORT` / `AC15_DB_NAME` / `AC15_DB_USER` / `AC15_DB_PASS` / `AC15_LEGACY_REPO` / `AC15_PHP_BIN`。 退出码:`0` 跑完(无论结论);`2` 参数错误;`3` 环境层面失败(守卫 / 断言 / 连接)。 ## 11. 用例集 | 类别 | 数量 | 说明 | |---|---|---| | `real100` | 100 | `status=2` + `income>0` + 有规则命中;按 `percentage` 区间 × 人数区间**分层抽样**(不是取前 100 条) | | `synthetic` | 6 类 × 5 | 四级兜底(classify / cert 必须造数)、income 三位小数、income=0 夹下限、`isSupplement` 两值、人数不整除、`rewardType=2` 查表 | **不做**:`getTeacherPaymentDetail` 无命中的记录(占 6.25%)—— 只标 `ORACLE-DEGRADED`,不判定。 ## 12. 与生产代码的唯一接触点 `internal/academic/service/dualrun_shim.go` —— 两个**只做转发、不含逻辑**的导出函数 (`ComputeRecordRewardForDualRun` / `DistributeRecordRewardForDualRun`)。 为什么需要它:`computeRecordReward` / `distributeAmount` 是包内私有,harness 在另一个包。 为什么不能让 harness 自己再写一份公式:那会变成"用第二份实现校验第一份", 一旦两份都错、错法一致,双跑会给出全绿结论 —— 这正是要避免的自证。 --- ## 13. 两个必踩坑(合成 case 专属) 这两条是本轮实测踩出来的,**踩中时给出的数字看起来极像真缺陷**,务必先怀疑它们再怀疑算法。 ### 13.1 造数之后必须重读输入 合成 case 会在事务内改数据(`UPDATE ... SET cost=0` / `SET teacher_reward_type=2` / 插规则)。 PHP 结算用的是**造数之后**的世界,harness 若沿用造数之前的 `cost` / `teacher_reward_type` / `income`, target 侧就会拿着旧基数去算,得到一个"基数不同"的假 DEFECT。 实战症状(都是 harness 的错,不是算法差异): | case | 假 DEFECT 的差值 | 真实原因 | |---|---|---| | `syn-income-zero-min-*` | `+110.000` | target 用造数前的 `income=1200`,PHP 用造数后的 `income=0`(应夹下限 130) | | `syn-rewardtype2-*` | `+122.500` | target 用造数前的 `teacher_reward_type=1`,PHP 用造数后的 `2`(应走固定课酬 70) | **修法**:探针在造数后重读 `income` / `student_number` / `teacher_reward_type` / 打卡行,作为 `inputsEffective`; 造数前的快照只留作溯源(`inputsBeforeMutation`),target 一律用 `inputsEffective`。 ### 13.2 规则必须在事务内、造数之后读 同一个坑的另一半:`getTeacherPaymentDetailStoredProcedure` 若在事务之前调用,拿到的是**造数前的旧规则**。 于是 PHP 用注入的新规则算、target 用旧规则算 ⇒ 假 DEFECT。 实战症状: | case | 假 DEFECT 的差值 | 真实原因 | |---|---|---| | `syn-tier-teacher_classify-*` | `+11.000` | PHP 命中注入的 `pct=33` 规则(181.5),target 用旧的 `pct=35`(192.5) | | `syn-tier-cert_tier-*` | `-93.680` | PHP 命中注入的 `cert` 规则(163.68),target 用旧的通用规则(70) | **修法**:规则读取挪进事务、造数之后;造数 case 的 `expectTier` 不再与"造数前的复刻判定"比对(必然不同), 而由实返规则的字段自证。 > 这两条合起来意味着:**合成 case 的"输入"不是 case 定义,而是造数之后的库状态。** > 任何在造数之前抓取的量(收入、规则、人数、结算方式)都可能是毒药。 ## 14. 两个环境坑(与业务无关,但会让人查半天) ### 14.1 `SET SESSION timestamp` 不能用绑定参数 ```php // ✗ 报错:SQLSTATE[42000] 1232 Incorrect argument type to variable 'timestamp' $db->createCommand('SET SESSION timestamp = :ts', [':ts' => (string) $epoch])->execute(); // ✓ timestamp 是"数值型会话变量",必须给数值字面量 $db->createCommand('SET SESSION timestamp = ' . (int) $epoch)->execute(); ``` `time_zone` 是字符串变量,用绑定没问题;`timestamp` 不行。 ### 14.2 子进程输出必须加显式分隔标记 PHP CLI 的 notice/warning 会混进 stdout(legacy 的 `config/params.php` 在 CLI 下就有 `Undefined index: REQUEST_SCHEME`)。 探针因此以 `##AC15_JSON_BEGIN##` 标记 JSON 起点,Go 侧按标记切。 **不能用"最后一个 `{`"**:JSON 内部有嵌套对象(打卡行、规则),会切在中间,报 `invalid character ']' after top-level value` —— 而且报出来的片段看起来像"探针输出被截断", 很容易误判成 PHP 侧的问题。 ## 15. 容差口径(三个误差源相加) 第 3 步 `teacher_reward` 的判据容差 = **`0.0055 + |Δ预期|`**。 它不是"调出来的经验值",而是三个**互不重叠**的误差源上界之和: | # | 误差源 | 上界 | 说明 | |---|---|---|---| | ① | `Δincome` 传递 | `|Δ预期| = |Δincome| × pct/100` | 旧库 `cost` 逐行 3 位→2 位收敛,经比例放大后传到课酬 | | ② | **`teacher_reward` 自身精度收敛** | **0.005** | 目标态 `DECIMAL(12,2)`,Go 在"乘后舍到分"处最多舍掉半分 | | ③ | **PHP 侧 `round(x, 3)` 固有误差** | **0.0005** | 旧库只留 3 位小数,相对"真实值"本就有三位的半分误差 | 三者相加即 `0.0055 + |Δ预期|`。 ### 15.1 为什么"漏掉 ②③"是危险的 早期版本只建了 ①(`tolerance = 0.005 + |Δ预期|`)。实测 16 条精度收敛里,模式 B(`Δincome=+0.003`、`pct=30`): ``` Δ预期 = +0.0009 ← ① 只占这么点 实测Δreward = +0.0060 ← 剩下的 +0.0051 全来自 ②(Go 乘后舍到分) |实测−预期| = 0.0051 旧容差 = 0.005 + 0.0009 = 0.0059 → 通过,但只剩 0.0008 余量 新容差 = 0.0055 + 0.0009 = 0.0064 → 通过,余量 0.0013 ``` **余量 0.0008 不是"宽裕通过",是"擦边通过"**:换一条 `pct` 稍大或 `Δincome` 稍偏的记录,就会被误判成 `DEFECT`。 判据的失败模式因此不是"漏报缺陷",而是"把正常收敛报成缺陷"—— 后者会让人去改一个本来正确的算法,代价更高。 ### 15.2 纪律 **不要为了消红放宽容差。** 容差是判据,不是目标。 历史上用过 1.5 分容差,它会把一个真实的 1 分级算法 bug 吞掉 —— 因为"差 1 分算收敛"。 若发现容差不够,正确做法是**补误差源**(像本节 ②③ 这样逐项推导),而不是把常数往上抬。 ## 16. 割接相关口径(阶段 1 实测,财务/对账可直接引用) 以下数字来自**真实 100 条**(`status=2` 且 `income>0` 且有规则命中,按 percentage × 人数分层抽样), 容差 `0.005 + |Δ预期|`。 ### 16.1 结论分布 ``` MATCH 84 PRECISION-CONVERGENCE 16 DEFECT 0 (ORACLE-DEGRADED / PATH-DIVERGENCE / ENV-FAILURE 均为 0 —— 注意其含义见 §16.3) ``` ### 16.2 课酬变动幅度(Δreward) | 统计项 | 值 | |---|---| | 最小 | **0.60 分**(0.006 元) | | 中位 | **0.70 分** | | 最大 | **0.70 分** | | `Δincome ≠ 0` 但 `Δreward = 0` | **15 条**(基数变了、但没跨过分位) | 最大一条: ``` caseId real-0008 Σcost (3dp) 358.824 → 358.82(迁移后 ΣROUND(cost,2)),Δincome = -0.004 规则 percentage=70.0,min=0(不设限),max=10000,reward=0 teacher_reward 251.177 (legacy) → 251.17 (target),Δ = -0.007 ``` **对外口径**:迁移后约 **16%** 的历史课酬会变,全部是分位级,**最大 0.7 分(0.007 元)**,方向随 Δincome 正负。 ### 16.3 PC 按 percentage 分档(解释"哪些课会变") | pct 区间 | 总数 | PC | 变化概率 | |---|---|---|---| | `pct < 25` | 25 | 0 | **0.0%** | | `25 ≤ pct < 50` | 38 | 6 | **15.8%** | | `50 ≤ pct < 100` | 37 | 10 | **27.0%** | | `pct ≥ 100` | 0 | 0 | n/a(本批无此档记录) | PC 只落在两个具体值上:`pct=70` → 10 条,`pct=30` → 6 条。 **结论**:`Δreward ≈ Δincome × pct/100`,pct 越大越容易把 0.001~0.005 的收入收敛放大到跨分。 **对账可先只看 `pct ≥ 50` 的课程**(本批 27.0% 会变,而 `pct < 25` 一条都不变)。 ### 16.4 覆盖率缺口(必须与结论一起出现) > **因 `getTeacherPaymentDetail` 无命中被排除:4,679 条 / 74,870(6.25%),未参与本轮比对(R14)。** 两个分母不要混: | 出处 | 分子 / 分母 | 含义 | |---|---|---| | 全库口径(**对外用这个**) | 4,679 / 74,870 = **6.25%** | 全部 record 中无规则命中的比例 | | 阶段 1 候选内口径 | 4,149 / 62,487 = 6.64%(或 / 60,636 = 6.84%) | 先剔除 `status≠2 / income≤0 / 无有效学生` 后的剩余缺口 | 报告必须在摘要旁同时给出这句,否则 `ORACLE-DEGRADED = 0` 会被读成"全库没有这类记录"—— 实际含义是"**本轮 case 集里没有这类**"。