Q 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前
..
README.md abda2fac86 feat(ac15): 双跑 harness 阶段1 骨架(课酬链) 12 小时之前
attribution.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前
cases.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 12 小时之前
guard.go abda2fac86 feat(ac15): 双跑 harness 阶段1 骨架(课酬链) 12 小时之前
model.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前
oracle.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前
oracle_probe.php 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前
report.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 12 小时之前
run.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 12 小时之前
target.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 11 小时之前

README.md

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 自己再写一份公式:那会变成"用第二份实现校验第一份", 一旦两份都错、错法一致,双跑会给出全绿结论 —— 这正是要避免的自证。