model.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. // Package dualrun 是 AC-15 双跑 harness 的阶段 1 实现。
  2. //
  3. // 范围(**刻意收窄**):只比对两条链路
  4. // - oracle(legacy,PHP 真跑):CoursesRecordHelper::settlementExpenses 结课结算
  5. // - target(Go,纯函数):service.computeRecordReward + 逐行分摊
  6. //
  7. // 不做的:28 个写 SP、活动链、分账链(阶段 2~4)。
  8. //
  9. // 两侧**故意不对称**(见 README §1):
  10. // - oracle 侧真跑 PHP 并写库(事务内,finally 回滚)
  11. // - target 侧只调纯函数,**不连 yuansiwei_2026**
  12. //
  13. // 这个不对称是设计选择,不是遗漏:课酬是"由输入算出一个金额",用纯函数就足以判定,
  14. // 而且 target 库没有对应历史数据,真跑反而引入无关变量。
  15. package dualrun
  16. // Attribution 是差异归因,取值**只有六个**(报告与统计都靠它分类,不得自增)。
  17. type Attribution string
  18. const (
  19. // AttrMatch 两侧一致(按分层判据逐分相等)。
  20. AttrMatch Attribution = "MATCH"
  21. // AttrPrecisionConvergence 差异由 income 基数收敛引起(Δreward ≈ Δincome × pct/100)。
  22. AttrPrecisionConvergence Attribution = "PRECISION-CONVERGENCE"
  23. // AttrRoundingConvention Δincome = 0 但逐行分摊方式不同(R13:PHP 每行 round(,3) vs Go 分位分配+余数)。
  24. //
  25. // 与 AttrPrecisionConvergence 的区别是本质的:前者的 Δincome **恒为 0**,
  26. // 差异只来自"怎么分";后者 Δincome ≠ 0,差异来自"基数不同"。合并统计会看不清。
  27. AttrRoundingConvention Attribution = "ROUNDING-CONVENTION"
  28. // AttrDefect 真缺陷(规则选错 / 差异与基数不成比例 / 公式不一致)。
  29. AttrDefect Attribution = "DEFECT"
  30. // AttrOracleDegraded oracle 侧走了退化分支(SP 无命中),不可比。
  31. AttrOracleDegraded Attribution = "ORACLE-DEGRADED"
  32. // AttrPathDivergence 数值相同但路径不同(约束 17)。
  33. AttrPathDivergence Attribution = "PATH-DIVERGENCE"
  34. // AttrEnvFailure 环境失败(PHP 进程失败 / 守卫不通过 / 数据缺失等),不是业务结论。
  35. AttrEnvFailure Attribution = "ENV-FAILURE"
  36. )
  37. // AllAttributions 供汇总按固定顺序输出六类计数。
  38. var AllAttributions = []Attribution{
  39. AttrMatch, AttrPrecisionConvergence, AttrRoundingConvention, AttrDefect,
  40. AttrOracleDegraded, AttrPathDivergence, AttrEnvFailure,
  41. }
  42. // CaseKind 区分真实基线与合成边界。
  43. type CaseKind string
  44. const (
  45. // KindReal 真实基线(从 legacy_oracle 里挑出来的已结课记录)。
  46. KindReal CaseKind = "real"
  47. // KindSynthetic 合成边界(事务内造数后跑,或按层级/数值特征定向挑选)。
  48. KindSynthetic CaseKind = "synthetic"
  49. )
  50. // Case 是一个待跑的双跑用例。
  51. //
  52. // 字段设计取向:**一切影响结论的输入都显式落在结构体里**,便于 dry-run 审阅与幂等复现。
  53. type Case struct {
  54. // ID 稳定可复现的用例 id(real-0001 / syn-tier-cert-1 …),报告以它为主键。
  55. ID string `json:"id"`
  56. // Kind 真实 / 合成。
  57. Kind CaseKind `json:"kind"`
  58. // Category 用例类别(baseline / tier-* / income-3dp / income-zero-min / supplement-both / nondivisible / rewardtype2)。
  59. Category string `json:"category"`
  60. // RecordID t_courses_record.id(varchar(32))。
  61. RecordID string `json:"recordId"`
  62. // IsSupplement 传给 settlementExpenses 的第 2 个参数(bool $isSupplement)。
  63. IsSupplement bool `json:"isSupplement"`
  64. // BothSupplement 为 true 时,同一 case 会跑 false / true 两遍并各自出结论(类别 supplement-both)。
  65. BothSupplement bool `json:"bothSupplement"`
  66. // Mutations 事务内、结算前执行的 SQL(仅合成 case;全部在 legacy_oracle 的事务里,随回滚消失)。
  67. Mutations []string `json:"mutations,omitempty"`
  68. // ExpectTier 期望命中的兜底层级(路径证据用;空表示不校验)。
  69. ExpectTier string `json:"expectTier,omitempty"`
  70. // Expectation 人类可读的期望说明(dry-run 里给人看)。
  71. Expectation string `json:"expectation,omitempty"`
  72. // Note 备注(为什么造这条 case)。
  73. Note string `json:"note,omitempty"`
  74. }
  75. // Rule 是 SP 命中的课酬规则快照(金额单位为元,与旧库 float 列一致)。
  76. type Rule struct {
  77. DetailID int64 `json:"detailId"`
  78. PaymentID int64 `json:"paymentId"`
  79. Percentage string `json:"percentage"` // "25.0"(百分数)
  80. Min float64 `json:"min"`
  81. Max float64 `json:"max"`
  82. Reward float64 `json:"reward"`
  83. ClassType int64 `json:"classType"`
  84. GradeID int64 `json:"gradeId"`
  85. SubjectID int64 `json:"subjectId"`
  86. ExpMin float64 `json:"expMin"`
  87. ExpMax float64 `json:"expMax"`
  88. TeacherID int64 `json:"teacherId"`
  89. TeacherCert int64 `json:"teacherCert"`
  90. ClassifyID int64 `json:"classifyId"`
  91. }
  92. // PathEvidence 是"路径证据"(约束 17):数值相等但路径不同必须能被看出来。
  93. type PathEvidence struct {
  94. // Tier 命中的兜底层级:teacher-classify / teacher-grade-subject / cert-tier / generic-tier / degraded / none。
  95. Tier string `json:"tier"`
  96. // DetailID 命中的规则 id(0 = 未命中)。
  97. DetailID int64 `json:"detailId"`
  98. // Degraded oracle 侧是否走了 legacy 退化分支(SP 无命中)。
  99. Degraded bool `json:"degraded"`
  100. // IsSupplement 本次执行用的 isSupplement 取值。
  101. IsSupplement bool `json:"isSupplement"`
  102. // RewardType 结算方式(1 正常 / 2 固定课酬)。
  103. RewardType int8 `json:"rewardType"`
  104. // Recount 是否进入"重写逐行"分支(PHP 的 recountTeacherReward 语义)。
  105. Recount bool `json:"recount"`
  106. }
  107. // OracleResult 是 PHP 探针返回的 oracle 观测值。
  108. type OracleResult struct {
  109. OK bool `json:"ok"`
  110. Error string `json:"error"`
  111. ErrorDetail string `json:"errorDetail"`
  112. RecordID string `json:"recordId"`
  113. IsSupplement bool `json:"isSupplement"`
  114. // Inputs 是**造数之后**重读的权威输入(PHP 结算用的就是这一份)。
  115. Inputs OracleIn `json:"inputsEffective"`
  116. // InputsBefore 是造数之前的输入,仅用于溯源对比。
  117. InputsBefore OracleIn `json:"inputsBeforeMutation"`
  118. // StudentsEffective 是造数之后的打卡行(target 侧必须用这一份)。
  119. StudentsEffective []StudentRow `json:"studentsEffective"`
  120. // PerRowRewritten PHP 是否真的重写了逐行(false 时逐行比对无意义,会与历史值比较)。
  121. PerRowRewritten bool `json:"perRowRewritten"`
  122. Quote *RuleRaw `json:"quote"`
  123. Clock ClockInfo `json:"clock"`
  124. Mutations []string `json:"mutations"`
  125. MutationErrs []struct {
  126. SQL string `json:"sql"`
  127. Error string `json:"error"`
  128. } `json:"mutationErrors"`
  129. Return *bool `json:"return"`
  130. Exception *OracleError `json:"exception"`
  131. After *RecordAfter `json:"after"`
  132. StudentsBefore []StudentRow `json:"studentsBefore"`
  133. StudentsAfter []StudentRow `json:"studentsAfter"`
  134. RollbackRestored bool `json:"rollbackRestored"`
  135. }
  136. // OracleIn 是 oracle 侧读到的输入。
  137. type OracleIn struct {
  138. SchoolID int64 `json:"schoolid"`
  139. CampusID int64 `json:"campusid"`
  140. TeacherID int64 `json:"teacherid"`
  141. CoursesID int64 `json:"coursesid"`
  142. ClassifyID int64 `json:"classifyid"`
  143. TeacherExp float64 `json:"teacherExp"`
  144. TeacherRewardType int8 `json:"teacherRewardType"`
  145. StudentNumber int `json:"studentNumber"`
  146. EligibleCount int `json:"eligibleCount"`
  147. IncomeSumCost3 float64 `json:"incomeSumCost3"`
  148. StatusBefore int `json:"statusBefore"`
  149. TeacherRewardBefore float64 `json:"teacherRewardBefore"`
  150. ActualStartTime string `json:"actualStartTime"`
  151. }
  152. // RuleRaw 是探针里 quote 的原始形状(金额为元)。
  153. type RuleRaw struct {
  154. DetailID float64 `json:"id"`
  155. PaymentID float64 `json:"course_paymentid"`
  156. Percentage float64 `json:"percentage"`
  157. Min float64 `json:"min_payment"`
  158. Max float64 `json:"max_payment"`
  159. Reward float64 `json:"reward"`
  160. ClassType float64 `json:"class_type"`
  161. GradeID float64 `json:"gradeid"`
  162. SubjectID float64 `json:"subjectid"`
  163. ClassifyID float64 `json:"classifyid"`
  164. TeacherCert float64 `json:"teacher_cert"`
  165. ExpMin float64 `json:"teacher_exp_min"`
  166. ExpMax float64 `json:"teacher_exp_max"`
  167. }
  168. // ClockInfo 是冻结时钟的实测证据(A22 用)。
  169. type ClockInfo struct {
  170. FrozenEpoch int64 `json:"frozenEpoch"`
  171. MySQLNow string `json:"now"`
  172. MySQLUnix string `json:"unixNow"`
  173. PHPTime int64 `json:"phpTime"`
  174. PHPDate string `json:"phpDate"`
  175. TimeZone string `json:"timeZone"`
  176. }
  177. // OracleError 是结算抛出的异常(A24 说明它必抛)。
  178. type OracleError struct {
  179. Class string `json:"class"`
  180. Message string `json:"message"`
  181. File string `json:"file"`
  182. Line int `json:"line"`
  183. }
  184. // RecordAfter 是"异常前已写入"的 record 观测值。
  185. type RecordAfter struct {
  186. Status int `json:"status"`
  187. Income float64 `json:"income"`
  188. TeacherReward float64 `json:"teacherReward"`
  189. StudentNumber int `json:"studentNumber"`
  190. ActualEndTime string `json:"actualEndTime"`
  191. }
  192. // StudentRow 是逐行打卡观测值。
  193. type StudentRow struct {
  194. StudentID int64 `json:"studentid"`
  195. Type int `json:"type"`
  196. Cost float64 `json:"cost"`
  197. TeacherReward float64 `json:"teacherReward"`
  198. }
  199. // TargetResult 是 Go 纯函数算出的 target 侧答案。
  200. type TargetResult struct {
  201. Income float64 `json:"income"`
  202. Reward float64 `json:"reward"`
  203. PerRow []float64 `json:"perRow"`
  204. RowStudents []int64 `json:"rowStudents"`
  205. Error string `json:"error,omitempty"`
  206. }
  207. // Step1Income 是三步归因的第一步结果。
  208. type Step1Income struct {
  209. Legacy float64 `json:"legacy"` // round(Σcost, 3)
  210. Target float64 `json:"target"` // Σ ROUND(cost, 2)
  211. Delta float64 `json:"delta"` // target - legacy
  212. EqualCents bool `json:"equalCents"` // 分位是否相等
  213. Explanation string `json:"explanation"`
  214. }
  215. // DiffRow 是报告里的一行差异(每条一行)。
  216. type DiffRow struct {
  217. CaseID string `json:"caseId"`
  218. Table string `json:"table"`
  219. PK string `json:"pk"`
  220. Column string `json:"column"`
  221. Legacy string `json:"legacy"`
  222. Target string `json:"target"`
  223. Delta string `json:"delta"`
  224. Attribution Attribution `json:"attribution"`
  225. }
  226. // CaseResult 是一条 case 的完整结论(含中间量与路径证据)。
  227. type CaseResult struct {
  228. CaseID string `json:"caseId"`
  229. Category string `json:"category"`
  230. Kind CaseKind `json:"kind"`
  231. RecordID string `json:"recordId"`
  232. IsSupplement bool `json:"isSupplement"`
  233. Attribution Attribution `json:"attribution"`
  234. Oracle *OracleResult `json:"oracle,omitempty"`
  235. Target *TargetResult `json:"target,omitempty"`
  236. Step1 *Step1Income `json:"step1,omitempty"`
  237. Rows []DiffRow `json:"rows,omitempty"`
  238. Note string `json:"note,omitempty"`
  239. }
  240. // Report 是整轮双跑的产物(JSON + Markdown 同源)。
  241. type Report struct {
  242. Stage string `json:"stage"`
  243. GeneratedAt string `json:"generatedAt"`
  244. CaseSet string `json:"caseSet"`
  245. DryRun bool `json:"dryRun"`
  246. FrozenEpoch int64 `json:"frozenEpoch"`
  247. FrozenAt string `json:"frozenAt"`
  248. Guard map[string]string `json:"guard"`
  249. Assertions map[string]string `json:"assertions"`
  250. CaseCounts map[string]int `json:"caseCounts"`
  251. Summary map[Attribution]int `json:"summary"`
  252. Cases []CaseResult `json:"cases"`
  253. DryRunCases []Case `json:"dryRunCases,omitempty"`
  254. Idempotency []IdempotencyCheck `json:"idempotency,omitempty"`
  255. MutationNotes []string `json:"mutationNotes,omitempty"`
  256. }
  257. // IdempotencyCheck 记录同一 case 连跑两次是否结论一致。
  258. type IdempotencyCheck struct {
  259. CaseID string `json:"caseId"`
  260. First Attribution `json:"first"`
  261. Second Attribution `json:"second"`
  262. Same bool `json:"same"`
  263. RowsEqual bool `json:"rowsEqual"`
  264. }