Kaynağa Gözat

feat(ac15): 双跑 harness 阶段1 骨架(课酬链)

范围:只比对 sp_course_payment_detail_get_teacher_detail + settlementExpenses

- oracle:起 PHP 进程真跑结算,事务内 + finally 回滚,读 A24 异常前已写入的库值
- target:只调纯函数 computeRecordReward/distributeAmount,不连 yuansiwei_2026
- 守卫:host/库 双白名单(违反 panic)、sysdate 静态断言必须为 0
- 归因:三步分层(income → 规则 → teacher_reward),成比例判据区分精度收敛与真缺陷
- 路径证据:detail_id/层级/退化/isSupplement/rewardType(约束 17)
- 每 case 新连接(MEMORY 临时表行不参与回滚)
- 用例:real100 分层抽样 + 6 类×5 合成边界(classify/cert 靠事务内造数)
- 输出 JSON + Markdown;支持 --dry-run / --idempotency

service 包新增 dualrun_shim.go:仅转发 computeRecordReward/distributeAmount,
避免 harness 另写一份公式导致自证(不改任何既有文件)
Q 8 saat önce
ebeveyn
işleme
abda2fac86

+ 121 - 0
cmd/dualrun/main.go

@@ -0,0 +1,121 @@
+// Command dualrun 是 AC-15 双跑 harness 的 CLI 入口。
+//
+// 用法:
+//
+//	dualrun stage1 --case-set real100                 # 真实基线 100 条
+//	dualrun stage1 --case-set synthetic               # 合成边界 6 类 × 5
+//	dualrun stage1 --all --out report.json --md report.md
+//	dualrun stage1 --dry-run                          # 只列 case 不执行
+//	dualrun stage1 --all --idempotency                # 额外做幂等验证
+//
+// 环境变量(口令只经环境变量,不落盘):
+//
+//	AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS
+//	AC15_LEGACY_REPO   legacy 应用根目录(默认 E:/study/php/ysw-finance/yuansiwei-finance)
+//	AC15_PHP_BIN       php 可执行(默认 php)
+//
+// 退出码:0 = 跑完(无论结论如何);2 = 参数错误;3 = 环境层面失败(守卫/断言/连接)。
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"os"
+
+	"yuansiwei/unified-backend/internal/academic/dualrun"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		usage()
+		os.Exit(2)
+	}
+	switch os.Args[1] {
+	case "stage1":
+		os.Exit(runStage1(os.Args[2:]))
+	case "-h", "--help", "help":
+		usage()
+		os.Exit(0)
+	default:
+		fmt.Fprintf(os.Stderr, "未知子命令:%s\n\n", os.Args[1])
+		usage()
+		os.Exit(2)
+	}
+}
+
+func usage() {
+	fmt.Fprint(os.Stderr, `dualrun —— AC-15 双跑 harness(阶段 1:课酬链)
+
+子命令:
+  stage1    跑课酬链比对
+
+stage1 参数:
+  --case-set string   real100 | synthetic | all(默认 all)
+  --all               等价于 --case-set all
+  --dry-run           只列 case 不执行
+  --out string        JSON 报告路径(默认 dualrun-stage1.json)
+  --md string         Markdown 报告路径(默认 dualrun-stage1.md)
+  --idempotency       额外做幂等验证(同 case 连跑两次)
+  --real-count int    真实基线条数(默认 100)
+  --per-cat int       合成每类条数(默认 5)
+`)
+}
+
+func runStage1(args []string) int {
+	fs := flag.NewFlagSet("stage1", flag.ContinueOnError)
+	fs.SetOutput(os.Stderr)
+	caseSet := fs.String("case-set", "all", "real100 | synthetic | all")
+	all := fs.Bool("all", false, "等价于 --case-set all")
+	dryRun := fs.Bool("dry-run", false, "只列 case 不执行")
+	out := fs.String("out", "dualrun-stage1.json", "JSON 报告路径")
+	md := fs.String("md", "dualrun-stage1.md", "Markdown 报告路径")
+	idem := fs.Bool("idempotency", false, "幂等验证")
+	realCount := fs.Int("real-count", 100, "真实基线条数")
+	perCat := fs.Int("per-cat", 5, "合成每类条数")
+	if err := fs.Parse(args); err != nil {
+		return 2
+	}
+	if *all {
+		*caseSet = "all"
+	}
+
+	cfg := dualrun.LoadConfigFromEnv()
+
+	// 守卫违反会 panic(验收要求),这里转成清晰的退出码与提示
+	exit := 0
+	func() {
+		defer func() {
+			if r := recover(); r != nil {
+				fmt.Fprintf(os.Stderr, "FATAL: %v\n", r)
+				exit = 3
+			}
+		}()
+		rep, err := dualrun.RunStage1(context.Background(), cfg, dualrun.Options{
+			CaseSet:     *caseSet,
+			DryRun:      *dryRun,
+			OutJSON:     *out,
+			OutMD:       *md,
+			Idempotency: *idem,
+			RealCount:   *realCount,
+			PerCat:      *perCat,
+		})
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "FATAL: %v\n", err)
+			exit = 3
+			return
+		}
+		fmt.Printf("cases=%d  dryRun=%v\n", len(rep.Cases)+len(rep.DryRunCases), rep.DryRun)
+		for _, a := range dualrun.AllAttributions {
+			if n := rep.Summary[a]; n > 0 {
+				fmt.Printf("  %-24s %d\n", a, n)
+			}
+		}
+		if rep.DryRun {
+			fmt.Printf("dry-run 清单已写入 %s / %s\n", *out, *md)
+		} else {
+			fmt.Printf("报告已写入 %s / %s\n", *out, *md)
+		}
+	}()
+	return exit
+}

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

