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 tolerance := math.Max(0.015, math.Abs(expectedDelta)+0.015) // 1.5 分容差 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 }