Browse Source

chore(ac15): 容差收紧为 0.005+|Δ预期|;报告加覆盖率缺口;README 补两个必踩坑

- 容差:0.015 -> 0.005 + |Δincome×pct/100|(半分 + 预期差),不得为消红放宽
- 报告新增「1.1 覆盖率缺口」:显式给出因无命中被排除的规模,
  避免 ORACLE-DEGRADED=0 被读成「全库没有这类」
- README §13 两个必踩坑(造数后必须重读输入 / 规则必须在事务内读),
  附假 DEFECT 的实测差值 +110/+122/+11/-93.68
- README §14 两个环境坑(timestamp 不能用绑定参数 1232;子进程输出必须加显式标记)
- README §15 容差口径与不可放宽的理由

真实 100 条复跑(新容差):MATCH 84 / PRECISION-CONVERGENCE 16 / DEFECT 0
Q 9 hours ago
parent
commit
7007399f02

+ 73 - 0
internal/academic/dualrun/README.md

@@ -117,3 +117,76 @@ dualrun stage1 --all --idempotency
 为什么需要它:`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` 不能用绑定参数
+
+```php
+// ✗ 报错: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. 容差口径
+
+三层判据的容差 = **`0.005 + |Δ预期|`**:
+
+- `0.005` = 舍入到"分"的最大误差(半分)
+- `|Δ预期|` = `|Δincome| × pct/100`,即基数差传递过来的量
+- 两者相加 = 理论上允许的最大差
+
+**不要为了消红放宽容差。** 容差是判据,不是目标;1.5 分容差会把一个真实的 1 分级算法 bug 吞掉。

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

@@ -146,7 +146,10 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		//   ③ 其余(不成比例)          ⇒ DEFECT
 		expectedDelta := s1.Delta * obs.Quote.Percentage / 100
 		actualDelta := tgt.Reward - legacyReward
-		tolerance := math.Max(0.015, math.Abs(expectedDelta)+0.015) // 1.5 分容差
+		// 容差 = 半分 + 基数差传递过来的预期差。
+		// 理论依据:舍入到"分"的最大误差是半分(0.005),再加上 Δincome×pct/100 传过来的量,
+		// 两者相加就是"理论上允许的最大差"。**不得为了消红而放宽**(容差是判据,不是目标)。
+		tolerance := 0.005 + math.Abs(expectedDelta)
 		proportional := math.Abs(actualDelta-expectedDelta) <= tolerance
 		switch {
 		case deltaIncomeMilli != 0 && proportional:

+ 11 - 8
internal/academic/dualrun/model.go

@@ -259,14 +259,17 @@ type CaseResult struct {
 
 // Report 是整轮双跑的产物(JSON + Markdown 同源)。
 type Report struct {
-	Stage         string              `json:"stage"`
-	GeneratedAt   string              `json:"generatedAt"`
-	CaseSet       string              `json:"caseSet"`
-	DryRun        bool                `json:"dryRun"`
-	FrozenEpoch   int64               `json:"frozenEpoch"`
-	FrozenAt      string              `json:"frozenAt"`
-	Guard         map[string]string   `json:"guard"`
-	Assertions    map[string]string   `json:"assertions"`
+	Stage       string            `json:"stage"`
+	GeneratedAt string            `json:"generatedAt"`
+	CaseSet     string            `json:"caseSet"`
+	DryRun      bool              `json:"dryRun"`
+	FrozenEpoch int64             `json:"frozenEpoch"`
+	FrozenAt    string            `json:"frozenAt"`
+	Guard       map[string]string `json:"guard"`
+	Assertions  map[string]string `json:"assertions"`
+	// Coverage 是覆盖率缺口(被排除的规模)。必须显式写进报告:
+	// 本轮只挑"有规则命中"的记录,所以 ORACLE-DEGRADED=0 只代表"本轮 case 集里没有这类"。
+	Coverage      map[string]string   `json:"coverage"`
 	CaseCounts    map[string]int      `json:"caseCounts"`
 	Summary       map[Attribution]int `json:"summary"`
 	Cases         []CaseResult        `json:"cases"`

+ 40 - 0
internal/academic/dualrun/run.go

@@ -121,6 +121,9 @@ func RunStage1(ctx context.Context, cfg Config, opts Options) (*Report, error) {
 	}
 	report.Assertions["record_scan"] = fmt.Sprintf("%d 条(teacherid>0 且有 actual_start_time)", len(records))
 
+	// ---- 覆盖率缺口:显式统计"被排除"的规模(不能靠 ORACLE-DEGRADED=0 暗示"没有这类")----
+	report.Coverage = computeCoverage(records, refs, opts.Now)
+
 	// ---- 组装 case ----
 	var cases []Case
 	if opts.CaseSet == "real100" || opts.CaseSet == "all" {
@@ -295,3 +298,40 @@ func SortedCaseIDs(cases []Case) []string {
 	sort.Strings(ids)
 	return ids
 }
+
+// computeCoverage 计算"真实基线候选总体"与"被排除"的规模。
+//
+// 为什么要单独算:本轮 real100 只挑「有规则命中」的记录,所以 ORACLE-DEGRADED 必然是 0;
+// 若报告只写 0,读的人会误以为"全库都覆盖了"。覆盖率缺口必须显式给出数字。
+func computeCoverage(records []recordRow, r *refs, now time.Time) map[string]string {
+	var total, eligible, hit, noHit, noIncome, noEligible int
+	for _, rec := range records {
+		total++
+		if rec.status != 2 || rec.income <= 0 {
+			noIncome++
+			continue
+		}
+		f := r.buildFacts(rec, now)
+		if f.agg.elig <= 0 {
+			noEligible++
+			continue
+		}
+		eligible++
+		if f.hit {
+			hit++
+		} else {
+			noHit++
+		}
+	}
+	ratio := 0.0
+	if total > 0 {
+		ratio = float64(noHit) * 100 / float64(total)
+	}
+	return map[string]string{
+		"全库记录数":    fmt.Sprintf("%d", total),
+		"真实基线候选总体": fmt.Sprintf("%d(status=2 且 income>0 且有有效学生)", eligible),
+		"其中有规则命中":  fmt.Sprintf("%d", hit),
+		"其中无命中被排除": fmt.Sprintf("%d(%.2f%% of 全库)未参与本轮比对(R14 / oracle-degraded)", noHit, ratio),
+		"其余未入选原因":  fmt.Sprintf("status≠2 或 income≤0:%d 条;无有效学生:%d 条", noIncome, noEligible),
+	}
+}