@@ -0,0 +1,119 @@
+# `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
+
+```bash
+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 自己再写一份公式:那会变成"用第二份实现校验第一份",
+一旦两份都错、错法一致,双跑会给出全绿结论 —— 这正是要避免的自证。

+ 206 - 0
internal/academic/dualrun/attribution.go

@@ -0,0 +1,206 @@
+package dualrun
+
+import (
+	"fmt"
+	"math"
+)
+
+// severity 把归因折算成"严重度",用于把一条 case 的多行结论合成一条 case 结论。
+//
+// 取最大值(越严重越优先),保证"有一条真缺陷"不会被"其余都 MATCH"淹没。
+func severity(a Attribution) int {
+	switch a {
+	case AttrEnvFailure:
+		return 5
+	case AttrDefect:
+		return 4
+	case AttrOracleDegraded:
+		return 3
+	case AttrPathDivergence:
+		return 2
+	case AttrPrecisionConvergence:
+		return 1
+	default:
+		return 0
+	}
+}
+
+// worse 返回两者中更严重的归因。
+func worse(a, b Attribution) Attribution {
+	if severity(b) > severity(a) {
+		return b
+	}
+	return a
+}
+
+// AttrInput 是归因所需的一切(避免把 refs 传进纯函数)。
+type AttrInput struct {
+	Case Case
+	// Oracle oracle 侧观测。
+	Oracle *OracleResult
+	// Target target 侧纯函数结果。
+	Target *TargetResult
+	// PredictedDetailID / PredictedTier 是 runner 复刻的兜底判定(用于校验 SP 实返是否与理解一致)。
+	PredictedDetailID int64
+	PredictedTier     string
+}
+
+// Attribute 执行**三步分层归因**(顺序不可换),返回逐行差异与 case 结论。
+//
+// 第 1 步 income:legacy `round(Σcost,3)` vs 迁移后 `Σ ROUND(cost,2)` —— 不等属精度收敛,继续往下看。
+// 第 2 步 规则:oracle 实返的 detail_id 与 runner 复刻判定比对 —— 不等是真缺陷(规则选错/层级错)。
+// 第 3 步 teacher_reward:不等时用 `Δreward ≈ Δincome × pct/100` 判断是基数不同还是算法不同。
+func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
+	obs, tgt, c := in.Oracle, in.Target, in.Case
+
+	// ---- 环境层面:拿不到观测就不判定 ----
+	if obs == nil || tgt == nil {
+		return []DiffRow{{
+			CaseID: c.ID, Table: "-", PK: c.RecordID, Column: "-",
+			Legacy: "-", Target: "-", Delta: "-", Attribution: AttrEnvFailure,
+		}}, AttrEnvFailure, nil
+	}
+	if !obs.RollbackRestored {
+		return []DiffRow{{
+			CaseID: c.ID, Table: "t_courses_record", PK: c.RecordID, Column: "(rollback)",
+			Legacy: "restored", Target: "NOT restored", Delta: "-", Attribution: AttrEnvFailure,
+		}}, AttrEnvFailure, nil
+	}
+
+	// ---- 第 1 步:income ----
+	legacyIncome := obs.Inputs.IncomeSumCost3
+	targetIncome := tgt.Income
+	s1 := &Step1Income{
+		Legacy: legacyIncome,
+		Target: targetIncome,
+		Delta:  math.Round((targetIncome-legacyIncome)*1000000) / 1000000,
+	}
+	s1.EqualCents = centsOf(legacyIncome) == centsOf(targetIncome)
+	s1.Explanation = fmt.Sprintf("legacy round(Σcost,3)=%.3f vs 迁移后 ΣROUND(cost,2)=%.3f", legacyIncome, targetIncome)
+
+	// ---- oracle 退化:不可比,直接标记(R14)----
+	if obs.Quote == nil {
+		return []DiffRow{{
+			CaseID: c.ID, Table: "t_course_payment_detail", PK: c.RecordID, Column: "detail_id",
+			Legacy: "未命中(退化分支)", Target: fmt.Sprintf("%.2f (无方案→0)", tgt.Reward),
+			Delta: "-", Attribution: AttrOracleDegraded,
+		}}, AttrOracleDegraded, s1
+	}
+
+	// ---- 第 2 步:规则命中是否与复刻判定一致 ----
+	var rows []DiffRow
+	attr := AttrMatch
+	actualDetail := int64(obs.Quote.DetailID)
+	if in.PredictedDetailID != 0 && actualDetail != in.PredictedDetailID {
+		rows = append(rows, DiffRow{
+			CaseID: c.ID, Table: "t_course_payment_detail", PK: c.RecordID, Column: "detail_id",
+			Legacy: fmt.Sprintf("%d", actualDetail), Target: fmt.Sprintf("%d", in.PredictedDetailID),
+			Delta: "-", Attribution: AttrDefect,
+		})
+		attr = worse(attr, AttrDefect)
+	}
+	if c.ExpectTier != "" && in.PredictedTier != "" && c.ExpectTier != in.PredictedTier {
+		rows = append(rows, DiffRow{
+			CaseID: c.ID, Table: "path", PK: c.RecordID, Column: "tier",
+			Legacy: in.PredictedTier, Target: c.ExpectTier, Delta: "-", Attribution: AttrPathDivergence,
+		})
+		attr = worse(attr, AttrPathDivergence)
+	}
+
+	// ---- 第 3 步:teacher_reward ----
+	legacyReward := 0.0
+	if obs.After != nil {
+		legacyReward = obs.After.TeacherReward
+	}
+	rewardRow := DiffRow{
+		CaseID: c.ID, Table: "t_courses_record", PK: c.RecordID, Column: "teacher_reward",
+		Legacy: fmt.Sprintf("%.3f", legacyReward),
+		Target: fmt.Sprintf("%.2f", tgt.Reward),
+		Delta:  fmt.Sprintf("%+.3f", tgt.Reward-legacyReward),
+	}
+
+	if centsOf(legacyReward) == centsOf(tgt.Reward) {
+		rewardRow.Attribution = AttrMatch
+		rows = append(rows, rewardRow)
+	} else {
+		// 用"差值是否成比例"区分基数不同 vs 算法不同
+		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 {
+			rewardRow.Attribution = AttrPrecisionConvergence
+			rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f)", expectedDelta, actualDelta)
+			rows = append(rows, rewardRow)
+			attr = worse(attr, AttrPrecisionConvergence)
+			if attr == AttrPathDivergence { // 精度收敛不应压下 path-divergence 之外的东西
+				attr = AttrPrecisionConvergence
+			}
+		} else {
+			rewardRow.Attribution = AttrDefect
+			rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f, 不成比例)", expectedDelta, actualDelta)
+			rows = append(rows, rewardRow)
+			attr = worse(attr, AttrDefect)
+		}
+	}
+
+	// ---- 逐行分摊(R13:不参与通过判定,但必须逐行记录)----
+	rows = append(rows, attributePerRow(c, obs, tgt, attr)...)
+	return rows, attr, s1
+}
+
+// attributePerRow 逐行比对;差异按 R13 归因(PHP 每行 round(,3) 不守恒 vs Go 分位分配守恒)。
+func attributePerRow(c Case, obs *OracleResult, tgt *TargetResult, caseAttr Attribution) []DiffRow {
+	legacy := map[int64]float64{}
+	for _, s := range obs.StudentsAfter {
+		if s.Type == 1 || s.Type == 2 {
+			legacy[s.StudentID] = s.TeacherReward
+		}
+	}
+	var rows []DiffRow
+	legacySum := 0.0
+	for _, v := range legacy {
+		legacySum += v
+	}
+	targetSum := 0.0
+	for _, v := range tgt.PerRow {
+		targetSum += v
+	}
+	totalLegacy := 0.0
+	if obs.After != nil {
+		totalLegacy = obs.After.TeacherReward
+	}
+
+	for i, sid := range tgt.RowStudents {
+		lv, ok := legacy[sid]
+		if !ok {
+			continue
+		}
+		tv := tgt.PerRow[i]
+		if centsOf(lv) == centsOf(tv) {
+			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 {
+			rowAttr = AttrPrecisionConvergence
+		}
+		rows = append(rows, DiffRow{
+			CaseID: c.ID, Table: "t_courses_record_student", PK: fmt.Sprintf("%s/%d", c.RecordID, sid),
+			Column: "teacher_reward",
+			Legacy: fmt.Sprintf("%.3f", lv), Target: fmt.Sprintf("%.2f", tv),
+			Delta: fmt.Sprintf("%+.3f", tv-lv), Attribution: rowAttr,
+		})
+	}
+	return rows
+}
+
+// CaseAttribution 把一行行结论合成 case 结论(取最严重)。
+func CaseAttribution(rows []DiffRow, fallback Attribution) Attribution {
+	out := fallback
+	for _, r := range rows {
+		out = worse(out, r.Attribution)
+	}
+	return out
+}

+ 694 - 0
internal/academic/dualrun/cases.go

@@ -0,0 +1,694 @@
+package dualrun
+
+import (
+	"database/sql"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+)
+
+// 兜底层级(与 SP 的四级兜底一一对应)。
+const (
+	TierTeacherClassify     = "teacher-classify"      // 老师 + 具体分类
+	TierTeacherGradeSubject = "teacher-grade-subject" // 老师 + 年级/科目
+	TierCert                = "cert-tier"             // 持证老师 + 教龄档
+	TierGeneric             = "generic-tier"          // 通用 + 教龄档
+	TierDegraded            = "degraded"              // SP 无命中 ⇒ PHP 走 legacy 退化分支
+	TierNone                = "none"
+)
+
+// 用例类别。
+const (
+	CatBaseline      = "baseline"
+	CatTier          = "tier"
+	CatIncome3DP     = "income-3dp"
+	CatIncomeZeroMin = "income-zero-min"
+	CatSupplement    = "supplement-both"
+	CatNonDivisible  = "nondivisible"
+	CatRewardType2   = "rewardtype2"
+)
+
+// ---------- 内存参照表(小表全量装载,避免 74,870 次相关子查询)----------
+
+type payRow struct {
+	id       int64
+	schoolid int64
+	campusid int64
+	actived  int64
+	effect   time.Time
+}
+
+type clsRow struct {
+	id        int64
+	gradeid   int64
+	subjectid int64
+	classType int64
+}
+
+type teacherRow struct {
+	exp    time.Time
+	cert   int64
+	hasExp bool
+}
+
+type detailRow struct {
+	id         int64
+	paymentid  int64
+	teacherid  int64
+	cert       int64
+	classType  int64
+	gradeid    int64
+	subjectid  int64
+	expMin     float64
+	expMax     float64
+	classifyid int64
+	percentage float64
+	minPay     float64
+	maxPay     float64
+	reward     float64
+}
+
+type aggRow struct {
+	sumCost float64
+	elig    int
+	rows    int
+}
+
+type refs struct {
+	pays        []payRow
+	classifies  map[int64]*clsRow
+	courseClass map[int64]int64
+	teachers    map[int64]teacherRow
+	details     map[int64][]detailRow // paymentid → 规则
+	detailByID  map[int64]detailRow
+	studentAgg  map[string]aggRow // recordid → 打卡聚合
+}
+
+func loadRefs(db *sql.DB, schema string) (*refs, error) {
+	r := &refs{
+		classifies:  map[int64]*clsRow{},
+		courseClass: map[int64]int64{},
+		teachers:    map[int64]teacherRow{},
+		details:     map[int64][]detailRow{},
+		detailByID:  map[int64]detailRow{},
+		studentAgg:  map[string]aggRow{},
+	}
+
+	rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), CAST(IFNULL(actived,0) AS UNSIGNED), " +
+		"DATE_FORMAT(IFNULL(effect_date,'1970-01-01'),'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_course_payment")
+	if err != nil {
+		return nil, fmt.Errorf("load payments: %w", err)
+	}
+	for rows.Next() {
+		var p payRow
+		var eff string
+		if err := rows.Scan(&p.id, &p.schoolid, &p.campusid, &p.actived, &eff); err == nil {
+			p.effect, _ = time.ParseInLocation("2006-01-02 15:04:05", eff, time.Local)
+			r.pays = append(r.pays, p)
+		}
+	}
+	rows.Close()
+
+	if rows, err = db.Query("SELECT id, IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(class_type,0) FROM " + schema + ".t_courses_classify"); err == nil {
+		for rows.Next() {
+			var c clsRow
+			if err := rows.Scan(&c.id, &c.gradeid, &c.subjectid, &c.classType); err == nil {
+				cc := c
+				r.classifies[c.id] = &cc
+			}
+		}
+		rows.Close()
+	} else {
+		return nil, fmt.Errorf("load classify: %w", err)
+	}
+
+	if rows, err = db.Query("SELECT id, IFNULL(classifyid,0) FROM " + schema + ".t_courses"); err == nil {
+		for rows.Next() {
+			var id, cid int64
+			if err := rows.Scan(&id, &cid); err == nil {
+				r.courseClass[id] = cid
+			}
+		}
+		rows.Close()
+	} else {
+		return nil, fmt.Errorf("load courses: %w", err)
+	}
+
+	if rows, err = db.Query("SELECT teacherid, IFNULL(DATE_FORMAT(teacher_exp,'%Y-%m-%d %H:%i:%s'),''), " +
+		"CAST(IFNULL(teacher_cert,0) AS UNSIGNED) FROM " + schema + ".t_teacher_information"); err == nil {
+		for rows.Next() {
+			var id int64
+			var expStr string
+			var t teacherRow
+			if err := rows.Scan(&id, &expStr, &t.cert); err == nil {
+				if expStr != "" {
+					t.exp, _ = time.ParseInLocation("2006-01-02 15:04:05", expStr, time.Local)
+					t.hasExp = true
+				}
+				r.teachers[id] = t
+			}
+		}
+		rows.Close()
+	} else {
+		return nil, fmt.Errorf("load teachers: %w", err)
+	}
+
+	rows, err = db.Query("SELECT id, course_paymentid, IFNULL(teacherid,0), CAST(IFNULL(teacher_cert,0) AS UNSIGNED), " +
+		"IFNULL(class_type,0), IFNULL(gradeid,-1), IFNULL(subjectid,-1), IFNULL(teacher_exp_min,0), IFNULL(teacher_exp_max,0), " +
+		"IFNULL(classifyid,0), IFNULL(percentage,0), IFNULL(min_payment,0), IFNULL(max_payment,0), IFNULL(reward,0) FROM " +
+		schema + ".t_course_payment_detail")
+	if err != nil {
+		return nil, fmt.Errorf("load rules: %w", err)
+	}
+	for rows.Next() {
+		var d detailRow
+		if err := rows.Scan(&d.id, &d.paymentid, &d.teacherid, &d.cert, &d.classType, &d.gradeid, &d.subjectid,
+			&d.expMin, &d.expMax, &d.classifyid, &d.percentage, &d.minPay, &d.maxPay, &d.reward); err == nil {
+			r.details[d.paymentid] = append(r.details[d.paymentid], d)
+			r.detailByID[d.id] = d
+		}
+	}
+	rows.Close()
+
+	rows, err = db.Query("SELECT recordid, IFNULL(SUM(cost),0), SUM(type IN (1,2)), COUNT(*) FROM " +
+		schema + ".t_courses_record_student GROUP BY recordid")
+	if err != nil {
+		return nil, fmt.Errorf("load student agg: %w", err)
+	}
+	for rows.Next() {
+		var rid string
+		var a aggRow
+		if err := rows.Scan(&rid, &a.sumCost, &a.elig, &a.rows); err == nil {
+			r.studentAgg[rid] = a
+		}
+	}
+	rows.Close()
+
+	return r, nil
+}
+
+// resolvePayment 复刻 SP 的两级找方案:先 (school,campus),再 (school,0)。
+func (r *refs) resolvePayment(schoolid, campusid int64, at time.Time) int64 {
+	best, varEff := int64(0), time.Time{}
+	for _, p := range r.pays {
+		if p.schoolid != schoolid || p.actived != 1 || p.campusid != campusid || p.effect.After(at) {
+			continue
+		}
+		if best == 0 || p.effect.After(varEff) {
+			best, varEff = p.id, p.effect
+		}
+	}
+	if best > 0 {
+		return best
+	}
+	for _, p := range r.pays {
+		if p.schoolid != schoolid || p.actived != 1 || p.campusid != 0 || p.effect.After(at) {
+			continue
+		}
+		if best == 0 || p.effect.After(varEff) {
+			best, varEff = p.id, p.effect
+		}
+	}
+	return best
+}
+
+// hitRule 复刻 sp_course_payment_detail_get_teacher_detail 的判定顺序,返回 (规则, 层级)。
+//
+// 顺序(与 SP 一致,不可换):
+//  1. teacher-classify     老师专属 + classifyid 精确匹配
+//  2. teacher-grade-subject 老师专属 + 年级/科目(含 -1 通配)
+//  3. cert-tier            持证 + 教龄档
+//  4. generic-tier         通用 + 教龄档
+//
+// 注意 SP 用 `id>1` 才采纳(历史怪癖),这里照抄。
+func (r *refs) hitRule(schoolid, campusid, teacherid int64, at time.Time, cls *clsRow, teacherExp float64, teacherCert int64) (detailRow, string, bool) {
+	if cls == nil {
+		return detailRow{}, TierNone, false
+	}
+	payid := r.resolvePayment(schoolid, campusid, at)
+	if payid <= 0 {
+		return detailRow{}, TierDegraded, false
+	}
+	if teacherExp < 0 {
+		teacherExp = 0
+	}
+	oneself := 0
+	for _, d := range r.details[payid] {
+		if d.teacherid == teacherid && d.classType == cls.classType {
+			oneself++
+		}
+	}
+	find := func(match func(d detailRow) bool) (detailRow, bool) {
+		var best detailRow
+		found := false
+		for _, d := range r.details[payid] {
+			if d.classType != cls.classType || !match(d) {
+				continue
+			}
+			if !found || d.id < best.id {
+				best, found = d, true
+			}
+		}
+		if found && best.id > 1 {
+			return best, true
+		}
+		return detailRow{}, false
+	}
+	if oneself > 0 {
+		if d, ok := find(func(d detailRow) bool { return d.teacherid == teacherid && d.classifyid == cls.id }); ok {
+			return d, TierTeacherClassify, true
+		}
+		if d, ok := find(func(d detailRow) bool {
+			return d.teacherid == teacherid &&
+				(d.gradeid == cls.gradeid || d.gradeid == -1) &&
+				(d.subjectid == cls.subjectid || d.subjectid == -1)
+		}); ok {
+			return d, TierTeacherGradeSubject, true
+		}
+	}
+	if teacherCert > 0 {
+		if d, ok := find(func(d detailRow) bool {
+			return d.teacherid == 0 && d.cert == 1 &&
+				(d.gradeid == cls.gradeid || d.gradeid == -1) &&
+				(d.subjectid == cls.subjectid || d.subjectid == -1) &&
+				teacherExp >= d.expMin && teacherExp <= d.expMax
+		}); ok {
+			return d, TierCert, true
+		}
+	}
+	if d, ok := find(func(d detailRow) bool {
+		return d.teacherid == 0 && d.cert == 0 &&
+			(d.gradeid == cls.gradeid || d.gradeid == -1) &&
+			(d.subjectid == cls.subjectid || d.subjectid == -1) &&
+			teacherExp >= d.expMin && teacherExp <= d.expMax
+	}); ok {
+		return d, TierGeneric, true
+	}
+	return detailRow{}, TierDegraded, false
+}
+
+// teacherExpYears 与 PHP `Utils::calculatingAge()` 同口径:固定 365 天/年、四舍五入 1 位。
+//
+// ⚠ 这里用 runner 自己的 `now` 计算,只用于**选 case 与命中预判**;
+// 真正参与 oracle 计算的教龄由 PHP 侧 `time()` 决定(A22:PHP 时钟不受 SET timestamp 影响)。
+func teacherExpYears(t teacherRow, now time.Time) float64 {
+	if !t.hasExp || t.exp.Year() <= 1970 {
+		return 0
+	}
+	years := now.Sub(t.exp).Hours() / 24 / 365
+	return float64(int(years*10+0.5)) / 10
+}
+
+// recordRow 是挑选 case 时需要的 record 字段。
+type recordRow struct {
+	id         string
+	schoolid   int64
+	campusid   int64
+	teacherid  int64
+	coursesid  int64
+	rewardType int8
+	stuNumber  int
+	status     int
+	income     float64
+	startTime  time.Time
+}
+
+// loadRecords 流式扫描 record(只取必要列)。
+func loadRecords(db *sql.DB, schema string) ([]recordRow, error) {
+	rows, err := db.Query("SELECT id, schoolid, IFNULL(campusid,0), teacherid, coursesid, " +
+		"IFNULL(teacher_reward_type,1), IFNULL(student_number,0), IFNULL(status,0), IFNULL(income,0), " +
+		"DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') FROM " + schema + ".t_courses_record " +
+		"WHERE teacherid>0 AND actual_start_time IS NOT NULL AND coursesid IS NOT NULL ORDER BY id")
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []recordRow
+	for rows.Next() {
+		var rec recordRow
+		var st string
+		if err := rows.Scan(&rec.id, &rec.schoolid, &rec.campusid, &rec.teacherid, &rec.coursesid,
+			&rec.rewardType, &rec.stuNumber, &rec.status, &rec.income, &st); err == nil {
+			rec.startTime, _ = time.ParseInLocation("2006-01-02 15:04:05", st, time.Local)
+			out = append(out, rec)
+		}
+	}
+	return out, rows.Err()
+}
+
+// caseFacts 是一条 record 在"选 case"阶段算出的全部判定依据。
+type caseFacts struct {
+	rec      recordRow
+	cls      *clsRow
+	teacher  teacherRow
+	expYears float64
+	agg      aggRow
+	rule     detailRow
+	tier     string
+	hit      bool
+}
+
+// buildFacts 给一条 record 补全判定依据。
+func (r *refs) buildFacts(rec recordRow, now time.Time) caseFacts {
+	cls := r.classifies[r.courseClass[rec.coursesid]]
+	t := r.teachers[rec.teacherid]
+	exp := teacherExpYears(t, now)
+	rule, tier, ok := r.hitRule(rec.schoolid, rec.campusid, rec.teacherid, rec.startTime, cls, exp, t.cert)
+	return caseFacts{
+		rec: rec, cls: cls, teacher: t, expYears: exp,
+		agg: r.studentAgg[rec.id], rule: rule, tier: tier, hit: ok,
+	}
+}
+
+// ---------- 真实基线:分层抽样 ----------
+
+func pctBand(d detailRow) string {
+	switch {
+	case d.percentage <= 25:
+		return "pct<=25"
+	case d.percentage <= 50:
+		return "pct26-50"
+	default:
+		return "pct>50"
+	}
+}
+
+func sizeBand(elig int) string {
+	switch {
+	case elig <= 1:
+		return "n1"
+	case elig <= 5:
+		return "n2-5"
+	default:
+		return "n6+"
+	}
+}
+
+// BuildRealCases 按「percentage 区间 × 人数区间」分层抽样 n 条真实基线。
+//
+// 分层而不是取前 n 条:前 n 条集中在某个校区/某位老师,会给出"看起来很整齐"的假结论。
+func BuildRealCases(records []recordRow, r *refs, n int, now time.Time) []Case {
+	buckets := map[string][]caseFacts{}
+	var keys []string
+	for _, rec := range records {
+		if rec.status != 2 {
+			continue // 已结课
+		}
+		f := r.buildFacts(rec, now)
+		if !f.hit || f.agg.sumCost <= 0 || f.agg.elig <= 0 {
+			continue // 无命中 / 无收入 / 无有效学生 ⇒ 不进基线
+		}
+		k := pctBand(f.rule) + "|" + sizeBand(f.agg.elig)
+		if _, ok := buckets[k]; !ok {
+			keys = append(keys, k)
+		}
+		buckets[k] = append(buckets[k], f)
+	}
+	sort.Strings(keys)
+
+	// 桶内按 record id 排序(稳定),桶间轮转取数 ⇒ 分层且可复现
+	for _, k := range keys {
+		sort.Slice(buckets[k], func(i, j int) bool { return buckets[k][i].rec.id < buckets[k][j].rec.id })
+	}
+	var out []Case
+	idx := make([]int, len(keys))
+	for len(out) < n {
+		progressed := false
+		for bi, k := range keys {
+			if idx[bi] >= len(buckets[k]) {
+				continue
+			}
+			f := buckets[k][idx[bi]]
+			idx[bi]++
+			progressed = true
+			out = append(out, Case{
+				ID:         fmt.Sprintf("real-%04d", len(out)+1),
+				Kind:       KindReal,
+				Category:   CatBaseline,
+				RecordID:   f.rec.id,
+				ExpectTier: f.tier,
+				Expectation: fmt.Sprintf("分层桶 %s,期望 %s 命中;income=%.3f,人数=%d",
+					k, f.tier, f.agg.sumCost, f.agg.elig),
+				Note: "真实基线:status=2 且 income>0 且有规则命中",
+			})
+			if len(out) >= n {
+				break
+			}
+		}
+		if !progressed {
+			break
+		}
+	}
+	return out
+}
+
+// ---------- 合成边界 ----------
+
+// synPool 收集某一特征下的候选 record(按 id 升序,保证可复现)。
+type synPool struct {
+	facts []caseFacts
+}
+
+func (r *refs) collect(records []recordRow, now time.Time, pred func(caseFacts) bool) []caseFacts {
+	var out []caseFacts
+	for _, rec := range records {
+		f := r.buildFacts(rec, now)
+		if pred(f) {
+			out = append(out, f)
+		}
+	}
+	sort.Slice(out, func(i, j int) bool { return out[i].rec.id < out[j].rec.id })
+	return out
+}
+
+// insertRuleSQL 造一条规则(事务内插入,随回滚消失)。
+func insertRuleSQL(paymentID int64, classType, classifyID, teacherID int64, teacherCert int, pct, minPay, maxPay, reward float64, expMin, expMax float64, name string) string {
+	return fmt.Sprintf("INSERT INTO t_course_payment_detail "+
+		"(course_paymentid, gradeid, subjectid, class_type, classifyid, teacherid, name, percentage, min_payment, max_payment, reward, teacher_cert, teacher_exp_min, teacher_exp_max, actived) "+
+		"VALUES (%d, -1, -1, %d, %d, %d, '%s', %.1f, %.1f, %.1f, %.1f, b'%d', %.3f, %.3f, b'1')",
+		paymentID, classType, classifyID, teacherID, name, pct, minPay, maxPay, reward, teacherCert, expMin, expMax)
+}
+
+// BuildSyntheticCases 造 6 类 × perCat 条合成边界。
+//
+// 每类 5 条(perCat=5),其中 tier 类覆盖四级兜底:teacher-grade-subject / generic-tier 直接选现成记录,
+// teacher-classify / cert-tier 真实命中数为 0,**必须靠事务内造规则**才可能命中(见 P0 §13)。
+func BuildSyntheticCases(records []recordRow, r *refs, perCat int, now time.Time) ([]Case, []string) {
+	var out []Case
+	var notes []string
+	used := map[string]bool{}
+
+	baseOK := func(f caseFacts) bool {
+		return f.hit && f.agg.sumCost > 0 && f.agg.elig > 0 && f.rec.status == 2 && !used[f.rec.id]
+	}
+	take := func(pool []caseFacts, n int) []caseFacts {
+		var got []caseFacts
+		for _, f := range pool {
+			if len(got) >= n {
+				break
+			}
+			if used[f.rec.id] {
+				continue
+			}
+			got = append(got, f)
+			used[f.rec.id] = true
+		}
+		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)",
+		})
+	}
+	if len(classifyPool) < 2 {
+		notes = append(notes, fmt.Sprintf("tier-teacher-classify:候选只有 %d 条,不足 2 条", len(classifyPool)))
+	}
+	// 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)
+		add(Case{
+			ID: "syn-tier-cert-1", Kind: KindSynthetic, Category: CatTier, RecordID: f.rec.id,
+			ExpectTier: TierCert,
+			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"),
+			},
+			Expectation: "老师置持证 + 造 teacher_cert=1 且教龄 0~99 的通用规则 ⇒ 应命中 cert-tier",
+			Note:        "真实数据里该级命中数为 0,必须造数(P0 §13)",
+		})
+	} 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 夹取)----
+	poolZero := r.collect(records, now, func(f caseFacts) bool {
+		return baseOK(f) && f.rule.minPay > 0 && f.agg.elig > 0
+	})
+	for i, f := range take(poolZero, perCat) {
+		add(Case{ID: fmt.Sprintf("syn-income-zero-min-%d", i+1), Kind: KindSynthetic, Category: CatIncomeZeroMin,
+			RecordID:    f.rec.id,
+			Mutations:   []string{fmt.Sprintf("UPDATE t_courses_record_student SET cost = 0 WHERE recordid = '%s'", f.rec.id)},
+			ExpectTier:  f.tier,
+			Expectation: fmt.Sprintf("事务内把 cost 全置 0 ⇒ income=0 但人数=%d>0 ⇒ 应被 min_payment=%.1f 夹取", f.agg.elig, f.rule.minPay),
+			Note:        "验证 min 夹取分支;真实数据里 status!=2 的 income=0 记录都走退化分支,不可比"})
+	}
+	if len(poolZero) < perCat {
+		notes = append(notes, fmt.Sprintf("income-zero-min:候选只有 %d 条", len(poolZero)))
+	}
+
+	// ---- 类 4:isSupplement 两值都跑 ----
+	poolSup := r.collect(records, now, func(f caseFacts) bool {
+		return baseOK(f) && f.agg.elig >= 2
+	})
+	for i, f := range take(poolSup, perCat) {
+		add(Case{ID: fmt.Sprintf("syn-supplement-both-%d", i+1), Kind: KindSynthetic, Category: CatSupplement,
+			RecordID: f.rec.id, BothSupplement: true, ExpectTier: f.tier,
+			Expectation: "同一 record 跑 isSupplement=false / true 各一次;record 级应相同,逐行级应如 R13 所述不同",
+			Note:        "验证 isSupplement 只控制「是否重写逐行」"})
+	}
+	if len(poolSup) < perCat {
+		notes = append(notes, fmt.Sprintf("supplement-both:候选只有 %d 条", len(poolSup)))
+	}
+
+	// ---- 类 5:人数不整除(R13)----
+	poolNonDiv := r.collect(records, now, func(f caseFacts) bool {
+		return baseOK(f) && f.agg.elig >= 2 && nondivisible(f.agg.sumCost, f.rule, f.agg.elig)
+	})
+	for i, f := range take(poolNonDiv, perCat) {
+		add(Case{ID: fmt.Sprintf("syn-nondivisible-%d", i+1), Kind: KindSynthetic, Category: CatNonDivisible,
+			RecordID: f.rec.id, ExpectTier: f.tier,
+			Expectation: fmt.Sprintf("总额/人数 不整除(人数=%d)⇒ PHP 每行 round(,3) 与 Go 分位分配必然不同", f.agg.elig),
+			Note:        "专门验证 R13,逐行不参与通过判定"})
+	}
+	if len(poolNonDiv) < perCat {
+		notes = append(notes, fmt.Sprintf("nondivisible:候选只有 %d 条", len(poolNonDiv)))
+	}
+
+	// ---- 类 6:rewardType=2 固定课酬(人数 1/6/9 + 2 条补充)----
+	wantSizes := []int{1, 6, 9, 3, 7}
+	for _, want := range wantSizes {
+		pool := r.collect(records, now, func(f caseFacts) bool {
+			return f.rec.status == 2 && f.agg.elig == want && f.hit && !used[f.rec.id]
+		})
+		got := take(pool, 1)
+		if len(got) != 1 {
+			notes = append(notes, fmt.Sprintf("rewardtype2 人数=%d:无法造数(找不到 elig=%d 且有命中的记录)", want, want))
+			continue
+		}
+		f := got[0]
+		add(Case{ID: fmt.Sprintf("syn-rewardtype2-n%d", want), Kind: KindSynthetic, Category: CatRewardType2,
+			RecordID:    f.rec.id,
+			Mutations:   []string{fmt.Sprintf("UPDATE t_courses_record SET teacher_reward_type = 2 WHERE id = '%s'", f.rec.id)},
+			ExpectTier:  f.tier,
+			Expectation: fmt.Sprintf("置 teacher_reward_type=2(人数=%d)⇒ 按 makeupCost 查表,>6 人应取 'max'=>200", f.agg.elig),
+			Note:        "验证固定课酬查表与超表兜底"})
+	}
+
+	return out, notes
+}
+
+// hasThirdDecimal 判断金额是否有第三位小数(即迁移 3→2 位会变)。
+func hasThirdDecimal(v float64) bool {
+	milli := int64(v*1000 + 0.5*signOf(v))
+	return milli%10 != 0
+}
+
+func signOf(v float64) float64 {
+	if v < 0 {
+		return -1
+	}
+	return 1
+}
+
+// nondivisible 判断"课酬总额 / 人数"在分位是否除不尽(R13 的触发条件)。
+func nondivisible(sumCost float64, rule detailRow, n int) bool {
+	if n <= 0 || rule.percentage == 0 {
+		return false
+	}
+	reward := sumCost*rule.percentage/100 + rule.reward
+	if rule.maxPay > 0 && reward > rule.maxPay {
+		reward = rule.maxPay
+	} else if rule.minPay > 0 && reward < rule.minPay {
+		reward = rule.minPay
+	}
+	cents := int64(reward*100 + 0.5)
+	return cents%int64(n) != 0
+}
+
+// tierLabel 把 refs 的内部层级名转成报告用词(保持与 SP 一致)。
+func tierLabel(t string) string {
+	switch t {
+	case TierTeacherClassify, TierTeacherGradeSubject, TierCert, TierGeneric, TierDegraded:
+		return t
+	case "":
+		return TierNone
+	default:
+		return t
+	}
+}
+
+// String 便于调试输出。
+func (f caseFacts) String() string {
+	return strings.Join([]string{
+		f.rec.id,
+		fmt.Sprintf("tier=%s", f.tier),
+		fmt.Sprintf("income=%.3f", f.agg.sumCost),
+		fmt.Sprintf("elig=%d", f.agg.elig),
+	}, " ")
+}

+ 196 - 0
internal/academic/dualrun/guard.go

@@ -0,0 +1,196 @@
+package dualrun
+
+import (
+	"database/sql"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+// 数据源守卫的允许清单。
+//
+// 这里是**允许列表**(allow-list),不是禁止列表:任何不在表里的 host 一律拒绝,
+// 因此不需要、也不允许把某个具体 IP 写进代码 —— 真实环境里那个地址属于生产级共享实例
+// (具备 SUPER / DROP / GRANT OPTION),一旦被误连,后果不可逆。
+var (
+	// allowedHosts 允许连接的 host(只允许本机)。
+	allowedHosts = map[string]bool{
+		"127.0.0.1": true,
+		"localhost": true,
+		"::1":       true,
+	}
+	// allowedDatabases 允许操作的库(legacy 只读基线 / 两个可写副本 / target dev)。
+	allowedDatabases = map[string]bool{
+		"finance_bk":     true,
+		"legacy_finance": true,
+		"legacy_oracle":  true,
+		"yuansiwei_2026": true,
+	}
+	// writableDatabases 允许写入的库(阶段 1 只在 legacy_oracle 上写)。
+	writableDatabases = map[string]bool{
+		"legacy_oracle":  true,
+		"legacy_finance": true,
+	}
+)
+
+// Config 是 runner 的连接与运行参数,全部来自环境变量,不落盘。
+type Config struct {
+	Host         string
+	Port         int
+	Name         string
+	User         string
+	Pass         string
+	LegacyRepo   string
+	LegacyDBName string // PHP oracle 侧连接的库(阶段 1 固定 legacy_oracle)
+	PHPBinary    string
+}
+
+// LoadConfigFromEnv 读取 AC15_* 环境变量。
+//
+// 口令只经环境变量传入:不落盘、不进代码常量、不进报告。
+func LoadConfigFromEnv() Config {
+	port := 3306
+	if v := os.Getenv("AC15_DB_PORT"); v != "" {
+		if n, err := strconv.Atoi(v); err == nil && n > 0 {
+			port = n
+		}
+	}
+	name := os.Getenv("AC15_DB_NAME")
+	if name == "" {
+		name = "legacy_oracle"
+	}
+	user := os.Getenv("AC15_DB_USER")
+	if user == "" {
+		user = "dev_legacy"
+	}
+	repo := os.Getenv("AC15_LEGACY_REPO")
+	if repo == "" {
+		repo = "E:/study/php/ysw-finance/yuansiwei-finance"
+	}
+	php := os.Getenv("AC15_PHP_BIN")
+	if php == "" {
+		php = "php"
+	}
+	host := os.Getenv("AC15_DB_HOST")
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	return Config{
+		Host: host, Port: port, Name: name, User: user,
+		Pass:       os.Getenv("AC15_DB_PASS"),
+		LegacyRepo: repo, LegacyDBName: name, PHPBinary: php,
+	}
+}
+
+// maskHost 把 host 遮掉后两段,保证日志与报告里不出现完整地址。
+func maskHost(host string) string {
+	parts := strings.Split(host, ".")
+	if len(parts) == 4 {
+		return parts[0] + "." + parts[1] + ".***.**"
+	}
+	return "<masked>"
+}
+
+// Guard 执行**双条件数据源守卫**:host 必须在允许表内。
+//
+// 违反时 **panic**(按验收要求"不满足 → panic 退出"),不返回 error,
+// 因为这类错误一旦被上层忽略,就会以"连到错误实例后的正常结果"的形式污染整轮结论。
+func (c Config) Guard() {
+	if !allowedHosts[c.Host] {
+		panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— host %q 不在允许清单内(只允许本机)", maskHost(c.Host)))
+	}
+	if !allowedDatabases[c.Name] {
+		panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— database %q 不在允许清单内", c.Name))
+	}
+	if !writableDatabases[c.Name] {
+		panic(fmt.Sprintf("dualrun: 阶段 1 只写 legacy_oracle / legacy_finance,当前是 %q", c.Name))
+	}
+	if c.Pass == "" {
+		panic("dualrun: AC15_DB_PASS 未设置(口令只经环境变量传入)")
+	}
+}
+
+// DSN 拼装 gorm/go-sql-driver 使用的 DSN。
+func (c Config) DSN() string {
+	return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
+		c.User, c.Pass, c.Host, c.Port, c.Name)
+}
+
+// Open 打开一个 *sql.DB(每个 case 应各自 Open/Close,见 README §4)。
+func (c Config) Open() (*sql.DB, error) {
+	gdb, err := gorm.Open(mysql.Open(c.DSN()), &gorm.Config{
+		Logger:               logger.Default.LogMode(logger.Silent),
+		DisableAutomaticPing: false,
+	})
+	if err != nil {
+		return nil, err
+	}
+	sqlDB, err := gdb.DB()
+	if err != nil {
+		return nil, err
+	}
+	sqlDB.SetMaxOpenConns(1)
+	sqlDB.SetMaxIdleConns(1)
+	return sqlDB, nil
+}
+
+// AssertDatabase 是双条件守卫的**第二个条件**:连上之后核对 SELECT DATABASE()。
+//
+// 只在连接串里写库名是不够的(DSN 可能被改、连接可能落到别的默认库),
+// 必须让服务器自己报出当前库再比对一次。
+func AssertDatabase(db *sql.DB, want string) error {
+	var got sql.NullString
+	if err := db.QueryRow("SELECT DATABASE()").Scan(&got); err != nil {
+		return err
+	}
+	if !allowedDatabases[got.String] {
+		return fmt.Errorf("当前库 %q 不在允许清单内", got.String)
+	}
+	if got.String != want {
+		return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want)
+	}
+	return nil
+}
+
+// AssertNoSysdate 是**启动静态断言**:目标库 31 个 routine 定义里 `sysdate(` 出现数必须为 0。
+//
+// 依据:`SET SESSION timestamp` 冻结 NOW() 但不冻结 SYSDATE(),一旦有 SP 用 SYSDATE(),
+// 冻结时钟就会失效而结果看起来"正常"。9 组实测见 P0 结果文档 §6。
+func AssertNoSysdate(db *sql.DB, schema string) (int, error) {
+	var n int
+	err := db.QueryRow(
+		`SELECT COUNT(*) FROM information_schema.routines
+		  WHERE routine_schema = ? AND LOWER(routine_definition) LIKE '%sysdate(%'`, schema).Scan(&n)
+	if err != nil {
+		return 0, err
+	}
+	if n != 0 {
+		return n, fmt.Errorf("库 %s 有 %d 个 routine 使用 SYSDATE(),冻结时钟不可靠,拒绝运行", schema, n)
+	}
+	return n, nil
+}
+
+// TruncateToSecond 是冻结口径:两侧都用整秒,避免 legacy 给 .123456 而 target 给 .000 的伪差异。
+func TruncateToSecond(t time.Time) time.Time {
+	return t.Truncate(time.Second)
+}
+
+// SetSessionClock 在给定连接上冻结时钟并统一时区。
+//
+// 只影响本会话(timestamp 无全局作用域,SET GLOBAL 会报 1228),因此必须"每 case 新连接"。
+func SetSessionClock(db *sql.DB, frozenAt time.Time) error {
+	epoch := TruncateToSecond(frozenAt).Unix()
+	if _, err := db.Exec("SET SESSION time_zone = '+08:00'"); err != nil {
+		return err
+	}
+	if _, err := db.Exec(fmt.Sprintf("SET SESSION timestamp = %d", epoch)); err != nil {
+		return err
+	}
+	return nil
+}

+ 272 - 0
internal/academic/dualrun/model.go

@@ -0,0 +1,272 @@
+// Package dualrun 是 AC-15 双跑 harness 的阶段 1 实现。
+//
+// 范围(**刻意收窄**):只比对两条链路
+//   - oracle(legacy,PHP 真跑):CoursesRecordHelper::settlementExpenses 结课结算
+//   - target(Go,纯函数):service.computeRecordReward + 逐行分摊
+//
+// 不做的:28 个写 SP、活动链、分账链(阶段 2~4)。
+//
+// 两侧**故意不对称**(见 README §1):
+//   - oracle 侧真跑 PHP 并写库(事务内,finally 回滚)
+//   - target 侧只调纯函数,**不连 yuansiwei_2026**
+//
+// 这个不对称是设计选择,不是遗漏:课酬是"由输入算出一个金额",用纯函数就足以判定,
+// 而且 target 库没有对应历史数据,真跑反而引入无关变量。
+package dualrun
+
+// Attribution 是差异归因,取值**只有六个**(报告与统计都靠它分类,不得自增)。
+type Attribution string
+
+const (
+	// AttrMatch 两侧一致(按分层判据逐分相等)。
+	AttrMatch Attribution = "MATCH"
+	// AttrPrecisionConvergence 差异由 income 基数收敛引起(Δreward ≈ Δincome × pct/100)。
+	AttrPrecisionConvergence Attribution = "PRECISION-CONVERGENCE"
+	// AttrDefect 真缺陷(规则选错 / 差异与基数不成比例 / 公式不一致)。
+	AttrDefect Attribution = "DEFECT"
+	// AttrOracleDegraded oracle 侧走了退化分支(SP 无命中),不可比。
+	AttrOracleDegraded Attribution = "ORACLE-DEGRADED"
+	// AttrPathDivergence 数值相同但路径不同(约束 17)。
+	AttrPathDivergence Attribution = "PATH-DIVERGENCE"
+	// AttrEnvFailure 环境失败(PHP 进程失败 / 守卫不通过 / 数据缺失等),不是业务结论。
+	AttrEnvFailure Attribution = "ENV-FAILURE"
+)
+
+// AllAttributions 供汇总按固定顺序输出六类计数。
+var AllAttributions = []Attribution{
+	AttrMatch, AttrPrecisionConvergence, AttrDefect, AttrOracleDegraded, AttrPathDivergence, AttrEnvFailure,
+}
+
+// CaseKind 区分真实基线与合成边界。
+type CaseKind string
+
+const (
+	// KindReal 真实基线(从 legacy_oracle 里挑出来的已结课记录)。
+	KindReal CaseKind = "real"
+	// KindSynthetic 合成边界(事务内造数后跑,或按层级/数值特征定向挑选)。
+	KindSynthetic CaseKind = "synthetic"
+)
+
+// Case 是一个待跑的双跑用例。
+//
+// 字段设计取向:**一切影响结论的输入都显式落在结构体里**,便于 dry-run 审阅与幂等复现。
+type Case struct {
+	// ID 稳定可复现的用例 id(real-0001 / syn-tier-cert-1 …),报告以它为主键。
+	ID string `json:"id"`
+	// Kind 真实 / 合成。
+	Kind CaseKind `json:"kind"`
+	// Category 用例类别(baseline / tier-* / income-3dp / income-zero-min / supplement-both / nondivisible / rewardtype2)。
+	Category string `json:"category"`
+	// RecordID t_courses_record.id(varchar(32))。
+	RecordID string `json:"recordId"`
+	// IsSupplement 传给 settlementExpenses 的第 2 个参数(bool $isSupplement)。
+	IsSupplement bool `json:"isSupplement"`
+	// BothSupplement 为 true 时,同一 case 会跑 false / true 两遍并各自出结论(类别 supplement-both)。
+	BothSupplement bool `json:"bothSupplement"`
+	// Mutations 事务内、结算前执行的 SQL(仅合成 case;全部在 legacy_oracle 的事务里,随回滚消失)。
+	Mutations []string `json:"mutations,omitempty"`
+	// ExpectTier 期望命中的兜底层级(路径证据用;空表示不校验)。
+	ExpectTier string `json:"expectTier,omitempty"`
+	// Expectation 人类可读的期望说明(dry-run 里给人看)。
+	Expectation string `json:"expectation,omitempty"`
+	// Note 备注(为什么造这条 case)。
+	Note string `json:"note,omitempty"`
+}
+
+// Rule 是 SP 命中的课酬规则快照(金额单位为元,与旧库 float 列一致)。
+type Rule struct {
+	DetailID    int64   `json:"detailId"`
+	PaymentID   int64   `json:"paymentId"`
+	Percentage  string  `json:"percentage"` // "25.0"(百分数)
+	Min         float64 `json:"min"`
+	Max         float64 `json:"max"`
+	Reward      float64 `json:"reward"`
+	ClassType   int64   `json:"classType"`
+	GradeID     int64   `json:"gradeId"`
+	SubjectID   int64   `json:"subjectId"`
+	ExpMin      float64 `json:"expMin"`
+	ExpMax      float64 `json:"expMax"`
+	TeacherID   int64   `json:"teacherId"`
+	TeacherCert int64   `json:"teacherCert"`
+	ClassifyID  int64   `json:"classifyId"`
+}
+
+// PathEvidence 是"路径证据"(约束 17):数值相等但路径不同必须能被看出来。
+type PathEvidence struct {
+	// Tier 命中的兜底层级:teacher-classify / teacher-grade-subject / cert-tier / generic-tier / degraded / none。
+	Tier string `json:"tier"`
+	// DetailID 命中的规则 id(0 = 未命中)。
+	DetailID int64 `json:"detailId"`
+	// Degraded oracle 侧是否走了 legacy 退化分支(SP 无命中)。
+	Degraded bool `json:"degraded"`
+	// IsSupplement 本次执行用的 isSupplement 取值。
+	IsSupplement bool `json:"isSupplement"`
+	// RewardType 结算方式(1 正常 / 2 固定课酬)。
+	RewardType int8 `json:"rewardType"`
+	// Recount 是否进入"重写逐行"分支(PHP 的 recountTeacherReward 语义)。
+	Recount bool `json:"recount"`
+}
+
+// OracleResult 是 PHP 探针返回的 oracle 观测值。
+type OracleResult struct {
+	OK           bool      `json:"ok"`
+	Error        string    `json:"error"`
+	ErrorDetail  string    `json:"errorDetail"`
+	RecordID     string    `json:"recordId"`
+	IsSupplement bool      `json:"isSupplement"`
+	Inputs       OracleIn  `json:"inputs"`
+	Quote        *RuleRaw  `json:"quote"`
+	Clock        ClockInfo `json:"clock"`
+	Mutations    []string  `json:"mutations"`
+	MutationErrs []struct {
+		SQL   string `json:"sql"`
+		Error string `json:"error"`
+	} `json:"mutationErrors"`
+	Return           *bool        `json:"return"`
+	Exception        *OracleError `json:"exception"`
+	After            *RecordAfter `json:"after"`
+	StudentsBefore   []StudentRow `json:"studentsBefore"`
+	StudentsAfter    []StudentRow `json:"studentsAfter"`
+	RollbackRestored bool         `json:"rollbackRestored"`
+}
+
+// OracleIn 是 oracle 侧读到的输入。
+type OracleIn struct {
+	SchoolID            int64   `json:"schoolid"`
+	CampusID            int64   `json:"campusid"`
+	TeacherID           int64   `json:"teacherid"`
+	CoursesID           int64   `json:"coursesid"`
+	ClassifyID          int64   `json:"classifyid"`
+	TeacherExp          float64 `json:"teacherExp"`
+	TeacherRewardType   int8    `json:"teacherRewardType"`
+	StudentNumber       int     `json:"studentNumber"`
+	EligibleCount       int     `json:"eligibleCount"`
+	IncomeSumCost3      float64 `json:"incomeSumCost3"`
+	StatusBefore        int     `json:"statusBefore"`
+	TeacherRewardBefore float64 `json:"teacherRewardBefore"`
+	ActualStartTime     string  `json:"actualStartTime"`
+}
+
+// RuleRaw 是探针里 quote 的原始形状(金额为元)。
+type RuleRaw struct {
+	DetailID    float64 `json:"id"`
+	PaymentID   float64 `json:"course_paymentid"`
+	Percentage  float64 `json:"percentage"`
+	Min         float64 `json:"min_payment"`
+	Max         float64 `json:"max_payment"`
+	Reward      float64 `json:"reward"`
+	ClassType   float64 `json:"class_type"`
+	GradeID     float64 `json:"gradeid"`
+	SubjectID   float64 `json:"subjectid"`
+	ClassifyID  float64 `json:"classifyid"`
+	TeacherCert float64 `json:"teacher_cert"`
+	ExpMin      float64 `json:"teacher_exp_min"`
+	ExpMax      float64 `json:"teacher_exp_max"`
+}
+
+// ClockInfo 是冻结时钟的实测证据(A22 用)。
+type ClockInfo struct {
+	FrozenEpoch int64  `json:"frozenEpoch"`
+	MySQLNow    string `json:"now"`
+	MySQLUnix   string `json:"unixNow"`
+	PHPTime     int64  `json:"phpTime"`
+	PHPDate     string `json:"phpDate"`
+	TimeZone    string `json:"timeZone"`
+}
+
+// OracleError 是结算抛出的异常(A24 说明它必抛)。
+type OracleError struct {
+	Class   string `json:"class"`
+	Message string `json:"message"`
+	File    string `json:"file"`
+	Line    int    `json:"line"`
+}
+
+// RecordAfter 是"异常前已写入"的 record 观测值。
+type RecordAfter struct {
+	Status        int     `json:"status"`
+	Income        float64 `json:"income"`
+	TeacherReward float64 `json:"teacherReward"`
+	StudentNumber int     `json:"studentNumber"`
+	ActualEndTime string  `json:"actualEndTime"`
+}
+
+// StudentRow 是逐行打卡观测值。
+type StudentRow struct {
+	StudentID     int64   `json:"studentid"`
+	Type          int     `json:"type"`
+	Cost          float64 `json:"cost"`
+	TeacherReward float64 `json:"teacherReward"`
+}
+
+// TargetResult 是 Go 纯函数算出的 target 侧答案。
+type TargetResult struct {
+	Income      float64   `json:"income"`
+	Reward      float64   `json:"reward"`
+	PerRow      []float64 `json:"perRow"`
+	RowStudents []int64   `json:"rowStudents"`
+	Error       string    `json:"error,omitempty"`
+}
+
+// Step1Income 是三步归因的第一步结果。
+type Step1Income struct {
+	Legacy      float64 `json:"legacy"`     // round(Σcost, 3)
+	Target      float64 `json:"target"`     // Σ ROUND(cost, 2)
+	Delta       float64 `json:"delta"`      // target - legacy
+	EqualCents  bool    `json:"equalCents"` // 分位是否相等
+	Explanation string  `json:"explanation"`
+}
+
+// DiffRow 是报告里的一行差异(每条一行)。
+type DiffRow struct {
+	CaseID      string      `json:"caseId"`
+	Table       string      `json:"table"`
+	PK          string      `json:"pk"`
+	Column      string      `json:"column"`
+	Legacy      string      `json:"legacy"`
+	Target      string      `json:"target"`
+	Delta       string      `json:"delta"`
+	Attribution Attribution `json:"attribution"`
+}
+
+// CaseResult 是一条 case 的完整结论(含中间量与路径证据)。
+type CaseResult struct {
+	CaseID       string        `json:"caseId"`
+	Category     string        `json:"category"`
+	Kind         CaseKind      `json:"kind"`
+	RecordID     string        `json:"recordId"`
+	IsSupplement bool          `json:"isSupplement"`
+	Attribution  Attribution   `json:"attribution"`
+	Oracle       *OracleResult `json:"oracle,omitempty"`
+	Target       *TargetResult `json:"target,omitempty"`
+	Step1        *Step1Income  `json:"step1,omitempty"`
+	Rows         []DiffRow     `json:"rows,omitempty"`
+	Note         string        `json:"note,omitempty"`
+}
+
+// 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"`
+	CaseCounts    map[string]int      `json:"caseCounts"`
+	Summary       map[Attribution]int `json:"summary"`
+	Cases         []CaseResult        `json:"cases"`
+	DryRunCases   []Case              `json:"dryRunCases,omitempty"`
+	Idempotency   []IdempotencyCheck  `json:"idempotency,omitempty"`
+	MutationNotes []string            `json:"mutationNotes,omitempty"`
+}
+
+// IdempotencyCheck 记录同一 case 连跑两次是否结论一致。
+type IdempotencyCheck struct {
+	CaseID    string      `json:"caseId"`
+	First     Attribution `json:"first"`
+	Second    Attribution `json:"second"`
+	Same      bool        `json:"same"`
+	RowsEqual bool        `json:"rowsEqual"`
+}

