attribution.go 8.7 KB

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