attribution.go 7.7 KB

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