+ 117 - 0
internal/academic/dualrun/oracle.go

@@ -0,0 +1,117 @@
+package dualrun
+
+import (
+	"bytes"
+	"context"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strconv"
+	"time"
+)
+
+//go:embed oracle_probe.php
+var oracleAssets embed.FS
+
+// OracleRunner 负责"起一个 PHP 进程、真跑一次结课、把观测值 JSON 带回来"。
+//
+// 为什么用子进程而不是同进程调用:
+//   - legacy 是 PHP 应用,无法在 Go 进程内调用;
+//   - **每次进程 = 每次新连接**,天然满足"每个 case 新连接"这条硬约束
+//     (Q5:MEMORY 引擎临时表的行不参与回滚,靠 ROLLBACK 清不掉);
+//   - 进程隔离后,PHP 侧的 notice/异常不会污染 Go 侧状态。
+type OracleRunner struct {
+	cfg Config
+	// probePath 是内嵌探针释放到磁盘后的路径(每轮 runner 一个,避免并发写)。
+	probePath string
+}
+
+// NewOracleRunner 把内嵌的 PHP 探针释放到临时目录。
+func NewOracleRunner(cfg Config) (*OracleRunner, error) {
+	dir, err := os.MkdirTemp("", "ac15-oracle-*")
+	if err != nil {
+		return nil, err
+	}
+	src, err := oracleAssets.ReadFile("oracle_probe.php")
+	if err != nil {
+		return nil, err
+	}
+	path := filepath.Join(dir, "oracle_probe.php")
+	if err := os.WriteFile(path, src, 0o600); err != nil {
+		return nil, err
+	}
+	return &OracleRunner{cfg: cfg, probePath: path}, nil
+}
+
+// Close 清理释放的探针。
+func (o *OracleRunner) Close() {
+	if o.probePath != "" {
+		_ = os.RemoveAll(filepath.Dir(o.probePath))
+	}
+}
+
+// Run 执行一条 case 的 oracle 侧观测。
+//
+// 入参:ctx;c 待跑用例;frozenAt 冻结点(会被截断到整秒)。
+// 出参:OracleResult(含路径证据与"异常前写入值");error 仅表示"进程/解析层面失败"。
+func (o *OracleRunner) Run(ctx context.Context, c Case, frozenAt time.Time) (*OracleResult, error) {
+	mut, err := json.Marshal(c.Mutations)
+	if err != nil {
+		return nil, err
+	}
+	cmd := exec.CommandContext(ctx, o.cfg.PHPBinary,
+		"-d", "date.timezone=Asia/Shanghai",
+		"-d", "opcache.enable_cli=0",
+		"-d", "memory_limit=1024M",
+		o.probePath,
+	)
+	cmd.Env = append(os.Environ(),
+		"AC15_LEGACY_REPO="+o.cfg.LegacyRepo,
+		"AC15_DB_HOST="+o.cfg.Host,
+		"AC15_DB_PORT="+strconv.Itoa(o.cfg.Port),
+		"AC15_DB_NAME="+o.cfg.LegacyDBName,
+		"AC15_DB_USER="+o.cfg.User,
+		"AC15_DB_PASS="+o.cfg.Pass,
+		"AC15_RECORD_ID="+c.RecordID,
+		"AC15_IS_SUPPLEMENT="+boolEnv(c.IsSupplement),
+		"AC15_FROZEN_EPOCH="+strconv.FormatInt(TruncateToSecond(frozenAt).Unix(), 10),
+		"AC15_MUTATIONS="+string(mut),
+	)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		return nil, fmt.Errorf("php 进程失败:%v;stderr=%s", err, truncate(stderr.String(), 800))
+	}
+
+	raw := bytes.TrimSpace(stdout.Bytes())
+	// 探针可能被 PHP 的 notice 污染 stdout,取最后一个 '{' 起始的 JSON 对象
+	if i := bytes.LastIndexByte(raw, '{'); i > 0 {
+		raw = raw[i:]
+	}
+	var res OracleResult
+	if err := json.Unmarshal(raw, &res); err != nil {
+		return nil, fmt.Errorf("解析探针输出失败:%v;原始输出=%s", err, truncate(string(raw), 800))
+	}
+	if !res.OK {
+		return &res, fmt.Errorf("探针报错 %s: %s", res.Error, res.ErrorDetail)
+	}
+	return &res, nil
+}
+
+func boolEnv(b bool) string {
+	if b {
+		return "1"
+	}
+	return "0"
+}
+
+func truncate(s string, n int) string {
+	if len(s) <= n {
+		return s
+	}
+	return s[:n] + "…"
+}

