瀏覽代碼

feat(ac15): 归因扩到七个 / 类1每级5条 / 3dp五种尾数 / 幂等全量 / DEFECT索引

- 新增 ROUNDING-CONVENTION:Δincome=0 但逐行分摊方式不同(R13),与 PRECISION-CONVERGENCE 分开统计
- 判据顺序改为先看 Δincome 是否为 0
- 类1 扩到每级 5 条(4x5=20),轮转 min/max 三种组合
- income-3dp 改为五种尾数各一条(.333/.667/.999/.005/.347),用事务内造数钉死 income
- 幂等改为所有 case 跑两遍
- 报告新增 DEFECT/ENV-FAILURE 索引(按 caseId 一键定位)
Q 12 小時之前
父節點
當前提交
549b099e88

+ 30 - 17
internal/academic/dualrun/attribution.go

@@ -11,12 +11,14 @@ import (
 func severity(a Attribution) int {
 	switch a {
 	case AttrEnvFailure:
-		return 5
+		return 6
 	case AttrDefect:
-		return 4
+		return 5
 	case AttrOracleDegraded:
-		return 3
+		return 4
 	case AttrPathDivergence:
+		return 3
+	case AttrRoundingConvention:
 		return 2
 	case AttrPrecisionConvergence:
 		return 1
@@ -119,28 +121,34 @@ func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
 		Delta:  fmt.Sprintf("%+.3f", tgt.Reward-legacyReward),
 	}
 
+	deltaIncomeCents := centsOf(targetIncome) - centsOf(legacyIncome)
 	if centsOf(legacyReward) == centsOf(tgt.Reward) {
 		rewardRow.Attribution = AttrMatch
 		rows = append(rows, rewardRow)
 	} else {
-		// 用"差值是否成比例"区分基数不同 vs 算法不同
+		// 判据顺序(不可换):
+		//   ① Δincome ≠ 0 且金额成比例  ⇒ PRECISION-CONVERGENCE(基数变了)
+		//   ② Δincome = 0              ⇒ DEFECT(基数没变而金额变了 = 算法不同)
+		//   ③ 其余(不成比例)          ⇒ DEFECT
 		expectedDelta := s1.Delta * obs.Quote.Percentage / 100
 		actualDelta := tgt.Reward - legacyReward
 		tolerance := math.Max(0.015, math.Abs(expectedDelta)+0.015) // 1.5 分容差
-		if math.Abs(actualDelta-expectedDelta) <= tolerance {
+		proportional := math.Abs(actualDelta-expectedDelta) <= tolerance
+		switch {
+		case deltaIncomeCents != 0 && proportional:
 			rewardRow.Attribution = AttrPrecisionConvergence
-			rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f)", expectedDelta, actualDelta)
-			rows = append(rows, rewardRow)
+			rewardRow.Column += fmt.Sprintf("(Δincome=%+d分, Δ预期 %+.4f, 实测 %+.4f)", deltaIncomeCents, expectedDelta, actualDelta)
 			attr = worse(attr, AttrPrecisionConvergence)
-			if attr == AttrPathDivergence { // 精度收敛不应压下 path-divergence 之外的东西
-				attr = AttrPrecisionConvergence
-			}
-		} else {
+		case deltaIncomeCents == 0:
+			rewardRow.Attribution = AttrDefect
+			rewardRow.Column += fmt.Sprintf("(Δincome=0 而 Δreward=%+.4f ⇒ 基数相同、算法不同)", actualDelta)
+			attr = worse(attr, AttrDefect)
+		default:
 			rewardRow.Attribution = AttrDefect
-			rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f, 不成比例)", expectedDelta, actualDelta)
-			rows = append(rows, rewardRow)
+			rewardRow.Column += fmt.Sprintf("(Δincome=%+d分, Δ预期 %+.4f, 实测 %+.4f, 不成比例)", deltaIncomeCents, expectedDelta, actualDelta)
 			attr = worse(attr, AttrDefect)
 		}
+		rows = append(rows, rewardRow)
 	}
 
 	// ---- 逐行分摊(R13:不参与通过判定,但必须逐行记录)----
@@ -180,10 +188,15 @@ func attributePerRow(c Case, obs *OracleResult, tgt *TargetResult, caseAttr Attr
 			continue // 逐行相等就不刷行,避免报告噪声
 		}
 		rowAttr := AttrDefect
-		// PHP 逐行和不等于 record 级总额(不守恒),而 Go 精确守恒 ⇒ R13
-		if math.Abs(legacySum-totalLegacy) > 0.0005 && math.Abs(targetSum-totalLegacy) < 0.005 {
-			rowAttr = AttrPrecisionConvergence
-		} else if caseAttr == AttrPrecisionConvergence {
+		deltaIncomeCents := centsOf(tgt.Income) - centsOf(obs.Inputs.IncomeSumCost3)
+		switch {
+		case deltaIncomeCents == 0:
+			// Δincome = 0 而逐行不等 ⇒ 只可能是分摊方式不同(R13)
+			rowAttr = AttrRoundingConvention
+		case math.Abs(legacySum-totalLegacy) > 0.0005 && math.Abs(targetSum-totalLegacy) < 0.005:
+			// PHP 逐行和不等于 record 总额(不守恒)、Go 精确守恒 ⇒ 舍入口径 + 基数变化
+			rowAttr = AttrRoundingConvention
+		case caseAttr == AttrPrecisionConvergence:
 			rowAttr = AttrPrecisionConvergence
 		}
 		rows = append(rows, DiffRow{

+ 100 - 69
internal/academic/dualrun/cases.go

@@ -497,83 +497,114 @@ func BuildSyntheticCases(records []recordRow, r *refs, perCat int, now time.Time
 		return got
 	}
 
-	// ---- 类 1:四级兜底(5 条,覆盖四级)----
-	// teacher-grade-subject ×1 / generic-tier ×1 / teacher-classify ×2(造数)/ cert-tier ×1(造数)
-	gsPool := r.collect(records, now, func(f caseFacts) bool { return baseOK(f) && f.tier == TierTeacherGradeSubject })
-	genPool := r.collect(records, now, func(f caseFacts) bool { return baseOK(f) && f.tier == TierGeneric })
-	// 造 classify 级:需要该方案下无老师专属规则(oneself==0 也可,但插入后即 >0),
-	// 用 generic 池的记录最稳(说明当前没有老师专属规则能命中它)
-	classifyPool := r.collect(records, now, func(f caseFacts) bool {
-		return baseOK(f) && f.tier == TierGeneric && f.cls != nil && f.rec.teacherid > 0
-	})
-	certPool := r.collect(records, now, func(f caseFacts) bool {
-		return baseOK(f) && f.tier == TierGeneric && f.teacher.cert == 0 && f.cls != nil
-	})
-
 	add := func(c Case) { out = append(out, c) }
 
-	if got := take(gsPool, 1); len(got) == 1 {
-		f := got[0]
-		add(Case{ID: "syn-tier-grade-subject-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
-			ExpectTier:  TierTeacherGradeSubject,
-			Expectation: "现成记录即命中 teacher-grade-subject(无需造数)",
-			Note:        "四级兜底之一"})
-	} else {
-		notes = append(notes, "tier-teacher-grade-subject:无法造数(无现成命中记录)")
-	}
-	if got := take(genPool, 1); len(got) == 1 {
-		f := got[0]
-		add(Case{ID: "syn-tier-generic-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
-			ExpectTier:  TierGeneric,
-			Expectation: "现成记录即命中 generic-tier(无需造数)",
-			Note:        "四级兜底之一"})
-	} else {
-		notes = append(notes, "tier-generic:无法造数(无现成命中记录)")
-	}
-	// teacher-classify ×2:插入一条 classifyid=当前分类 的老师专属规则
-	for i, f := range take(classifyPool, 2) {
-		payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
-		add(Case{
-			ID: fmt.Sprintf("syn-tier-teacher-classify-%d", i+1), Kind: KindSynthetic, Category: CatTier,
-			RecordID: f.rec.id, ExpectTier: TierTeacherClassify,
-			Mutations:   []string{insertRuleSQL(payid, f.cls.classType, f.cls.id, f.rec.teacherid, 0, 33.0, 0, 0, 0, 0, 99, "ac15-syn-classify")},
-			Expectation: "造一条 teacherid=该老师 且 classifyid=该分类 的规则 ⇒ 应命中 teacher-classify",
-			Note:        "真实数据里该级命中数为 0,必须造数(P0 §13)",
-		})
+	// ---- 类 1:四级兜底,**每级各 perCat 条**(默认 5 × 4 = 20 条)----
+	//
+	// 为什么要每级 5 条而不是凑 5 条:四级是"最担心的未验证路径"(真实数据里两级 0 命中),
+	// 造数成本几乎为零;而 1~2 条测不出「有 min 无 max / 无 min 无 max / 有 max 无 min」这类组合差异 ——
+	// `min_payment=0 表示不设限` 这个语义正是 D10 踩过的坑。
+	tierSpecs := []struct {
+		tier string
+		pred func(f caseFacts) bool
+		note string
+	}{
+		{TierTeacherGradeSubject, func(f caseFacts) bool {
+			return baseOK(f) && f.tier == TierTeacherGradeSubject
+		}, "现成记录即命中 teacher-grade-subject(无需造数)"},
+		{TierGeneric, func(f caseFacts) bool {
+			return baseOK(f) && f.tier == TierGeneric
+		}, "现成记录即命中 generic-tier(无需造数)"},
+		{TierTeacherClassify, func(f caseFacts) bool {
+			return baseOK(f) && f.tier == TierGeneric && f.cls != nil && f.rec.teacherid > 0
+		}, "造一条 teacherid=该老师 且 classifyid=该分类 的规则 ⇒ 应命中 teacher-classify"},
+		{TierCert, func(f caseFacts) bool {
+			return baseOK(f) && f.tier == TierGeneric && f.teacher.cert == 0 && f.cls != nil
+		}, "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier"},
+	}
+	for _, spec := range tierSpecs {
+		pool := r.collect(records, now, spec.pred)
+		got := take(pool, perCat)
+		if len(got) < perCat {
+			notes = append(notes, fmt.Sprintf("tier-%s:候选只有 %d 条(要求 %d)", spec.tier, len(got), perCat))
+		}
+		for i, f := range got {
+			c := Case{
+				ID:   fmt.Sprintf("syn-tier-%s-%d", strings.ReplaceAll(spec.tier, "-", "_"), i+1),
+				Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
+				ExpectTier: spec.tier, Expectation: spec.note,
+				Note: "四级兜底之一(P0 §13:classify / cert 真实命中数为 0,必须造数)",
+			}
+			payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
+			switch spec.tier {
+			case TierTeacherClassify:
+				// 三种 min/max 组合轮转,覆盖「只设 min」「只设 max」「都不设」
+				minPay, maxPay := 0.0, 0.0
+				switch i % 3 {
+				case 0:
+					minPay = 80.0
+				case 1:
+					maxPay = 260.0
+				case 2:
+					minPay, maxPay = 90.0, 240.0
+				}
+				c.Mutations = []string{insertRuleSQL(payid, f.cls.classType, f.cls.id, f.rec.teacherid, 0,
+					33.0, minPay, maxPay, 0, 0, 99, "ac15-syn-classify")}
+				c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay)
+			case TierCert:
+				minPay, maxPay := 0.0, 0.0
+				switch i % 3 {
+				case 0:
+					minPay = 70.0
+				case 1:
+					maxPay = 300.0
+				case 2:
+					minPay, maxPay = 60.0, 280.0
+				}
+				c.Mutations = []string{
+					fmt.Sprintf("UPDATE t_teacher_information SET teacher_cert = b'1' WHERE teacherid = %d", f.rec.teacherid),
+					insertRuleSQL(payid, f.cls.classType, 0, 0, 1, 44.0, minPay, maxPay, 0, 0, 99, "ac15-syn-cert"),
+				}
+				c.Expectation += fmt.Sprintf("(min=%.0f max=%.0f)", minPay, maxPay)
+			}
+			add(c)
+		}
 	}
-	if len(classifyPool) < 2 {
-		notes = append(notes, fmt.Sprintf("tier-teacher-classify:候选只有 %d 条,不足 2 条", len(classifyPool)))
+
+	// ---- 类 2:income 带 3 位小数,**五种尾数各一条**----
+	//
+	// 五个尾数分别对应五种收敛方向,缺一种就等于没测:
+	//   .333 舍 / .667 入 / .999 跨整数进位 / .005 恰好半分 / .347 保留原样
+	tails := []struct {
+		total float64
+		tail  string
+		why   string
+	}{
+		{100.333, "333", "舍(100.333 → 100.33)"},
+		{200.667, "667", "入(200.667 → 200.67)"},
+		{299.999, "999", "跨整数进位(299.999 → 300.00),Δincome=+0.001 最易漏"},
+		{300.005, "005", "恰好半分(300.005 → 300.01),四舍五入边界"},
+		{349.347, "347", "保留原样(349.347 → 349.35)"},
+	}
+	pool3dpBase := r.collect(records, now, func(f caseFacts) bool {
+		return baseOK(f) && f.agg.elig >= 1
+	})
+	got3dp := take(pool3dpBase, len(tails))
+	if len(got3dp) < len(tails) {
+		notes = append(notes, fmt.Sprintf("income-3dp:候选只有 %d 条(要求 %d)", len(got3dp), len(tails)))
 	}
-	// cert-tier ×1:把老师置为持证 + 插入 teacher_cert=1 的通用档规则
-	if got := take(certPool, 1); len(got) == 1 {
-		f := got[0]
-		payid := r.resolvePayment(f.rec.schoolid, f.rec.campusid, f.rec.startTime)
+	for i, f := range got3dp {
+		t := tails[i]
 		add(Case{
-			ID: "syn-tier-cert-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
-			ExpectTier: TierCert,
+			ID: fmt.Sprintf("syn-income-3dp-%s", t.tail), Kind: KindSynthetic, Category: CatIncome3DP,
+			RecordID: f.rec.id, ExpectTier: f.tier,
 			Mutations: []string{
-				fmt.Sprintf("UPDATE t_teacher_information SET teacher_cert = b'1' WHERE teacherid = %d", f.rec.teacherid),
-				insertRuleSQL(payid, f.cls.classType, 0, 0, 1, 44.0, 0, 0, 0, 0, 99, "ac15-syn-cert"),
+				fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id),
+				fmt.Sprintf("UPDATE t_courses_record_student SET cost = %.3f WHERE recordid = '%s' AND type IN (1,2) ORDER BY studentid LIMIT 1", t.total, f.rec.id),
 			},
-			Expectation: "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier",
-			Note:        "真实数据里该级命中数为 0,必须造数(P0 §13)",
+			Expectation: fmt.Sprintf("事务内把 income 钉成 %.3f(尾数 .%s):%s ⇒ 迁移后 ΣROUND(cost,2) 与 legacy round(Σ,3) 必不同", t.total, t.tail, t.why),
+			Note:        "专门验证 PRECISION-CONVERGENCE 的五个收敛方向",
 		})
-	} else {
-		notes = append(notes, "tier-cert:无法造数(找不到「generic 命中 + 老师未持证」的记录)")
-	}
-
-	// ---- 类 2:income 带 3 位小数 ----
-	pool3dp := r.collect(records, now, func(f caseFacts) bool {
-		return baseOK(f) && hasThirdDecimal(f.agg.sumCost)
-	})
-	for i, f := range take(pool3dp, perCat) {
-		add(Case{ID: fmt.Sprintf("syn-income-3dp-%d", i+1), Kind: KindSynthetic, Category: CatIncome3DP,
-			RecordID: f.rec.id, ExpectTier: f.tier,
-			Expectation: fmt.Sprintf("Σcost=%.3f 有第三位小数 ⇒ 迁移后 ΣROUND(cost,2) 会变,用于验证 Δincome 归因", f.agg.sumCost),
-			Note:        "专门验证 PRECISION-CONVERGENCE"})
-	}
-	if len(pool3dp) < perCat {
-		notes = append(notes, fmt.Sprintf("income-3dp:候选只有 %d 条", len(pool3dp)))
 	}
 
 	// ---- 类 3:income=0 但 student_number>0(min_payment 夹取)----

+ 7 - 1
internal/academic/dualrun/model.go

@@ -22,6 +22,11 @@ const (
 	AttrMatch Attribution = "MATCH"
 	// AttrPrecisionConvergence 差异由 income 基数收敛引起(Δreward ≈ Δincome × pct/100)。
 	AttrPrecisionConvergence Attribution = "PRECISION-CONVERGENCE"
+	// AttrRoundingConvention Δincome = 0 但逐行分摊方式不同(R13:PHP 每行 round(,3) vs Go 分位分配+余数)。
+	//
+	// 与 AttrPrecisionConvergence 的区别是本质的:前者的 Δincome **恒为 0**,
+	// 差异只来自"怎么分";后者 Δincome ≠ 0,差异来自"基数不同"。合并统计会看不清。
+	AttrRoundingConvention Attribution = "ROUNDING-CONVENTION"
 	// AttrDefect 真缺陷(规则选错 / 差异与基数不成比例 / 公式不一致)。
 	AttrDefect Attribution = "DEFECT"
 	// AttrOracleDegraded oracle 侧走了退化分支(SP 无命中),不可比。
@@ -34,7 +39,8 @@ const (
 
 // AllAttributions 供汇总按固定顺序输出六类计数。
 var AllAttributions = []Attribution{
-	AttrMatch, AttrPrecisionConvergence, AttrDefect, AttrOracleDegraded, AttrPathDivergence, AttrEnvFailure,
+	AttrMatch, AttrPrecisionConvergence, AttrRoundingConvention, AttrDefect,
+	AttrOracleDegraded, AttrPathDivergence, AttrEnvFailure,
 }
 
 // CaseKind 区分真实基线与合成边界。

+ 30 - 0
internal/academic/dualrun/report.go

@@ -56,6 +56,36 @@ func WriteMarkdown(r *Report, path string) error {
 	}
 	fmt.Fprintf(&b, "| **合计** | **%d** |\n\n", total)
 
+	// DEFECT / ENV-FAILURE 一栏必须能"一键定位到 caseId"(验收要看这一栏)
+	b.WriteString("## 2.1 DEFECT / ENV-FAILURE 索引(按 caseId 定位)\n\n")
+	type hit struct{ caseID, attr, where string }
+	var hits []hit
+	for _, cr := range r.Cases {
+		if cr.Attribution != AttrDefect && cr.Attribution != AttrEnvFailure {
+			continue
+		}
+		where := "-"
+		for _, row := range cr.Rows {
+			if row.Attribution == AttrDefect || row.Attribution == AttrEnvFailure {
+				where = fmt.Sprintf("%s.%s(legacy=%s target=%s Δ=%s)", row.Table, row.Column, row.Legacy, row.Target, row.Delta)
+				break
+			}
+		}
+		if where == "-" && cr.Note != "" {
+			where = cr.Note
+		}
+		hits = append(hits, hit{cr.CaseID, string(cr.Attribution), where})
+	}
+	if len(hits) == 0 {
+		b.WriteString("(无)\n\n")
+	} else {
+		b.WriteString("| caseId | 归因 | 位置与数值 |\n|---|---|---|\n")
+		for _, h := range hits {
+			fmt.Fprintf(&b, "| **%s** | %s | %s |\n", h.caseID, h.attr, h.where)
+		}
+		b.WriteString("\n")
+	}
+
 	if r.DryRun {
 		b.WriteString("## 3. dry-run 用例清单(未执行)\n\n")
 		fmt.Fprintf(&b, "| caseId | kind | category | recordId | isSupplement | expectTier | 造数 | 期望 |\n|---|---|---|---|---|---|---|---|\n")

+ 2 - 6
internal/academic/dualrun/run.go

@@ -170,14 +170,10 @@ func RunStage1(ctx context.Context, cfg Config, opts Options) (*Report, error) {
 		report.Cases = append(report.Cases, res...)
 	}
 
-	// ---- 幂等:抽样重跑(默认前 3 条 + 全部合成里的第一条)----
+	// ---- 幂等:**所有 case 各跑第二遍**(抽样会漏,差异是逐 case 的)----
 	if opts.Idempotency {
 		for i, c := range cases {
-			if i >= 3 && i%len(cases) != 0 {
-				if i >= 3 && i != len(cases)-1 {
-					continue
-				}
-			}
+			_ = i
 			first := collectRows(report.Cases, c.ID)
 			again := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
 			second := collectRows(again, c.ID)