run.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. package dualrun
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "sort"
  7. "time"
  8. )
  9. // 阶段 1 的冻结点:与 P0 实测一致的整秒 epoch(2026-03-31 10:00:00 +08:00)。
  10. //
  11. // 口径:两侧同源、**Truncate 到整秒**。理由见 P0 §6 组 2b:`SET timestamp` 连微秒都能冻,
  12. // 不取整就会出现"legacy 给 .123456、target 给 .000"的伪差异。
  13. const DefaultFrozenEpoch int64 = 1774922400
  14. // Options 是 stage1 的运行参数(全部来自 CLI)。
  15. type Options struct {
  16. // CaseSet:real100 / synthetic / all。
  17. CaseSet string
  18. // DryRun:只列 case,不执行。
  19. DryRun bool
  20. // OutJSON / OutMD 输出路径。
  21. OutJSON string
  22. OutMD string
  23. // Idempotency:每条 case 连跑两次比对结论。
  24. Idempotency bool
  25. // RealCount 真实基线条数(默认 100)。
  26. RealCount int
  27. // PerCat 合成每类条数(默认 5)。
  28. PerCat int
  29. // FrozenEpoch 冻结点(默认 DefaultFrozenEpoch)。
  30. FrozenEpoch int64
  31. // Now 选样用的"当前时间"(教龄换算用;零值取 time.Now)。
  32. Now time.Time
  33. }
  34. func (o *Options) normalize() {
  35. if o.CaseSet == "" {
  36. o.CaseSet = "all"
  37. }
  38. if o.RealCount <= 0 {
  39. o.RealCount = 100
  40. }
  41. if o.PerCat <= 0 {
  42. o.PerCat = 5
  43. }
  44. if o.FrozenEpoch == 0 {
  45. o.FrozenEpoch = DefaultFrozenEpoch
  46. }
  47. if o.Now.IsZero() {
  48. o.Now = time.Now()
  49. }
  50. if o.OutJSON == "" {
  51. o.OutJSON = "dualrun-stage1.json"
  52. }
  53. if o.OutMD == "" {
  54. o.OutMD = "dualrun-stage1.md"
  55. }
  56. }
  57. // RunStage1 执行阶段 1。
  58. //
  59. // 前置:调用方已 LoadConfigFromEnv 并传入;本函数内部会 Guard()(违反即 panic)。
  60. //
  61. // 出参:Report(同时已写入 OutJSON / OutMD);error 仅表示"环境层面无法继续"。
  62. func RunStage1(ctx context.Context, cfg Config, opts Options) (*Report, error) {
  63. opts.normalize()
  64. // ---- 守卫 1:host / 库名 / 口令(panic 语义见 Config.Guard)----
  65. cfg.Guard()
  66. report := &Report{
  67. Stage: "AC-15 stage1 (payroll chain)",
  68. GeneratedAt: time.Now().Format("2006-01-02 15:04:05"),
  69. CaseSet: opts.CaseSet,
  70. DryRun: opts.DryRun,
  71. FrozenEpoch: opts.FrozenEpoch,
  72. FrozenAt: time.Unix(opts.FrozenEpoch, 0).In(time.Local).Format("2006-01-02 15:04:05"),
  73. Guard: map[string]string{
  74. "host": maskHost(cfg.Host),
  75. "database": cfg.Name,
  76. "user": cfg.User,
  77. "口令": "已用 AC15_DB_PASS 传入(不记录)",
  78. },
  79. Assertions: map[string]string{},
  80. CaseCounts: map[string]int{},
  81. Summary: map[Attribution]int{},
  82. }
  83. db, err := cfg.Open()
  84. if err != nil {
  85. return nil, fmt.Errorf("连接失败:%w", err)
  86. }
  87. defer db.Close()
  88. // ---- 守卫 2:连上后核对 SELECT DATABASE() ----
  89. if err := AssertDatabase(db, cfg.Name); err != nil {
  90. return nil, fmt.Errorf("数据源守卫失败(SELECT DATABASE()):%w", err)
  91. }
  92. // ---- 启动静态断言:31 个 routine 里 sysdate( 必须为 0 ----
  93. nSys, err := AssertNoSysdate(db, cfg.Name)
  94. if err != nil {
  95. return nil, err
  96. }
  97. report.Assertions["sysdate_count"] = fmt.Sprintf("%d(必须为 0)", nSys)
  98. report.Assertions["frozen_clock"] = fmt.Sprintf("SET SESSION timestamp=%d;time_zone=+08:00;Truncate(time.Second)", opts.FrozenEpoch)
  99. report.Assertions["连接模型"] = "每个 case 一个新 PHP 进程(= 新连接);Go 侧另开只读连接选样"
  100. refs, err := loadRefs(db, cfg.Name)
  101. if err != nil {
  102. return nil, err
  103. }
  104. report.Assertions["参照表"] = fmt.Sprintf("payments=%d classify=%d courses=%d teachers=%d rules=%d",
  105. len(refs.pays), len(refs.classifies), len(refs.courseClass), len(refs.teachers), len(refs.detailByID))
  106. records, err := loadRecords(db, cfg.Name)
  107. if err != nil {
  108. return nil, err
  109. }
  110. report.Assertions["record_scan"] = fmt.Sprintf("%d 条(teacherid>0 且有 actual_start_time)", len(records))
  111. // ---- 覆盖率缺口:显式统计"被排除"的规模(不能靠 ORACLE-DEGRADED=0 暗示"没有这类")----
  112. report.Coverage = computeCoverage(records, refs, opts.Now)
  113. // ---- 组装 case ----
  114. var cases []Case
  115. if opts.CaseSet == "real100" || opts.CaseSet == "all" {
  116. cases = append(cases, BuildRealCases(records, refs, opts.RealCount, opts.Now)...)
  117. }
  118. if opts.CaseSet == "synthetic" || opts.CaseSet == "all" {
  119. syn, notes := BuildSyntheticCases(records, refs, opts.PerCat, opts.Now)
  120. cases = append(cases, syn...)
  121. report.MutationNotes = append(report.MutationNotes, notes...)
  122. }
  123. if len(cases) == 0 {
  124. return nil, fmt.Errorf("用例集 %q 组装结果为空", opts.CaseSet)
  125. }
  126. for _, c := range cases {
  127. report.CaseCounts[string(c.Kind)]++
  128. }
  129. // ---- dry-run:只列清单 ----
  130. if opts.DryRun {
  131. report.DryRunCases = cases
  132. if err := WriteJSON(report, opts.OutJSON); err != nil {
  133. return nil, err
  134. }
  135. if err := WriteMarkdown(report, opts.OutMD); err != nil {
  136. return nil, err
  137. }
  138. return report, nil
  139. }
  140. // ---- 实际执行 ----
  141. oracle, err := NewOracleRunner(cfg)
  142. if err != nil {
  143. return nil, err
  144. }
  145. defer oracle.Close()
  146. frozenAt := time.Unix(opts.FrozenEpoch, 0)
  147. // 事实依据:factsByRecord 用于第 2 步"复刻判定 vs SP 实返"比对
  148. factsByRecord := map[string]caseFacts{}
  149. for _, rec := range records {
  150. factsByRecord[rec.id] = refs.buildFacts(rec, opts.Now)
  151. }
  152. for _, c := range cases {
  153. // 单 case 失败不中断:记录后继续
  154. res := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
  155. report.Cases = append(report.Cases, res...)
  156. }
  157. // ---- 幂等:**所有 case 各跑第二遍**(抽样会漏,差异是逐 case 的)----
  158. if opts.Idempotency {
  159. for i, c := range cases {
  160. _ = i
  161. first := collectRows(report.Cases, c.ID)
  162. again := runOne(ctx, oracle, refs, factsByRecord, c, frozenAt, opts)
  163. second := collectRows(again, c.ID)
  164. report.Idempotency = append(report.Idempotency, IdempotencyCheck{
  165. CaseID: c.ID,
  166. First: first.attr,
  167. Second: second.attr,
  168. Same: first.attr == second.attr,
  169. RowsEqual: first.json == second.json,
  170. })
  171. }
  172. }
  173. // ---- 汇总 ----
  174. for _, cr := range report.Cases {
  175. report.Summary[cr.Attribution]++
  176. }
  177. if err := WriteJSON(report, opts.OutJSON); err != nil {
  178. return nil, err
  179. }
  180. if err := WriteMarkdown(report, opts.OutMD); err != nil {
  181. return nil, err
  182. }
  183. return report, nil
  184. }
  185. type rowSet struct {
  186. attr Attribution
  187. json string
  188. }
  189. func collectRows(results []CaseResult, caseID string) rowSet {
  190. for _, r := range results {
  191. if r.CaseID != caseID {
  192. continue
  193. }
  194. b, _ := json.Marshal(r.Rows)
  195. return rowSet{attr: r.Attribution, json: string(b)}
  196. }
  197. return rowSet{}
  198. }
  199. // runOne 跑一条 case(BothSupplement 时内部跑两次)。
  200. func runOne(ctx context.Context, oracle *OracleRunner, refs *refs, facts map[string]caseFacts,
  201. c Case, frozenAt time.Time, opts Options) []CaseResult {
  202. variants := []bool{c.IsSupplement}
  203. if c.BothSupplement {
  204. variants = []bool{false, true}
  205. }
  206. var out []CaseResult
  207. for _, sup := range variants {
  208. cc := c
  209. cc.IsSupplement = sup
  210. res := CaseResult{
  211. CaseID: c.ID,
  212. Category: c.Category,
  213. Kind: c.Kind,
  214. RecordID: c.RecordID,
  215. IsSupplement: sup,
  216. Note: c.Note,
  217. }
  218. if c.BothSupplement {
  219. res.CaseID = fmt.Sprintf("%s/sup=%v", c.ID, sup)
  220. }
  221. obs, err := oracle.Run(ctx, cc, frozenAt)
  222. if err != nil && obs == nil {
  223. res.Attribution = AttrEnvFailure
  224. res.Note = joinNote(res.Note, "oracle 进程失败:"+err.Error())
  225. out = append(out, res)
  226. continue
  227. }
  228. res.Oracle = obs
  229. tgt, terr := ComputeTarget(obs)
  230. res.Target = tgt
  231. if terr != nil {
  232. res.Attribution = AttrEnvFailure
  233. res.Note = joinNote(res.Note, "target 计算出错:"+terr.Error())
  234. out = append(out, res)
  235. continue
  236. }
  237. f := facts[c.RecordID]
  238. rows, attr, s1 := Attribute(AttrInput{
  239. Case: cc,
  240. Oracle: obs,
  241. Target: tgt,
  242. PredictedDetailID: f.rule.id,
  243. PredictedTier: tierLabel(f.tier),
  244. })
  245. // A24:结算必抛异常,异常本身不是失败;把它记进备注便于复核
  246. if obs.Exception != nil {
  247. res.Note = joinNote(res.Note, fmt.Sprintf("oracle 抛出 %s(A24 预期,异常前写入值已读回)", obs.Exception.Message))
  248. }
  249. res.Rows = rows
  250. res.Step1 = s1
  251. res.Attribution = CaseAttribution(rows, attr)
  252. out = append(out, res)
  253. }
  254. return out
  255. }
  256. func joinNote(a, b string) string {
  257. if a == "" {
  258. return b
  259. }
  260. return a + ";" + b
  261. }
  262. // SortedCaseIDs 便于测试与调试。
  263. func SortedCaseIDs(cases []Case) []string {
  264. ids := make([]string, 0, len(cases))
  265. for _, c := range cases {
  266. ids = append(ids, c.ID)
  267. }
  268. sort.Strings(ids)
  269. return ids
  270. }
  271. // computeCoverage 计算"真实基线候选总体"与"被排除"的规模。
  272. //
  273. // 为什么要单独算:本轮 real100 只挑「有规则命中」的记录,所以 ORACLE-DEGRADED 必然是 0;
  274. // 若报告只写 0,读的人会误以为"全库都覆盖了"。覆盖率缺口必须显式给出数字。
  275. func computeCoverage(records []recordRow, r *refs, now time.Time) map[string]string {
  276. var total, eligible, hit, noHit, noIncome, noEligible int
  277. for _, rec := range records {
  278. total++
  279. if rec.status != 2 || rec.income <= 0 {
  280. noIncome++
  281. continue
  282. }
  283. f := r.buildFacts(rec, now)
  284. if f.agg.elig <= 0 {
  285. noEligible++
  286. continue
  287. }
  288. eligible++
  289. if f.hit {
  290. hit++
  291. } else {
  292. noHit++
  293. }
  294. }
  295. ratio := 0.0
  296. if total > 0 {
  297. ratio = float64(noHit) * 100 / float64(total)
  298. }
  299. return map[string]string{
  300. "全库记录数": fmt.Sprintf("%d", total),
  301. "真实基线候选总体": fmt.Sprintf("%d(status=2 且 income>0 且有有效学生)", eligible),
  302. "其中有规则命中": fmt.Sprintf("%d", hit),
  303. "其中无命中被排除": fmt.Sprintf("%d(%.2f%% of 全库)未参与本轮比对(R14 / oracle-degraded)", noHit, ratio),
  304. "其余未入选原因": fmt.Sprintf("status≠2 或 income≤0:%d 条;无有效学生:%d 条", noIncome, noEligible),
  305. }
  306. }