+ 245 - 0
internal/academic/dualrun/oracle_probe.php

@@ -0,0 +1,245 @@
+<?php
+/**
+ * AC-15 阶段 1 · oracle 侧探针(由 Go runner 通过 embed 释放到临时目录后执行)
+ *
+ * 职责:在 legacy 应用里真实调用一次结课结算,把"异常前已写入的库值"读回来,以 JSON 输出。
+ * 纪律:
+ *   - 只在 legacy_oracle / legacy_finance 上写;finance_bk 一律拒绝
+ *   - 整个结算过程包在一个事务里,finally 必定 rollBack
+ *   - 每次进程独立 cachePath ⇒ 缓存不跨 case,保证幂等
+ *   - 口令只从环境变量读,不打印
+ *
+ * 入参(全部环境变量):
+ *   AC15_DB_HOST / AC15_DB_PORT / AC15_DB_NAME / AC15_DB_USER / AC15_DB_PASS
+ *   AC15_LEGACY_REPO      legacy 应用根目录
+ *   AC15_RECORD_ID        结课记录 id(varchar(32))
+ *   AC15_IS_SUPPLEMENT    "0"/"1"
+ *   AC15_FROZEN_EPOCH     冻结时钟的 epoch(整秒,字符串)
+ *   AC15_MUTATIONS        JSON 数组:事务内、结算前要执行的 SQL(合成 case 造数用;空数组表示不改)
+ *
+ * 出参:stdout 一个 JSON 对象(不夹带任何调试噪声)
+ */
+
+$repo = getenv('AC15_LEGACY_REPO') ?: 'E:/study/php/ysw-finance/yuansiwei-finance';
+$host = getenv('AC15_DB_HOST') ?: '127.0.0.1';
+$port = (int) (getenv('AC15_DB_PORT') ?: 3306);
+$name = getenv('AC15_DB_NAME') ?: 'legacy_oracle';
+$user = getenv('AC15_DB_USER') ?: 'dev_legacy';
+$pass = getenv('AC15_DB_PASS');
+$recordId = getenv('AC15_RECORD_ID');
+$isSupplement = getenv('AC15_IS_SUPPLEMENT') === '1';
+$frozenEpoch = (int) (getenv('AC15_FROZEN_EPOCH') ?: 0);
+$mutations = json_decode(getenv('AC15_MUTATIONS') ?: '[]', true) ?: [];
+
+function out(array $payload): void
+{
+    $payload['harness'] = 'ac15-stage1-oracle';
+    echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PARTIAL_OUTPUT_ON_ERROR);
+    exit(0);
+}
+
+function bail(string $code, string $detail): void
+{
+    out(['ok' => false, 'error' => $code, 'errorDetail' => $detail]);
+}
+
+// ---- 硬守卫:库名 + host 白名单(拒绝 finance_bk 与任何远端 host)----
+if (!in_array($host, ['127.0.0.1', 'localhost', '::1'], true)) {
+    bail('GUARD_HOST', 'host not in allow-list');
+}
+if (!in_array($name, ['legacy_oracle', 'legacy_finance'], true)) {
+    bail('GUARD_DB', 'write target must be legacy_oracle / legacy_finance, got ' . $name);
+}
+if ($pass === false || $pass === '') {
+    bail('NO_CREDENTIAL', 'AC15_DB_PASS missing');
+}
+if (empty($recordId)) {
+    bail('NO_RECORD', 'AC15_RECORD_ID missing');
+}
+
+define('YII_DEBUG', false);
+define('YII_ENV', 'prod');
+require $repo . '/vendor/autoload.php';
+require $repo . '/vendor/yiisoft/yii2/Yii.php';
+
+$config = require $repo . '/config/console.php';
+$localDb = [
+    'class' => 'yii\db\Connection',
+    'dsn' => sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, $port, $name),
+    'username' => $user,
+    'password' => $pass,
+    'charset' => 'utf8mb4',
+    'tablePrefix' => 't_',
+    'enableSchemaCache' => false,
+];
+$config['components']['db'] = $localDb;
+$config['components']['dbFinance'] = $localDb;
+// 独立 cachePath:保证每次进程冷缓存,重复执行结论一致(幂等)
+$cachePath = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'ac15-cache-' . getmypid();
+$config['components']['cache'] = ['class' => 'yii\caching\FileCache', 'cachePath' => $cachePath];
+
+try {
+    $app = new yii\console\Application($config);
+} catch (Throwable $e) {
+    bail('BOOT_FAIL', $e->getMessage());
+}
+
+use app\modules\courses\helpers\CoursesHelper;
+use app\modules\courses\helpers\CoursesRecordHelper;
+use app\modules\courses\helpers\CoursePaymentDetailHelper;
+use app\modules\user\helpers\TeacherInformationHelper;
+
+$db = Yii::$app->dbFinance;
+$clock = [];
+
+try {
+    // ---- 冻结时钟(会话级)+ 时区统一 ----
+    if ($frozenEpoch > 0) {
+        $db->createCommand('SET SESSION time_zone = :tz', [':tz' => '+08:00'])->execute();
+        $db->createCommand('SET SESSION timestamp = :ts', [':ts' => (string) $frozenEpoch])->execute();
+        $clock = [
+            'frozenEpoch' => $frozenEpoch,
+            'now' => (string) $db->createCommand('SELECT NOW()')->queryScalar(),
+            'unixNow' => (string) $db->createCommand('SELECT UNIX_TIMESTAMP()')->queryScalar(),
+            'phpTime' => time(),
+            'phpDate' => date('Y-m-d H:i:s'),
+            'timeZone' => (string) $db->createCommand('SELECT @@session.time_zone')->queryScalar(),
+        ];
+    }
+
+    $rec = $db->createCommand(
+        "SELECT id, schoolid, campusid, teacherid, coursesid, classid, teacher_reward_type,
+                student_number, status, income, teacher_reward,
+                DATE_FORMAT(actual_start_time,'%Y-%m-%d %H:%i:%s') st,
+                DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') et
+         FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
+    if (!$rec) {
+        bail('RECORD_NOT_FOUND', $recordId);
+    }
+
+    $classifyid = CoursesHelper::getClassifyidById($rec['coursesid']);
+    $teacherExp = TeacherInformationHelper::getTeacherExp($rec['teacherid']);
+    $income = round((float) $db->createCommand(
+        "SELECT IFNULL(SUM(cost),0) FROM t_courses_record_student WHERE recordid=:r", [':r' => $recordId])->queryScalar(), 3);
+    $studentsBefore = $db->createCommand(
+        "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
+        [':r' => $recordId])->queryAll();
+    $eligible = 0;
+    foreach ($studentsBefore as $s) {
+        if ((int) $s['type'] === 1 || (int) $s['type'] === 2) {
+            $eligible++;
+        }
+    }
+
+    // SP 命中的原始规则(绕过 helper 缓存,单独取一次,作为"路径证据"的 detail_id 来源)
+    $quote = null;
+    try {
+        $quote = CoursePaymentDetailHelper::getTeacherPaymentDetailStoredProcedure(
+            $rec['teacherid'], $rec['schoolid'], $rec['campusid'], $classifyid, $rec['st']
+        );
+    } catch (Throwable $e) {
+        $quote = null;
+    }
+    $quoteOut = null;
+    if (is_array($quote)) {
+        $quoteOut = [];
+        foreach (['id', 'course_paymentid', 'gradeid', 'subjectid', 'class_type', 'classifyid',
+                  'percentage', 'min_payment', 'max_payment', 'reward', 'teacher_cert',
+                  'teacher_exp_min', 'teacher_exp_max'] as $k) {
+            if (array_key_exists($k, $quote)) {
+                $quoteOut[$k] = (float) $quote[$k];
+            }
+        }
+    }
+
+    // ---- 事务内:造数 → 结算 → 读回 → 必定回滚 ----
+    $tx = $db->beginTransaction();
+    $exception = null;
+    $mutationErrors = [];
+    $ret = null;
+    $after = null;
+    $studentsAfter = [];
+    try {
+        foreach ($mutations as $sql) {
+            try {
+                $db->createCommand($sql)->execute();
+            } catch (Throwable $e) {
+                $mutationErrors[] = ['sql' => $sql, 'error' => $e->getMessage()];
+            }
+        }
+        try {
+            $ret = CoursesRecordHelper::settlementExpenses($recordId, $isSupplement);
+        } catch (Throwable $e) {
+            $exception = [
+                'class' => get_class($e),
+                'message' => $e->getMessage(),
+                'file' => $e->getFile(),
+                'line' => $e->getLine(),
+            ];
+        }
+        // A24:它必抛异常,所以要读"异常前已写入"的库值
+        $after = $db->createCommand(
+            "SELECT status, income, teacher_reward, student_number,
+                    DATE_FORMAT(actual_end_time,'%Y-%m-%d %H:%i:%s') aet
+             FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryOne();
+        $studentsAfter = $db->createCommand(
+            "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
+            [':r' => $recordId])->queryAll();
+    } finally {
+        if ($tx !== null && $tx->getIsActive()) {
+            $tx->rollBack();
+        }
+    }
+
+    // 回滚后复核(证明零残留)
+    $studentsRestored = $db->createCommand(
+        "SELECT studentid, type, cost, teacher_reward FROM t_courses_record_student WHERE recordid=:r ORDER BY studentid, type",
+        [':r' => $recordId])->queryAll();
+    $restored = ($db->createCommand("SELECT teacher_reward FROM t_courses_record WHERE id=:id", [':id' => $recordId])->queryScalar() == $rec['teacher_reward'])
+        && (json_encode($studentsRestored) === json_encode($studentsBefore));
+
+    out([
+        'ok' => true,
+        'recordId' => $recordId,
+        'isSupplement' => $isSupplement,
+        'inputs' => [
+            'schoolid' => (int) $rec['schoolid'],
+            'campusid' => (int) $rec['campusid'],
+            'teacherid' => (int) $rec['teacherid'],
+            'coursesid' => (int) $rec['coursesid'],
+            'classifyid' => (int) $classifyid,
+            'teacherExp' => (float) $teacherExp,
+            'teacherRewardType' => (int) $rec['teacher_reward_type'],
+            'studentNumber' => (int) $rec['student_number'],
+            'eligibleCount' => $eligible,
+            'incomeSumCost3' => $income,
+            'statusBefore' => (int) $rec['status'],
+            'teacherRewardBefore' => (float) $rec['teacher_reward'],
+            'actualStartTime' => $rec['st'],
+        ],
+        'quote' => $quoteOut,
+        'clock' => $clock,
+        'mutations' => $mutations,
+        'mutationErrors' => $mutationErrors,
+        'return' => $ret,
+        'exception' => $exception,
+        'after' => $after === null ? null : [
+            'status' => (int) $after['status'],
+            'income' => (float) $after['income'],
+            'teacherReward' => (float) $after['teacher_reward'],
+            'studentNumber' => (int) $after['student_number'],
+            'actualEndTime' => $after['aet'],
+        ],
+        'studentsBefore' => array_map(static function ($r) {
+            return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
+                    'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
+        }, $studentsBefore),
+        'studentsAfter' => array_map(static function ($r) {
+            return ['studentid' => (int) $r['studentid'], 'type' => (int) $r['type'],
+                    'cost' => (float) $r['cost'], 'teacherReward' => (float) $r['teacher_reward']];
+        }, $studentsAfter),
+        'rollbackRestored' => $restored,
+    ]);
+} catch (Throwable $e) {
+    bail('UNEXPECTED', $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
+}

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

@@ -0,0 +1,144 @@
+package dualrun
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"sort"
+	"strings"
+)
+
+// WriteJSON 输出机器可读报告。
+func WriteJSON(r *Report, path string) error {
+	b, err := json.MarshalIndent(r, "", "  ")
+	if err != nil {
+		return err
+	}
+	b = append(b, '\n')
+	return os.WriteFile(path, b, 0o644)
+}
+
+// WriteMarkdown 输出人可读报告。
+//
+// 结构固定:结论摘要(六类计数)→ 环境与守卫 → 用例清单 → 逐条差异表 → 幂等性 → 备注。
+func WriteMarkdown(r *Report, path string) error {
+	var b strings.Builder
+
+	b.WriteString("# AC-15 阶段 1 双跑报告(课酬链)\n\n")
+	fmt.Fprintf(&b, "- 生成时间:`%s`\n", r.GeneratedAt)
+	fmt.Fprintf(&b, "- 用例集:`%s`|dry-run:`%v`\n", r.CaseSet, r.DryRun)
+	fmt.Fprintf(&b, "- 冻结时钟:`%d`(%s,整秒)\n", r.FrozenEpoch, r.FrozenAt)
+	fmt.Fprintf(&b, "- 用例数:%s\n\n", formatCounts(r.CaseCounts))
+
+	b.WriteString("## 0. 两侧不对称(务必先读)\n\n")
+	b.WriteString("| 侧 | 怎么跑 |\n|---|---|\n")
+	b.WriteString("| **oracle(legacy / PHP)** | 真跑 `settlementExpenses($recordId, $isSupplement)`,事务内执行、`finally` 回滚,读**异常前已写入**的库值(A24:它必抛异常,不能等返回值) |\n")
+	b.WriteString("| **target(Go)** | 只调纯函数 `computeRecordReward` + `distributeAmount`,输入取自 oracle 侧同一份观测,**不连 yuansiwei_2026、不写库** |\n\n")
+	b.WriteString("> 这不是「两边各跑一次入库」。对「由输入算出一个金额」的逻辑,纯函数足以判定;\n")
+	b.WriteString("> 且 target 库没有对应历史数据,真跑只会引入无关变量。\n\n")
+
+	b.WriteString("## 1. 守卫与启动断言\n\n| 项 | 值 |\n|---|---|\n")
+	for _, k := range sortedKeys(r.Guard) {
+		fmt.Fprintf(&b, "| %s | %s |\n", k, r.Guard[k])
+	}
+	for _, k := range sortedKeys(r.Assertions) {
+		fmt.Fprintf(&b, "| %s | %s |\n", k, r.Assertions[k])
+	}
+	b.WriteString("\n")
+
+	b.WriteString("## 2. 结论分布(六类,按 case 计)\n\n")
+	b.WriteString("| 归因 | 条数 |\n|---|---|\n")
+	total := 0
+	for _, a := range AllAttributions {
+		n := r.Summary[a]
+		total += n
+		fmt.Fprintf(&b, "| %s | %d |\n", a, n)
+	}
+	fmt.Fprintf(&b, "| **合计** | **%d** |\n\n", total)
+
+	if r.DryRun {
+		b.WriteString("## 3. dry-run 用例清单(未执行)\n\n")
+		fmt.Fprintf(&b, "| caseId | kind | category | recordId | isSupplement | expectTier | 造数 | 期望 |\n|---|---|---|---|---|---|---|---|\n")
+		for _, c := range r.DryRunCases {
+			mut := "-"
+			if len(c.Mutations) > 0 {
+				mut = fmt.Sprintf("%d 条 SQL", len(c.Mutations))
+			}
+			sup := fmt.Sprintf("%v", c.IsSupplement)
+			if c.BothSupplement {
+				sup = "false+true"
+			}
+			fmt.Fprintf(&b, "| %s | %s | %s | `%s` | %s | %s | %s | %s |\n",
+				c.ID, c.Kind, c.Category, c.RecordID, sup, c.ExpectTier, mut, c.Expectation)
+		}
+		b.WriteString("\n")
+		for _, c := range r.DryRunCases {
+			if len(c.Mutations) == 0 {
+				continue
+			}
+			fmt.Fprintf(&b, "- **%s** 造数 SQL:\n", c.ID)
+			for _, m := range c.Mutations {
+				fmt.Fprintf(&b, "  ```sql\n  %s\n  ```\n", m)
+			}
+		}
+		b.WriteString("\n")
+	} else {
+		b.WriteString("## 3. 逐条差异\n\n")
+		b.WriteString("| caseId | 表 | 主键 | 列 | legacy | target | 差值 | 归因 |\n|---|---|---|---|---|---|---|---|\n")
+		for _, cr := range r.Cases {
+			if len(cr.Rows) == 0 {
+				fmt.Fprintf(&b, "| %s | - | `%s` | - | - | - | - | %s |\n", cr.CaseID, cr.RecordID, cr.Attribution)
+				continue
+			}
+			for _, row := range cr.Rows {
+				fmt.Fprintf(&b, "| %s | %s | `%s` | %s | %s | %s | %s | %s |\n",
+					row.CaseID, row.Table, row.PK, row.Column, row.Legacy, row.Target, row.Delta, row.Attribution)
+			}
+		}
+		b.WriteString("\n")
+
+		if len(r.Idempotency) > 0 {
+			b.WriteString("## 4. 幂等性(同 case 连跑两次)\n\n")
+			b.WriteString("| caseId | 第一次 | 第二次 | 归因一致 | 差异行一致 |\n|---|---|---|---|---|\n")
+			allSame := true
+			for _, ic := range r.Idempotency {
+				if !ic.Same || !ic.RowsEqual {
+					allSame = false
+				}
+				fmt.Fprintf(&b, "| %s | %s | %s | %v | %v |\n", ic.CaseID, ic.First, ic.Second, ic.Same, ic.RowsEqual)
+			}
+			fmt.Fprintf(&b, "\n**幂等结论:%v**\n\n", allSame)
+		}
+	}
+
+	if len(r.MutationNotes) > 0 {
+		b.WriteString("## 5. 造数与选样的限制(诚实记录)\n\n")
+		for _, n := range r.MutationNotes {
+			fmt.Fprintf(&b, "- %s\n", n)
+		}
+		b.WriteString("\n")
+	}
+
+	return os.WriteFile(path, []byte(b.String()), 0o644)
+}
+
+func formatCounts(m map[string]int) string {
+	if len(m) == 0 {
+		return "-"
+	}
+	keys := sortedKeys(m)
+	var parts []string
+	for _, k := range keys {
+		parts = append(parts, fmt.Sprintf("%s=%d", k, m[k]))
+	}
+	return strings.Join(parts, ",")
+}
+
+func sortedKeys[T any](m map[string]T) []string {
+	keys := make([]string, 0, len(m))
+	for k := range m {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	return keys
+}

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

@@ -0,0 +1,301 @@
+package dualrun
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"sort"
+	"time"
+)
+
+// 阶段 1 的冻结点:与 P0 实测一致的整秒 epoch(2026-03-31 10:00:00 +08:00)。
+//
+// 口径:两侧同源、**Truncate 到整秒**。理由见 P0 §6 组 2b:`SET timestamp` 连微秒都能冻,
+// 不取整就会出现"legacy 给 .123456、target 给 .000"的伪差异。
+const DefaultFrozenEpoch int64 = 1774922400
+
+// Options 是 stage1 的运行参数(全部来自 CLI)。
+type Options struct {
+	// CaseSet:real100 / synthetic / all。
+	CaseSet string
+	// DryRun:只列 case,不执行。
+	DryRun bool
+	// OutJSON / OutMD 输出路径。
+	OutJSON string
+	OutMD   string
+	// Idempotency:每条 case 连跑两次比对结论。
+	Idempotency bool
+	// RealCount 真实基线条数(默认 100)。
+	RealCount int
+	// PerCat 合成每类条数(默认 5)。
+	PerCat int
+	// FrozenEpoch 冻结点(默认 DefaultFrozenEpoch)。
+	FrozenEpoch int64
+	// Now 选样用的"当前时间"(教龄换算用;零值取 time.Now)。
+	Now time.Time
+}
+
+func (o *Options) normalize() {
+	if o.CaseSet == "" {
+		o.CaseSet = "all"
+	}
+	if o.RealCount <= 0 {
+		o.RealCount = 100
+	}
+	if o.PerCat <= 0 {
+		o.PerCat = 5
+	}
+	if o.FrozenEpoch == 0 {
+		o.FrozenEpoch = DefaultFrozenEpoch
+	}
+	if o.Now.IsZero() {
+		o.Now = time.Now()
+	}
+	if o.OutJSON == "" {
+		o.OutJSON = "dualrun-stage1.json"
+	}
+	if o.OutMD == "" {
+		o.OutMD = "dualrun-stage1.md"
+	}
+}
+
+// RunStage1 执行阶段 1。
+//
+// 前置:调用方已 LoadConfigFromEnv 并传入;本函数内部会 Guard()(违反即 panic)。
+//
+// 出参:Report(同时已写入 OutJSON / OutMD);error 仅表示"环境层面无法继续"。
+func RunStage1(ctx context.Context, cfg Config, opts Options) (*Report, error) {
+	opts.normalize()
+
+	// ---- 守卫 1:host / 库名 / 口令(panic 语义见 Config.Guard)----
+	cfg.Guard()
+
+	report := &Report{
+		Stage:       "AC-15 stage1 (payroll chain)",
+		GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
+		CaseSet:     opts.CaseSet,
+		DryRun:      opts.DryRun,
+		FrozenEpoch: opts.FrozenEpoch,
+		FrozenAt:    time.Unix(opts.FrozenEpoch, 0).In(time.Local).Format("2006-01-02 15:04:05"),
+		Guard: map[string]string{
+			"host":     maskHost(cfg.Host),
+			"database": cfg.Name,
+			"user":     cfg.User,
+			"口令":       "已用 AC15_DB_PASS 传入(不记录)",
+		},
+		Assertions: map[string]string{},
+		CaseCounts: map[string]int{},
+		Summary:    map[Attribution]int{},
+	}
+
+	db, err := cfg.Open()
+	if err != nil {
+		return nil, fmt.Errorf("连接失败:%w", err)
+	}
+	defer db.Close()
+
+	// ---- 守卫 2:连上后核对 SELECT DATABASE() ----
+	if err := AssertDatabase(db, cfg.Name); err != nil {
+		return nil, fmt.Errorf("数据源守卫失败(SELECT DATABASE()):%w", err)
+	}
+
+	// ---- 启动静态断言:31 个 routine 里 sysdate( 必须为 0 ----
+	nSys, err := AssertNoSysdate(db, cfg.Name)
+	if err != nil {
+		return nil, err
+	}
+	report.Assertions["sysdate_count"] = fmt.Sprintf("%d(必须为 0)", nSys)
+	report.Assertions["frozen_clock"] = fmt.Sprintf("SET SESSION timestamp=%d;time_zone=+08:00;Truncate(time.Second)", opts.FrozenEpoch)
+	report.Assertions["连接模型"] = "每个 case 一个新 PHP 进程(= 新连接);Go 侧另开只读连接选样"
+
+	refs, err := loadRefs(db, cfg.Name)
+	if err != nil {
+		return nil, err
+	}
+	report.Assertions["参照表"] = fmt.Sprintf("payments=%d classify=%d courses=%d teachers=%d rules=%d",
+		len(refs.pays), len(refs.classifies), len(refs.courseClass), len(refs.teachers), len(refs.detailByID))
+
+	records, err := loadRecords(db, cfg.Name)
+	if err != nil {
+		return nil, err
+	}
+	report.Assertions["record_scan"] = fmt.Sprintf("%d 条(teacherid>0 且有 actual_start_time)", len(records))
+
+	// ---- 组装 case ----
+	var cases []Case
+	if opts.CaseSet == "real100" || opts.CaseSet == "all" {
+		cases = append(cases, BuildRealCases(records, refs, opts.RealCount, opts.Now)...)
+	}
+	if opts.CaseSet == "synthetic" || opts.CaseSet == "all" {
+		syn, notes := BuildSyntheticCases(records, refs, opts.PerCat, opts.Now)
+		cases = append(cases, syn...)
+		report.MutationNotes = append(report.MutationNotes, notes...)
+	}
+	if len(cases) == 0 {
+		return nil, fmt.Errorf("用例集 %q 组装结果为空", opts.CaseSet)
+	}
+	for _, c := range cases {
+		report.CaseCounts[string(c.Kind)]++
+	}
+
+	// ---- dry-run:只列清单 ----
+	if opts.DryRun {
+		report.DryRunCases = cases
+		if err := WriteJSON(report, opts.OutJSON); err != nil {
+			return nil, err
+		}
+		if err := WriteMarkdown(report, opts.OutMD); err != nil {
+			return nil, err
+		}
+		return report, nil
+	}
+
+	// ---- 实际执行 ----
+	oracle, err := NewOracleRunner(cfg)
+	if err != nil {
+		return nil, err
+	}
+	defer oracle.Close()
+
+	frozenAt := time.Unix(opts.FrozenEpoch, 0)
+	// 事实依据:factsByRecord 用于第 2 步"复刻判定 vs SP 实返"比对
+	factsByRecord := map[string]caseFacts{}
+	for _, rec := range records {
+		factsByRecord[rec.id] = refs.buildFacts(rec, opts.Now)
+	}
+
+	for _, c := range cases {
+		// 单 case 失败不中断:记录后继续
+		res := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
+		report.Cases = append(report.Cases, res...)
+	}
+
+	// ---- 幂等:抽样重跑(默认前 3 条 + 全部合成里的第一条)----
+	if opts.Idempotency {
+		for i, c := range cases {
+			if i >= 3 && i%len(cases) != 0 {
+				if i >= 3 && i != len(cases)-1 {
+					continue
+				}
+			}
+			first := collectRows(report.Cases, c.ID)
+			again := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
+			second := collectRows(again, c.ID)
+			report.Idempotency = append(report.Idempotency, IdempotencyCheck{
+				CaseID:    c.ID,
+				First:     first.attr,
+				Second:    second.attr,
+				Same:      first.attr == second.attr,
+				RowsEqual: first.json == second.json,
+			})
+		}
+	}
+
+	// ---- 汇总 ----
+	for _, cr := range report.Cases {
+		report.Summary[cr.Attribution]++
+	}
+
+	if err := WriteJSON(report, opts.OutJSON); err != nil {
+		return nil, err
+	}
+	if err := WriteMarkdown(report, opts.OutMD); err != nil {
+		return nil, err
+	}
+	return report, nil
+}
+
+type rowSet struct {
+	attr Attribution
+	json string
+}
+
+func collectRows(results []CaseResult, caseID string) rowSet {
+	for _, r := range results {
+		if r.CaseID != caseID {
+			continue
+		}
+		b, _ := json.Marshal(r.Rows)
+		return rowSet{attr: r.Attribution, json: string(b)}
+	}
+	return rowSet{}
+}
+
+// runOne 跑一条 case(BothSupplement 时内部跑两次)。
+func runOne(ctx context.Context, oracle *OracleRunner, refs *refs, facts map[string]caseFacts,
+	c Case, frozenAt time.Time, opts Options) []CaseResult {
+
+	variants := []bool{c.IsSupplement}
+	if c.BothSupplement {
+		variants = []bool{false, true}
+	}
+	var out []CaseResult
+	for _, sup := range variants {
+		cc := c
+		cc.IsSupplement = sup
+		res := CaseResult{
+			CaseID:       c.ID,
+			Category:     c.Category,
+			Kind:         c.Kind,
+			RecordID:     c.RecordID,
+			IsSupplement: sup,
+			Note:         c.Note,
+		}
+		if c.BothSupplement {
+			res.CaseID = fmt.Sprintf("%s/sup=%v", c.ID, sup)
+		}
+
+		obs, err := oracle.Run(ctx, cc, frozenAt)
+		if err != nil && obs == nil {
+			res.Attribution = AttrEnvFailure
+			res.Note = joinNote(res.Note, "oracle 进程失败:"+err.Error())
+			out = append(out, res)
+			continue
+		}
+		res.Oracle = obs
+
+		tgt, terr := ComputeTarget(obs)
+		res.Target = tgt
+		if terr != nil {
+			res.Attribution = AttrEnvFailure
+			res.Note = joinNote(res.Note, "target 计算出错:"+terr.Error())
+			out = append(out, res)
+			continue
+		}
+
+		f := facts[c.RecordID]
+		rows, attr, s1 := Attribute(AttrInput{
+			Case:              cc,
+			Oracle:            obs,
+			Target:            tgt,
+			PredictedDetailID: f.rule.id,
+			PredictedTier:     tierLabel(f.tier),
+		})
+		// A24:结算必抛异常,异常本身不是失败;把它记进备注便于复核
+		if obs.Exception != nil {
+			res.Note = joinNote(res.Note, fmt.Sprintf("oracle 抛出 %s(A24 预期,异常前写入值已读回)", obs.Exception.Message))
+		}
+		res.Rows = rows
+		res.Step1 = s1
+		res.Attribution = CaseAttribution(rows, attr)
+		out = append(out, res)
+	}
+	return out
+}
+
+func joinNote(a, b string) string {
+	if a == "" {
+		return b
+	}
+	return a + ";" + b
+}
+
+// SortedCaseIDs 便于测试与调试。
+func SortedCaseIDs(cases []Case) []string {
+	ids := make([]string, 0, len(cases))
+	for _, c := range cases {
+		ids = append(ids, c.ID)
+	}
+	sort.Strings(ids)
+	return ids
+}

+ 98 - 0
internal/academic/dualrun/target.go

@@ -0,0 +1,98 @@
+package dualrun
+
+import (
+	"fmt"
+	"math"
+	"sort"
+
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/service"
+)
+
+// yuanToCents 元 → 分(四舍五入,负数对称)。
+func yuanToCents(v float64) money.Amount {
+	return money.FromCents(centsOf(v))
+}
+
+func centsOf(v float64) int64 {
+	if v < 0 {
+		return -int64(math.Round(-v * 100))
+	}
+	return int64(math.Round(v * 100))
+}
+
+// centsToYuan 分 → 元(仅用于报告展示)。
+func centsToYuan(c int64) float64 {
+	return float64(c) / 100
+}
+
+// ComputeTarget 用**纯函数**算出 target 侧答案。
+//
+// 关键设计(也是必须写进报告的不对称):
+//   - target 侧**不连** yuansiwei_2026、不写库,只调 `service.computeRecordReward` 与 `distributeAmount`;
+//   - 输入全部取自 oracle 侧同一份观测(规则、人数、结算方式);
+//   - **唯一按迁移口径重算的是 income**:`Σ ROUND(cost,2)`(目标态 `DECIMAL(12,2)`),
+//     而 oracle 侧是 `round(Σ cost, 3)`。这个差异正是三步归因第 1 步要量化的东西。
+func ComputeTarget(obs *OracleResult) (*TargetResult, error) {
+	if obs == nil || obs.Inputs.EligibleCount < 0 {
+		return nil, fmt.Errorf("oracle 观测为空")
+	}
+	out := &TargetResult{}
+
+	// ---- income:按迁移后口径逐行 ROUND 到分再求和 ----
+	var sumCents int64
+	for _, s := range obs.StudentsBefore {
+		sumCents += centsOf(s.Cost)
+	}
+	out.Income = centsToYuan(sumCents)
+
+	// ---- 规则:直接取 oracle 侧命中的同一条(隔离公式,不引入规则选择变量)----
+	if obs.Quote == nil {
+		// SP 无命中 ⇒ oracle 走退化分支,target 侧按"无方案"处理(3112 语义 → 0)
+		out.Reward = 0
+		out.PerRow = nil
+		return out, nil
+	}
+	q := &service.PayrollQuote{
+		Percentage: fmt.Sprintf("%.1f", obs.Quote.Percentage),
+		Min:        yuanToCents(obs.Quote.Min),
+		Max:        yuanToCents(obs.Quote.Max),
+		Reward:     yuanToCents(obs.Quote.Reward),
+	}
+	got, err := service.ComputeRecordRewardForDualRun(
+		q, money.FromCents(sumCents), int32(obs.Inputs.StudentNumber), obs.Inputs.TeacherRewardType)
+	if err != nil {
+		out.Error = err.Error()
+		return out, err
+	}
+	out.Reward = centsToYuan(got.Cents())
+
+	// ---- 逐行分摊:只分摊给 type ∈ {1,2},除数 = 该集合大小 ----
+	eligible := make([]StudentRow, 0, len(obs.StudentsBefore))
+	for _, s := range obs.StudentsBefore {
+		if s.Type == 1 || s.Type == 2 {
+			eligible = append(eligible, s)
+		}
+	}
+	sort.Slice(eligible, func(i, j int) bool { return eligible[i].StudentID < eligible[j].StudentID })
+	if n := len(eligible); n > 0 {
+		shares := service.DistributeRecordRewardForDualRun(got, n)
+		for i := range eligible {
+			out.RowStudents = append(out.RowStudents, eligible[i].StudentID)
+			out.PerRow = append(out.PerRow, centsToYuan(shares[i].Cents()))
+		}
+	}
+	return out, nil
+}
+
+// TargetIncomeLegacy 计算"按旧库 3 位精度"的 income(= oracle 侧的收入口径),用于第 1 步对比。
+func TargetIncomeLegacy(obs *OracleResult) float64 {
+	if obs == nil {
+		return 0
+	}
+	var sum float64
+	for _, s := range obs.StudentsBefore {
+		sum += s.Cost
+	}
+	return math.Round(sum*1000) / 1000
+}

+ 36 - 0
internal/academic/service/dualrun_shim.go

@@ -0,0 +1,36 @@
+package service
+
+import (
+	"yuansiwei/unified-backend/internal/academic/money"
+)
+
+// ---------------------------------------------------------------------------
+// dual-run harness 专用出口(AC-15 阶段 1)
+// ---------------------------------------------------------------------------
+//
+// 存在理由:`computeRecordReward` / `distributeAmount` 是**包内私有**,
+// 而双跑 harness 位于 `internal/academic/dualrun`,无法直接调用。
+//
+// 为什么不让 harness 自己再写一份公式:那会变成"用我们自己的第二份实现去校验第一份",
+// 一旦两份都错、错法一致,双跑会给出全绿结论——这正是 P0 里反复出现的"自证"陷阱。
+// 因此 harness 必须调到**生产代码本体**,这两个函数只做转发,不含任何逻辑。
+//
+// 约束:**只允许 dualrun 调用**。生产链路请直接用包内私有函数;
+// 若将来需要在仓库外暴露课酬计算,应设计正式 API 而不是复用这里。
+// ---------------------------------------------------------------------------
+
+// ComputeRecordRewardForDualRun 转发到 computeRecordReward(纯函数,无 DB 依赖)。
+//
+// 入参:q 命中规则;income 收入(分);studentCount 上课人数;rewardType 结算方式。
+// 出参:单节课课酬(分);错误语义与 computeRecordReward 完全一致。
+func ComputeRecordRewardForDualRun(q *PayrollQuote, income money.Amount, studentCount int32, rewardType int8) (money.Amount, error) {
+	return computeRecordReward(q, income, studentCount, rewardType)
+}
+
+// DistributeRecordRewardForDualRun 转发到 distributeAmount(纯函数)。
+//
+// 入参:total 单节课课酬合计;n 参与分摊的人数(= count(type∈{1,2}))。
+// 出参:逐行课酬(分),长度 = n,余数补首行。
+func DistributeRecordRewardForDualRun(total money.Amount, n int) []money.Amount {
+	return distributeAmount(total, n)
+}