// Package dualrun 是 AC-15 双跑 harness 的阶段 1 实现。 // // 范围(**刻意收窄**):只比对两条链路 // - oracle(legacy,PHP 真跑):CoursesRecordHelper::settlementExpenses 结课结算 // - target(Go,纯函数):service.computeRecordReward + 逐行分摊 // // 不做的:28 个写 SP、活动链、分账链(阶段 2~4)。 // // 两侧**故意不对称**(见 README §1): // - oracle 侧真跑 PHP 并写库(事务内,finally 回滚) // - target 侧只调纯函数,**不连 yuansiwei_2026** // // 这个不对称是设计选择,不是遗漏:课酬是"由输入算出一个金额",用纯函数就足以判定, // 而且 target 库没有对应历史数据,真跑反而引入无关变量。 package dualrun // Attribution 是差异归因,取值**只有六个**(报告与统计都靠它分类,不得自增)。 type Attribution string const ( // AttrMatch 两侧一致(按分层判据逐分相等)。 AttrMatch Attribution = "MATCH" // AttrPrecisionConvergence 差异由 income 基数收敛引起(Δreward ≈ Δincome × pct/100)。 AttrPrecisionConvergence Attribution = "PRECISION-CONVERGENCE" // AttrRoundingConvention Δincome = 0 但逐行分摊方式不同(R13:PHP 每行 round(,3) vs Go 分位分配+余数)。 // // 与 AttrPrecisionConvergence 的区别是本质的:前者的 Δincome **恒为 0**, // 差异只来自"怎么分";后者 Δincome ≠ 0,差异来自"基数不同"。合并统计会看不清。 AttrRoundingConvention Attribution = "ROUNDING-CONVENTION" // AttrDefect 真缺陷(规则选错 / 差异与基数不成比例 / 公式不一致)。 AttrDefect Attribution = "DEFECT" // AttrOracleDegraded oracle 侧走了退化分支(SP 无命中),不可比。 AttrOracleDegraded Attribution = "ORACLE-DEGRADED" // AttrPathDivergence 数值相同但路径不同(约束 17)。 AttrPathDivergence Attribution = "PATH-DIVERGENCE" // AttrEnvFailure 环境失败(PHP 进程失败 / 守卫不通过 / 数据缺失等),不是业务结论。 AttrEnvFailure Attribution = "ENV-FAILURE" ) // AllAttributions 供汇总按固定顺序输出六类计数。 var AllAttributions = []Attribution{ AttrMatch, AttrPrecisionConvergence, AttrRoundingConvention, AttrDefect, AttrOracleDegraded, AttrPathDivergence, AttrEnvFailure, } // CaseKind 区分真实基线与合成边界。 type CaseKind string const ( // KindReal 真实基线(从 legacy_oracle 里挑出来的已结课记录)。 KindReal CaseKind = "real" // KindSynthetic 合成边界(事务内造数后跑,或按层级/数值特征定向挑选)。 KindSynthetic CaseKind = "synthetic" ) // Case 是一个待跑的双跑用例。 // // 字段设计取向:**一切影响结论的输入都显式落在结构体里**,便于 dry-run 审阅与幂等复现。 type Case struct { // ID 稳定可复现的用例 id(real-0001 / syn-tier-cert-1 …),报告以它为主键。 ID string `json:"id"` // Kind 真实 / 合成。 Kind CaseKind `json:"kind"` // Category 用例类别(baseline / tier-* / income-3dp / income-zero-min / supplement-both / nondivisible / rewardtype2)。 Category string `json:"category"` // RecordID t_courses_record.id(varchar(32))。 RecordID string `json:"recordId"` // IsSupplement 传给 settlementExpenses 的第 2 个参数(bool $isSupplement)。 IsSupplement bool `json:"isSupplement"` // BothSupplement 为 true 时,同一 case 会跑 false / true 两遍并各自出结论(类别 supplement-both)。 BothSupplement bool `json:"bothSupplement"` // Mutations 事务内、结算前执行的 SQL(仅合成 case;全部在 legacy_oracle 的事务里,随回滚消失)。 Mutations []string `json:"mutations,omitempty"` // ExpectTier 期望命中的兜底层级(路径证据用;空表示不校验)。 ExpectTier string `json:"expectTier,omitempty"` // Expectation 人类可读的期望说明(dry-run 里给人看)。 Expectation string `json:"expectation,omitempty"` // Note 备注(为什么造这条 case)。 Note string `json:"note,omitempty"` } // Rule 是 SP 命中的课酬规则快照(金额单位为元,与旧库 float 列一致)。 type Rule struct { DetailID int64 `json:"detailId"` PaymentID int64 `json:"paymentId"` Percentage string `json:"percentage"` // "25.0"(百分数) Min float64 `json:"min"` Max float64 `json:"max"` Reward float64 `json:"reward"` ClassType int64 `json:"classType"` GradeID int64 `json:"gradeId"` SubjectID int64 `json:"subjectId"` ExpMin float64 `json:"expMin"` ExpMax float64 `json:"expMax"` TeacherID int64 `json:"teacherId"` TeacherCert int64 `json:"teacherCert"` ClassifyID int64 `json:"classifyId"` } // PathEvidence 是"路径证据"(约束 17):数值相等但路径不同必须能被看出来。 type PathEvidence struct { // Tier 命中的兜底层级:teacher-classify / teacher-grade-subject / cert-tier / generic-tier / degraded / none。 Tier string `json:"tier"` // DetailID 命中的规则 id(0 = 未命中)。 DetailID int64 `json:"detailId"` // Degraded oracle 侧是否走了 legacy 退化分支(SP 无命中)。 Degraded bool `json:"degraded"` // IsSupplement 本次执行用的 isSupplement 取值。 IsSupplement bool `json:"isSupplement"` // RewardType 结算方式(1 正常 / 2 固定课酬)。 RewardType int8 `json:"rewardType"` // Recount 是否进入"重写逐行"分支(PHP 的 recountTeacherReward 语义)。 Recount bool `json:"recount"` } // OracleResult 是 PHP 探针返回的 oracle 观测值。 type OracleResult struct { OK bool `json:"ok"` Error string `json:"error"` ErrorDetail string `json:"errorDetail"` RecordID string `json:"recordId"` IsSupplement bool `json:"isSupplement"` // Inputs 是**造数之后**重读的权威输入(PHP 结算用的就是这一份)。 Inputs OracleIn `json:"inputsEffective"` // InputsBefore 是造数之前的输入,仅用于溯源对比。 InputsBefore OracleIn `json:"inputsBeforeMutation"` // StudentsEffective 是造数之后的打卡行(target 侧必须用这一份)。 StudentsEffective []StudentRow `json:"studentsEffective"` // PerRowRewritten PHP 是否真的重写了逐行(false 时逐行比对无意义,会与历史值比较)。 PerRowRewritten bool `json:"perRowRewritten"` Quote *RuleRaw `json:"quote"` Clock ClockInfo `json:"clock"` Mutations []string `json:"mutations"` MutationErrs []struct { SQL string `json:"sql"` Error string `json:"error"` } `json:"mutationErrors"` Return *bool `json:"return"` Exception *OracleError `json:"exception"` After *RecordAfter `json:"after"` StudentsBefore []StudentRow `json:"studentsBefore"` StudentsAfter []StudentRow `json:"studentsAfter"` RollbackRestored bool `json:"rollbackRestored"` } // OracleIn 是 oracle 侧读到的输入。 type OracleIn struct { SchoolID int64 `json:"schoolid"` CampusID int64 `json:"campusid"` TeacherID int64 `json:"teacherid"` CoursesID int64 `json:"coursesid"` ClassifyID int64 `json:"classifyid"` TeacherExp float64 `json:"teacherExp"` TeacherRewardType int8 `json:"teacherRewardType"` StudentNumber int `json:"studentNumber"` EligibleCount int `json:"eligibleCount"` IncomeSumCost3 float64 `json:"incomeSumCost3"` StatusBefore int `json:"statusBefore"` TeacherRewardBefore float64 `json:"teacherRewardBefore"` ActualStartTime string `json:"actualStartTime"` } // RuleRaw 是探针里 quote 的原始形状(金额为元)。 type RuleRaw struct { DetailID float64 `json:"id"` PaymentID float64 `json:"course_paymentid"` Percentage float64 `json:"percentage"` Min float64 `json:"min_payment"` Max float64 `json:"max_payment"` Reward float64 `json:"reward"` ClassType float64 `json:"class_type"` GradeID float64 `json:"gradeid"` SubjectID float64 `json:"subjectid"` ClassifyID float64 `json:"classifyid"` TeacherCert float64 `json:"teacher_cert"` ExpMin float64 `json:"teacher_exp_min"` ExpMax float64 `json:"teacher_exp_max"` } // ClockInfo 是冻结时钟的实测证据(A22 用)。 type ClockInfo struct { FrozenEpoch int64 `json:"frozenEpoch"` MySQLNow string `json:"now"` MySQLUnix string `json:"unixNow"` PHPTime int64 `json:"phpTime"` PHPDate string `json:"phpDate"` TimeZone string `json:"timeZone"` } // OracleError 是结算抛出的异常(A24 说明它必抛)。 type OracleError struct { Class string `json:"class"` Message string `json:"message"` File string `json:"file"` Line int `json:"line"` } // RecordAfter 是"异常前已写入"的 record 观测值。 type RecordAfter struct { Status int `json:"status"` Income float64 `json:"income"` TeacherReward float64 `json:"teacherReward"` StudentNumber int `json:"studentNumber"` ActualEndTime string `json:"actualEndTime"` } // StudentRow 是逐行打卡观测值。 type StudentRow struct { StudentID int64 `json:"studentid"` Type int `json:"type"` Cost float64 `json:"cost"` TeacherReward float64 `json:"teacherReward"` } // TargetResult 是 Go 纯函数算出的 target 侧答案。 type TargetResult struct { Income float64 `json:"income"` Reward float64 `json:"reward"` PerRow []float64 `json:"perRow"` RowStudents []int64 `json:"rowStudents"` Error string `json:"error,omitempty"` } // Step1Income 是三步归因的第一步结果。 type Step1Income struct { Legacy float64 `json:"legacy"` // round(Σcost, 3) Target float64 `json:"target"` // Σ ROUND(cost, 2) Delta float64 `json:"delta"` // target - legacy EqualCents bool `json:"equalCents"` // 分位是否相等 Explanation string `json:"explanation"` } // DiffRow 是报告里的一行差异(每条一行)。 type DiffRow struct { CaseID string `json:"caseId"` Table string `json:"table"` PK string `json:"pk"` Column string `json:"column"` Legacy string `json:"legacy"` Target string `json:"target"` Delta string `json:"delta"` Attribution Attribution `json:"attribution"` } // CaseResult 是一条 case 的完整结论(含中间量与路径证据)。 type CaseResult struct { CaseID string `json:"caseId"` Category string `json:"category"` Kind CaseKind `json:"kind"` RecordID string `json:"recordId"` IsSupplement bool `json:"isSupplement"` Attribution Attribution `json:"attribution"` Oracle *OracleResult `json:"oracle,omitempty"` Target *TargetResult `json:"target,omitempty"` Step1 *Step1Income `json:"step1,omitempty"` Rows []DiffRow `json:"rows,omitempty"` Note string `json:"note,omitempty"` } // Report 是整轮双跑的产物(JSON + Markdown 同源)。 type Report struct { Stage string `json:"stage"` GeneratedAt string `json:"generatedAt"` CaseSet string `json:"caseSet"` DryRun bool `json:"dryRun"` FrozenEpoch int64 `json:"frozenEpoch"` FrozenAt string `json:"frozenAt"` Guard map[string]string `json:"guard"` Assertions map[string]string `json:"assertions"` // Coverage 是覆盖率缺口(被排除的规模)。必须显式写进报告: // 本轮只挑"有规则命中"的记录,所以 ORACLE-DEGRADED=0 只代表"本轮 case 集里没有这类"。 Coverage map[string]string `json:"coverage"` CaseCounts map[string]int `json:"caseCounts"` Summary map[Attribution]int `json:"summary"` Cases []CaseResult `json:"cases"` DryRunCases []Case `json:"dryRunCases,omitempty"` Idempotency []IdempotencyCheck `json:"idempotency,omitempty"` MutationNotes []string `json:"mutationNotes,omitempty"` } // IdempotencyCheck 记录同一 case 连跑两次是否结论一致。 type IdempotencyCheck struct { CaseID string `json:"caseId"` First Attribution `json:"first"` Second Attribution `json:"second"` Same bool `json:"same"` RowsEqual bool `json:"rowsEqual"` }