Переглянути джерело

fix(ac15): 容差常数 0.005 -> 0.0055(补两个漏建的误差源)

容差 = ①|Δincome×pct/100| + ②teacher_reward 自身 3→2 位收敛(≤0.005)
      + ③PHP round(x,3) 固有误差(≤0.0005) = 0.0055 + |Δ预期|

漏建 ②③ 的后果是擦边通过:模式 B 实测 |实测-预期|=0.0051,
旧容差 0.0059 只剩 0.0008 余量;新容差 0.0064 余量 0.0013。
README §15 补三段误差来源推导与「不得为消红放宽」的纪律。

真实 100 条复跑(0.0055):MATCH 84 / PRECISION-CONVERGENCE 16 / DEFECT 0
Q 7 годин тому
батько
коміт
2abbc0d0a5

+ 31 - 6
internal/academic/dualrun/README.md

@@ -181,15 +181,40 @@ PHP CLI 的 notice/warning 会混进 stdout(legacy 的 `config/params.php` 在
 `invalid character ']' after top-level value` —— 而且报出来的片段看起来像"探针输出被截断",
 很容易误判成 PHP 侧的问题。
 
-## 15. 容差口径
+## 15. 容差口径(三个误差源相加)
 
-三层判据的容差 = **`0.005 + |Δ预期|`**:
+第 3 步 `teacher_reward` 的判据容差 = **`0.0055 + |Δ预期|`**。
 
-- `0.005` = 舍入到"分"的最大误差(半分)
-- `|Δ预期|` = `|Δincome| × pct/100`,即基数差传递过来的量
-- 两者相加 = 理论上允许的最大差
+它不是"调出来的经验值",而是三个**互不重叠**的误差源上界之和:
 
-**不要为了消红放宽容差。** 容差是判据,不是目标;1.5 分容差会把一个真实的 1 分级算法 bug 吞掉。
+| # | 误差源 | 上界 | 说明 |
+|---|---|---|---|
+| ① | `Δincome` 传递 | `|Δ预期| = |Δincome| × pct/100` | 旧库 `cost` 逐行 3 位→2 位收敛,经比例放大后传到课酬 |
+| ② | **`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 实测,财务/对账可直接引用)
 

+ 9 - 4
internal/academic/dualrun/attribution.go

@@ -146,10 +146,15 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		//   ③ 其余(不成比例)          ⇒ DEFECT
 		expectedDelta := s1.Delta * obs.Quote.Percentage / 100
 		actualDelta := tgt.Reward - legacyReward
-		// 容差 = 半分 + 基数差传递过来的预期差。
-		// 理论依据:舍入到"分"的最大误差是半分(0.005),再加上 Δincome×pct/100 传过来的量,
-		// 两者相加就是"理论上允许的最大差"。**不得为了消红而放宽**(容差是判据,不是目标)。
-		tolerance := 0.005 + math.Abs(expectedDelta)
+		// 容差 = 三个误差源的上界之和 + 基数差传递量(推导见 README §15):
+		//   ① Δincome 传递:|Δincome × pct/100|            = |expectedDelta|
+		//   ② teacher_reward 自身 3 位→2 位收敛:             ≤ 0.005  (半分)
+		//   ③ PHP 侧 round(x,3) 的固有误差:                 ≤ 0.0005(三位的半分)
+		// 漏掉 ②③ 会让判据"擦边通过":实测模式 B 的 |实测-预期| = 0.0051,
+		// 而 0.005 的上界只有 0.0059 中的一半可用 —— 那不是宽裕,是侥幸。
+		// **不得为了消红而放宽**(容差是判据,不是目标)。
+		const roundingBound = 0.0055 // ② + ③
+		tolerance := roundingBound + math.Abs(expectedDelta)
 		proportional := math.Abs(actualDelta-expectedDelta) <= tolerance
 		switch {
 		case deltaIncomeMilli != 0 && proportional: