|
|
8 ساعت پیش | |
|---|---|---|
| .. | ||
| fixture | 10 ساعت پیش | |
| stage2 | 8 ساعت پیش | |
| README.md | 14 ساعت پیش | |
| attribution.go | 14 ساعت پیش | |
| cases.go | 15 ساعت پیش | |
| guard.go | 15 ساعت پیش | |
| model.go | 14 ساعت پیش | |
| oracle.go | 14 ساعت پیش | |
| oracle_probe.php | 14 ساعت پیش | |
| report.go | 15 ساعت پیش | |
| run.go | 14 ساعت پیش | |
| target.go | 14 ساعت پیش | |
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、活动链、分账链不在本包范围。
这不是"两边各跑一次入库":
理由是课酬属于"由输入算出一个金额"的逻辑,纯函数足以判定;target 库也没有对应历史数据, 真跑只会引入无关变量。报告里必须原样保留这段说明,否则读的人会以为两侧都真跑了。
guard.go)两道条件,缺一不可:
127.0.0.1 / localhost / ::1SELECT DATABASE() 必须 ∈ {finance_bk, legacy_finance, legacy_oracle, yuansiwei_2026}违反时 panic(不返回 error)——这类错误一旦被忽略,会以"连到错误实例后的正常结果"形式污染整轮结论。
写库另有更严的允许表(阶段 1 只允许 legacy_oracle / legacy_finance)。
allowed-list 而非 deny-list:代码里不出现任何具体远端地址,日志里的 host 一律脱敏(127.0.***.**)。
AssertNoSysdate:目标库所有 routine 定义里 sysdate( 出现数必须为 0,否则拒绝运行。
依据:SET SESSION timestamp 冻结 NOW() 但不冻结 SYSDATE()(P0 §6 组 1)。
一旦有 SP 用 SYSDATE(),冻结时钟会静默失效而结果看起来正常。
oracle 侧每个 case 起一个新 PHP 进程(= 新连接)。依据(P0 §8.1 Q5):
MEMORY 引擎临时表的行不参与 ROLLBACK,只靠回滚清不掉;sp_user_expense_get_bill 正是 12 张 MEMORY 临时表。
进程隔离同时解决三件事:临时表残留、表结构残留、会话变量(timestamp / time_zone)污染。
| 类 | 判据 | 重置方式 |
|---|---|---|
| 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 的扩展点。
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 位小数的记录都会红,然后有人会去改一个本来正确的算法。
每条 case 记录:detail_id 是否命中、命中层级(teacher-classify / teacher-grade-subject /
cert-tier / generic-tier)、是否退化、isSupplement 取值、rewardType。
数值相等但路径不同 ⇒ PATH-DIVERGENCE,交人工判。R4 就是"数值相等掩盖路径不同"的实例。
Truncate(time.Second);SET SESSION timestamp=<整秒> + time_zone='+08:00'time() 的结果 —— A22:PHP 时钟不受 SET timestamp 影响,不假装能冻MATCH / PRECISION-CONVERGENCE / DEFECT / ORACLE-DEGRADED / PATH-DIVERGENCE / ENV-FAILURE
汇总必须给出六类计数,不能只给"通过率"。
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 环境层面失败(守卫 / 断言 / 连接)。
| 类别 | 数量 | 说明 |
|---|---|---|
real100 |
100 | status=2 + income>0 + 有规则命中;按 percentage 区间 × 人数区间分层抽样(不是取前 100 条) |
synthetic |
6 类 × 5 | 四级兜底(classify / cert 必须造数)、income 三位小数、income=0 夹下限、isSupplement 两值、人数不整除、rewardType=2 查表 |
不做:getTeacherPaymentDetail 无命中的记录(占 6.25%)—— 只标 ORACLE-DEGRADED,不判定。
internal/academic/service/dualrun_shim.go —— 两个只做转发、不含逻辑的导出函数
(ComputeRecordRewardForDualRun / DistributeRecordRewardForDualRun)。
为什么需要它:computeRecordReward / distributeAmount 是包内私有,harness 在另一个包。
为什么不能让 harness 自己再写一份公式:那会变成"用第二份实现校验第一份",
一旦两份都错、错法一致,双跑会给出全绿结论 —— 这正是要避免的自证。
这两条是本轮实测踩出来的,踩中时给出的数字看起来极像真缺陷,务必先怀疑它们再怀疑算法。
合成 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。
同一个坑的另一半: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 定义,而是造数之后的库状态。 任何在造数之前抓取的量(收入、规则、人数、结算方式)都可能是毒药。
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 不行。
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 侧的问题。
第 3 步 teacher_reward 的判据容差 = 0.0055 + |Δ预期|。
它不是"调出来的经验值",而是三个互不重叠的误差源上界之和:
| # | 误差源 | 上界 | 说明 |
|---|---|---|---|
| ① | Δincome 传递 |
` | Δ预期 |
| ② | teacher_reward 自身精度收敛 |
0.005 | 目标态 DECIMAL(12,2),Go 在"乘后舍到分"处最多舍掉半分 |
| ③ | PHP 侧 round(x, 3) 固有误差 |
0.0005 | 旧库只留 3 位小数,相对"真实值"本就有三位的半分误差 |
三者相加即 0.0055 + |Δ预期|。
早期版本只建了 ①(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。
判据的失败模式因此不是"漏报缺陷",而是"把正常收敛报成缺陷"—— 后者会让人去改一个本来正确的算法,代价更高。
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 变化 ⇒ 只能是课酬侧那半分(②),即判据里被漏建的那一项。
不要为了消红放宽容差。 容差是判据,不是目标。 历史上用过 1.5 分容差,它会把一个真实的 1 分级算法 bug 吞掉 —— 因为"差 1 分算收敛"。 若发现容差不够,正确做法是补误差源(像本节 ②③ 这样逐项推导),而不是把常数往上抬。
以下数字来自真实 100 条(status=2 且 income>0 且有规则命中,按 percentage × 人数分层抽样),
容差 0.005 + |Δ预期|。
MATCH 84
PRECISION-CONVERGENCE 16
DEFECT 0
(ORACLE-DEGRADED / PATH-DIVERGENCE / ENV-FAILURE 均为 0 —— 注意其含义见 §16.3)
| 统计项 | 值 |
|---|---|
| 最小 | 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 正负。
percentage 上(用原始取值,不用分档百分比)先给不依赖分桶的事实 —— 这是本节最可靠的一句:
全部 16 条变化记录只出现在两个 pct 值上:
pct=306 条、pct=7010 条。 其余 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 | — |
同样的数据换个分桶边界,百分比就变:
| 分桶方式 | 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),不是某一档的综合率。
pct ≤ 25 的 33 条一条未变(0.0%)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% 的变动。
因
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 集里没有这类"。