Q a02b8addcc feat(ac15): RS-5 账单右端语义(`t = to` 只进 FlowInclusiveEnd) hai 10 horas
..
fixture 3744037dd5 feat(ac15): 3c 课酬应收核销双跑(SP27)—— RA-1/RA-2,50→52 全绿 hai 13 horas
stage2 a02b8addcc feat(ac15): RS-5 账单右端语义(`t = to` 只进 FlowInclusiveEnd) hai 10 horas
README.md 3218d9b989 docs(ac15): pct 分档改为原始取值口径;补"超出量为常数级"的证据 hai 20 horas
attribution.go 2abbc0d0a5 fix(ac15): 容差常数 0.005 -> 0.0055(补两个漏建的误差源) hai 20 horas
cases.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 hai 21 horas
guard.go abda2fac86 feat(ac15): 双跑 harness 阶段1 骨架(课酬链) hai 21 horas
model.go 7007399f02 chore(ac15): 容差收紧为 0.005+|Δ预期|;报告加覆盖率缺口;README 补两个必踩坑 hai 20 horas
oracle.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) hai 20 horas
oracle_probe.php 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) hai 20 horas
report.go 549b099e88 feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引 hai 21 horas
run.go 7007399f02 chore(ac15): 容差收紧为 0.005+|Δ预期|;报告加覆盖率缺口;README 补两个必踩坑 hai 20 horas
target.go 2ef5cd76ea fix(ac15): 修正 6 处 harness 缺陷,全量 145 条跑通(DEFECT=0) hai 20 horas

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.1.1 "多出来的那部分"是常数级的(这是它属于误差、不属于算法差异的证据)

16 条精度收敛只有两种模式,而两种模式的"实测Δ − 预期Δ"几乎相同:

模式 条数 Δincome pct 预期Δ 实测Δ 超出量 = 实测 − 预期
A 10 −0.004 70 −0.0028 −0.0070 −0.0042
B 6 +0.003 30 +0.0009 +0.0060 +0.0051

全部 16 条的超出量落在 0.0042 ~ 0.0051,极差只有 0.0009。

这就是关键证据:如果多出来的部分来自算法差异(比如少乘一次、夹取顺序错),它应当随 pct 或 income 成比例变化, 不可能在 pct=70 和 pct=30、Δincome 正负相反的两组里给出几乎相同的绝对值。 恒定、且不随 pct / Δincome 变化 ⇒ 只能是课酬侧那半分(②),即判据里被漏建的那一项。

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 变化的记录落在哪些 percentage 上(用原始取值,不用分档百分比)

先给不依赖分桶的事实 —— 这是本节最可靠的一句:

全部 16 条变化记录只出现在两个 pct 值上:pct=30 6 条、pct=70 10 条。 其余 pct 取值(10 / 20 / 22 / 25 / 35 / 50)一条都没变。本批无 pct ≥ 100 的记录。

按原始取值的完整分布:

pct 总数 PC 变化率
10.0 2 0 0.0%
20.0 20 0 0.0%
22.0 3 0 0.0%
25.0 8 0 0.0%
30.0 24 6 25.0%
35.0 6 0 0.0%
50.0 4 0 0.0%
70.0 33 10 30.3%
合计 100 16 —

为什么不要引用"某档 X%"这种数字

同样的数据换个分桶边界,百分比就变:

分桶方式 pct<25 / ≤25 中间档 pct≥50 / >50
≤25 / 26~50 / >50 0 / 33 = 0.0% 6 / 34 = 17.6% 10 / 33 = 30.3%
<25 / 25~50 / 50~100 0 / 25 = 0.0% 6 / 38 = 15.8% 10 / 37 = 27.0%

差别全部来自 pct=25 的 8 条和 pct=50 的 4 条被挪了桶 —— 数据没变,只是边界挪了。 另外注意:30.3% 其实就是 pct=70 这一个值的变化率(10/33),不是某一档的综合率。

可引用的结论(两条,都不依赖分桶)

  1. pct ≤ 25 的 33 条一条未变(0.0%)
  2. 变化的 16 条只出现在 pct=30(6/24=25.0%) 与 pct=70(10/33=30.3%) 两个值上

机理:Δreward ≈ Δincome × pct/100,pct 越大越容易把 0.001~0.005 的收入收敛放大到跨分。 对账建议:优先看 pct=70 与 pct=30 两类课程,它们覆盖了本批 100% 的变动。

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 集里没有这类"。