Q 2abbc0d0a5 fix(ac15): 容差常数 0.005 -> 0.0055(补两个漏建的误差源) 13 ore fa
..
README.md 2abbc0d0a5 fix(ac15): 容差常数 0.005 -> 0.0055(补两个漏建的误差源) 13 ore fa
attribution.go 2abbc0d0a5 fix(ac15): 容差常数 0.005 -> 0.0055(补两个漏建的误差源) 13 ore fa
cases.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 15 ore fa
guard.go abda2fac86 feat(ac15): 双跑 harness 阶段1 骨架(课酬链) 15 ore fa
model.go 7007399f02 chore(ac15): 容差收紧为 0.005+|Δ预期|;报告加覆盖率缺口;README 补两个必踩坑 14 ore fa
oracle.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 14 ore fa
oracle_probe.php 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 14 ore fa
report.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 15 ore fa
run.go 7007399f02 chore(ac15): 容差收紧为 0.005+|Δ预期|;报告加覆盖率缺口;README 补两个必踩坑 14 ore fa
target.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) 14 ore fa

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


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. 容差口径(三个误差源相加)

第 3 步 teacher_reward 的判据容差 = 0.0055 + |Δ预期|。

它不是"调出来的经验值",而是三个互不重叠的误差源上界之和:

# 误差源 上界 说明
① Δincome 传递 ` Δ预期
② 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 集里没有这类"。