attribution.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. package dualrun
  2. import (
  3. "fmt"
  4. "math"
  5. )
  6. // severity 把归因折算成"严重度",用于把一条 case 的多行结论合成一条 case 结论。
  7. //
  8. // 取最大值(越严重越优先),保证"有一条真缺陷"不会被"其余都 MATCH"淹没。
  9. func severity(a Attribution) int {
  10. switch a {
  11. case AttrEnvFailure:
  12. return 5
  13. case AttrDefect:
  14. return 4
  15. case AttrOracleDegraded:
  16. return 3
  17. case AttrPathDivergence:
  18. return 2
  19. case AttrPrecisionConvergence:
  20. return 1
  21. default:
  22. return 0
  23. }
  24. }
  25. // worse 返回两者中更严重的归因。
  26. func worse(a, b Attribution) Attribution {
  27. if severity(b) > severity(a) {
  28. return b
  29. }
  30. return a
  31. }
  32. // AttrInput 是归因所需的一切(避免把 refs 传进纯函数)。
  33. type AttrInput struct {
  34. Case Case
  35. // Oracle oracle 侧观测。
  36. Oracle *OracleResult
  37. // Target target 侧纯函数结果。
  38. Target *TargetResult
  39. // PredictedDetailID / PredictedTier 是 runner 复刻的兜底判定(用于校验 SP 实返是否与理解一致)。
  40. PredictedDetailID int64
  41. PredictedTier string
  42. }
  43. // Attribute 执行**三步分层归因**(顺序不可换),返回逐行差异与 case 结论。
  44. //
  45. // 第 1 步 income:legacy `round(Σcost,3)` vs 迁移后 `Σ ROUND(cost,2)` —— 不等属精度收敛,继续往下看。
  46. // 第 2 步 规则:oracle 实返的 detail_id 与 runner 复刻判定比对 —— 不等是真缺陷(规则选错/层级错)。
  47. // 第 3 步 teacher_reward:不等时用 `Δreward ≈ Δincome × pct/100` 判断是基数不同还是算法不同。
  48. func Attribute(in AttrInput) ([]DiffRow, Attribution, *Step1Income) {
  49. obs, tgt, c := in.Oracle, in.Target, in.Case
  50. // ---- 环境层面:拿不到观测就不判定 ----
  51. if obs == nil || tgt == nil {
  52. return []DiffRow{{
  53. CaseID: c.ID, Table: "-", PK: c.RecordID, Column: "-",
  54. Legacy: "-", Target: "-", Delta: "-", Attribution: AttrEnvFailure,
  55. }}, AttrEnvFailure, nil
  56. }
  57. if !obs.RollbackRestored {
  58. return []DiffRow{{
  59. CaseID: c.ID, Table: "t_courses_record", PK: c.RecordID, Column: "(rollback)",
  60. Legacy: "restored", Target: "NOT restored", Delta: "-", Attribution: AttrEnvFailure,
  61. }}, AttrEnvFailure, nil
  62. }
  63. // ---- 第 1 步:income ----
  64. legacyIncome := obs.Inputs.IncomeSumCost3
  65. targetIncome := tgt.Income
  66. s1 := &Step1Income{
  67. Legacy: legacyIncome,
  68. Target: targetIncome,
  69. Delta: math.Round((targetIncome-legacyIncome)*1000000) / 1000000,
  70. }
  71. s1.EqualCents = centsOf(legacyIncome) == centsOf(targetIncome)
  72. s1.Explanation = fmt.Sprintf("legacy round(Σcost,3)=%.3f vs 迁移后 ΣROUND(cost,2)=%.3f", legacyIncome, targetIncome)
  73. // ---- oracle 退化:不可比,直接标记(R14)----
  74. if obs.Quote == nil {
  75. return []DiffRow{{
  76. CaseID: c.ID, Table: "t_course_payment_detail", PK: c.RecordID, Column: "detail_id",
  77. Legacy: "未命中(退化分支)", Target: fmt.Sprintf("%.2f (无方案→0)", tgt.Reward),
  78. Delta: "-", Attribution: AttrOracleDegraded,
  79. }}, AttrOracleDegraded, s1
  80. }
  81. // ---- 第 2 步:规则命中是否与复刻判定一致 ----
  82. var rows []DiffRow
  83. attr := AttrMatch
  84. actualDetail := int64(obs.Quote.DetailID)
  85. if in.PredictedDetailID != 0 && actualDetail != in.PredictedDetailID {
  86. rows = append(rows, DiffRow{
  87. CaseID: c.ID, Table: "t_course_payment_detail", PK: c.RecordID, Column: "detail_id",
  88. Legacy: fmt.Sprintf("%d", actualDetail), Target: fmt.Sprintf("%d", in.PredictedDetailID),
  89. Delta: "-", Attribution: AttrDefect,
  90. })
  91. attr = worse(attr, AttrDefect)
  92. }
  93. if c.ExpectTier != "" && in.PredictedTier != "" && c.ExpectTier != in.PredictedTier {
  94. rows = append(rows, DiffRow{
  95. CaseID: c.ID, Table: "path", PK: c.RecordID, Column: "tier",
  96. Legacy: in.PredictedTier, Target: c.ExpectTier, Delta: "-", Attribution: AttrPathDivergence,
  97. })
  98. attr = worse(attr, AttrPathDivergence)
  99. }
  100. // ---- 第 3 步:teacher_reward ----
  101. legacyReward := 0.0
  102. if obs.After != nil {
  103. legacyReward = obs.After.TeacherReward
  104. }
  105. rewardRow := DiffRow{
  106. CaseID: c.ID, Table: "t_courses_record", PK: c.RecordID, Column: "teacher_reward",
  107. Legacy: fmt.Sprintf("%.3f", legacyReward),
  108. Target: fmt.Sprintf("%.2f", tgt.Reward),
  109. Delta: fmt.Sprintf("%+.3f", tgt.Reward-legacyReward),
  110. }
  111. if centsOf(legacyReward) == centsOf(tgt.Reward) {
  112. rewardRow.Attribution = AttrMatch
  113. rows = append(rows, rewardRow)
  114. } else {
  115. // 用"差值是否成比例"区分基数不同 vs 算法不同
  116. expectedDelta := s1.Delta * obs.Quote.Percentage / 100
  117. actualDelta := tgt.Reward - legacyReward
  118. tolerance := math.Max(0.015, math.Abs(expectedDelta)+0.015) // 1.5 分容差
  119. if math.Abs(actualDelta-expectedDelta) <= tolerance {
  120. rewardRow.Attribution = AttrPrecisionConvergence
  121. rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f)", expectedDelta, actualDelta)
  122. rows = append(rows, rewardRow)
  123. attr = worse(attr, AttrPrecisionConvergence)
  124. if attr == AttrPathDivergence { // 精度收敛不应压下 path-divergence 之外的东西
  125. attr = AttrPrecisionConvergence
  126. }
  127. } else {
  128. rewardRow.Attribution = AttrDefect
  129. rewardRow.Column += fmt.Sprintf("(Δ预期 %+.4f, 实测 %+.4f, 不成比例)", expectedDelta, actualDelta)
  130. rows = append(rows, rewardRow)
  131. attr = worse(attr, AttrDefect)
  132. }
  133. }
  134. // ---- 逐行分摊(R13:不参与通过判定,但必须逐行记录)----
  135. rows = append(rows, attributePerRow(c, obs, tgt, attr)...)
  136. return rows, attr, s1
  137. }
  138. // attributePerRow 逐行比对;差异按 R13 归因(PHP 每行 round(,3) 不守恒 vs Go 分位分配守恒)。
  139. func attributePerRow(c Case, obs *OracleResult, tgt *TargetResult, caseAttr Attribution) []DiffRow {
  140. legacy := map[int64]float64{}
  141. for _, s := range obs.StudentsAfter {
  142. if s.Type == 1 || s.Type == 2 {
  143. legacy[s.StudentID] = s.TeacherReward
  144. }
  145. }
  146. var rows []DiffRow
  147. legacySum := 0.0
  148. for _, v := range legacy {
  149. legacySum += v
  150. }
  151. targetSum := 0.0
  152. for _, v := range tgt.PerRow {
  153. targetSum += v
  154. }
  155. totalLegacy := 0.0
  156. if obs.After != nil {
  157. totalLegacy = obs.After.TeacherReward
  158. }
  159. for i, sid := range tgt.RowStudents {
  160. lv, ok := legacy[sid]
  161. if !ok {
  162. continue
  163. }
  164. tv := tgt.PerRow[i]
  165. if centsOf(lv) == centsOf(tv) {
  166. continue // 逐行相等就不刷行,避免报告噪声
  167. }
  168. rowAttr := AttrDefect
  169. // PHP 逐行和不等于 record 级总额(不守恒),而 Go 精确守恒 ⇒ R13
  170. if math.Abs(legacySum-totalLegacy) > 0.0005 && math.Abs(targetSum-totalLegacy) < 0.005 {
  171. rowAttr = AttrPrecisionConvergence
  172. } else if caseAttr == AttrPrecisionConvergence {
  173. rowAttr = AttrPrecisionConvergence
  174. }
  175. rows = append(rows, DiffRow{
  176. CaseID: c.ID, Table: "t_courses_record_student", PK: fmt.Sprintf("%s/%d", c.RecordID, sid),
  177. Column: "teacher_reward",
  178. Legacy: fmt.Sprintf("%.3f", lv), Target: fmt.Sprintf("%.2f", tv),
  179. Delta: fmt.Sprintf("%+.3f", tv-lv), Attribution: rowAttr,
  180. })
  181. }
  182. return rows
  183. }
  184. // CaseAttribution 把一行行结论合成 case 结论(取最严重)。
  185. func CaseAttribution(rows []DiffRow, fallback Attribution) Attribution {
  186. out := fallback
  187. for _, r := range rows {
  188. out = worse(out, r.Attribution)
  189. }
  190. return out
  191. }