README.md 9.3 KB

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

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 不能用绑定参数

// ✗ 报错: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. 容差口径

三层判据的容差 = 0.005 + |Δ预期|:

  • 0.005 = 舍入到"分"的最大误差(半分)
  • |Δ预期| = |Δincome| × pct/100,即基数差传递过来的量
  • 两者相加 = 理论上允许的最大差

不要为了消红放宽容差。 容差是判据,不是目标;1.5 分容差会把一个真实的 1 分级算法 bug 吞掉。