| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- package dualrun
- import (
- "fmt"
- "math"
- )
- // milliOf 把金额折算成"毫"(1e-3 元)。PHP 侧金额精度是 3 位小数,
- // 判断"基数有没有变"必须在这个精度上做,用"分"会漏掉 0.001~0.005 的收敛。
- func milliOf(v float64) int64 {
- if v < 0 {
- return -int64(math.Round(-v * 1000))
- }
- return int64(math.Round(v * 1000))
- }
- // severity 把归因折算成"严重度",用于把一条 case 的多行结论合成一条 case 结论。
- //
- // 取最大值(越严重越优先),保证"有一条真缺陷"不会被"其余都 MATCH"淹没。
- func severity(a Attribution) int {
- switch a {
- case AttrEnvFailure:
- return 6
- case AttrDefect:
- return 5
- case AttrOracleDegraded:
- return 4
- case AttrPathDivergence:
- return 3
- case AttrRoundingConvention:
- 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)
- // 造数 case 的"复刻判定"是在**造数之前**的数据上算的,与 SP 实返不可比(必然不同),
- // 这两项只对非造数 case 生效;造数 case 的正确性由 ExpectTier → 实返规则字段自证。
- skipPathCheck := len(c.Mutations) > 0
- if !skipPathCheck && 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 !skipPathCheck && 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),
- }
- // Δincome 必须在**毫(1e-3)**上判断是否为 0:PHP 在 3 位小数上工作,
- // 用"分"判断会把 349.347 → 349.35(Δ=+0.003,分位相同)误当成"基数没变"。
- deltaIncomeMilli := milliOf(targetIncome) - milliOf(legacyIncome)
- if centsOf(legacyReward) == centsOf(tgt.Reward) {
- rewardRow.Attribution = AttrMatch
- rows = append(rows, rewardRow)
- } else {
- // 判据顺序(不可换):
- // ① Δincome ≠ 0 且金额成比例 ⇒ PRECISION-CONVERGENCE(基数变了)
- // ② Δincome = 0 ⇒ DEFECT(基数没变而金额变了 = 算法不同)
- // ③ 其余(不成比例) ⇒ DEFECT
- expectedDelta := s1.Delta * obs.Quote.Percentage / 100
- actualDelta := tgt.Reward - legacyReward
- // 容差 = 半分 + 基数差传递过来的预期差。
- // 理论依据:舍入到"分"的最大误差是半分(0.005),再加上 Δincome×pct/100 传过来的量,
- // 两者相加就是"理论上允许的最大差"。**不得为了消红而放宽**(容差是判据,不是目标)。
- tolerance := 0.005 + math.Abs(expectedDelta)
- proportional := math.Abs(actualDelta-expectedDelta) <= tolerance
- switch {
- case deltaIncomeMilli != 0 && proportional:
- rewardRow.Attribution = AttrPrecisionConvergence
- rewardRow.Column += fmt.Sprintf("(Δincome=%+.3f, Δ预期 %+.4f, 实测 %+.4f)", s1.Delta, expectedDelta, actualDelta)
- attr = worse(attr, AttrPrecisionConvergence)
- case deltaIncomeMilli == 0:
- rewardRow.Attribution = AttrDefect
- rewardRow.Column += fmt.Sprintf("(Δincome=0 而 Δreward=%+.4f ⇒ 基数相同、算法不同)", actualDelta)
- attr = worse(attr, AttrDefect)
- default:
- rewardRow.Attribution = AttrDefect
- rewardRow.Column += fmt.Sprintf("(Δincome=%+.3f, Δ预期 %+.4f, 实测 %+.4f, 不成比例)", s1.Delta, expectedDelta, actualDelta)
- attr = worse(attr, AttrDefect)
- }
- rows = append(rows, rewardRow)
- }
- // ---- 逐行分摊(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
- }
- }
- if !obs.PerRowRewritten {
- // PHP 没进分摊分支(recount=false 且 type≠2)⇒ 逐行保持历史值,
- // 拿历史值比 Go 的分摊结果没有意义,直接跳过(不是通过,也不是差异)。
- return nil
- }
- 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
- deltaIncomeCents := milliOf(tgt.Income) - milliOf(obs.Inputs.IncomeSumCost3)
- switch {
- case deltaIncomeCents == 0:
- // Δincome = 0 而逐行不等 ⇒ 只可能是分摊方式不同(R13)
- rowAttr = AttrRoundingConvention
- case math.Abs(legacySum-totalLegacy) > 0.0005 && math.Abs(targetSum-totalLegacy) < 0.005:
- // PHP 逐行和不等于 record 总额(不守恒)、Go 精确守恒 ⇒ 舍入口径 + 基数变化
- rowAttr = AttrRoundingConvention
- case 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
- }
|