model.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 OracleIn `json:"inputs"`
  115. Quote *RuleRaw `json:"quote"`
  116. Clock ClockInfo `json:"clock"`
  117. Mutations []string `json:"mutations"`
  118. MutationErrs []struct {
  119. SQL string `json:"sql"`
  120. Error string `json:"error"`
  121. } `json:"mutationErrors"`
  122. Return *bool `json:"return"`
  123. Exception *OracleError `json:"exception"`
  124. After *RecordAfter `json:"after"`
  125. StudentsBefore []StudentRow `json:"studentsBefore"`
  126. StudentsAfter []StudentRow `json:"studentsAfter"`
  127. RollbackRestored bool `json:"rollbackRestored"`
  128. }
  129. // OracleIn 是 oracle 侧读到的输入。
  130. type OracleIn struct {
  131. SchoolID int64 `json:"schoolid"`
  132. CampusID int64 `json:"campusid"`
  133. TeacherID int64 `json:"teacherid"`
  134. CoursesID int64 `json:"coursesid"`
  135. ClassifyID int64 `json:"classifyid"`
  136. TeacherExp float64 `json:"teacherExp"`
  137. TeacherRewardType int8 `json:"teacherRewardType"`
  138. StudentNumber int `json:"studentNumber"`
  139. EligibleCount int `json:"eligibleCount"`
  140. IncomeSumCost3 float64 `json:"incomeSumCost3"`
  141. StatusBefore int `json:"statusBefore"`
  142. TeacherRewardBefore float64 `json:"teacherRewardBefore"`
  143. ActualStartTime string `json:"actualStartTime"`
  144. }
  145. // RuleRaw 是探针里 quote 的原始形状(金额为元)。
  146. type RuleRaw struct {
  147. DetailID float64 `json:"id"`
  148. PaymentID float64 `json:"course_paymentid"`
  149. Percentage float64 `json:"percentage"`
  150. Min float64 `json:"min_payment"`
  151. Max float64 `json:"max_payment"`
  152. Reward float64 `json:"reward"`
  153. ClassType float64 `json:"class_type"`
  154. GradeID float64 `json:"gradeid"`
  155. SubjectID float64 `json:"subjectid"`
  156. ClassifyID float64 `json:"classifyid"`
  157. TeacherCert float64 `json:"teacher_cert"`
  158. ExpMin float64 `json:"teacher_exp_min"`
  159. ExpMax float64 `json:"teacher_exp_max"`
  160. }
  161. // ClockInfo 是冻结时钟的实测证据(A22 用)。
  162. type ClockInfo struct {
  163. FrozenEpoch int64 `json:"frozenEpoch"`
  164. MySQLNow string `json:"now"`
  165. MySQLUnix string `json:"unixNow"`
  166. PHPTime int64 `json:"phpTime"`
  167. PHPDate string `json:"phpDate"`
  168. TimeZone string `json:"timeZone"`
  169. }
  170. // OracleError 是结算抛出的异常(A24 说明它必抛)。
  171. type OracleError struct {
  172. Class string `json:"class"`
  173. Message string `json:"message"`
  174. File string `json:"file"`
  175. Line int `json:"line"`
  176. }
  177. // RecordAfter 是"异常前已写入"的 record 观测值。
  178. type RecordAfter struct {
  179. Status int `json:"status"`
  180. Income float64 `json:"income"`
  181. TeacherReward float64 `json:"teacherReward"`
  182. StudentNumber int `json:"studentNumber"`
  183. ActualEndTime string `json:"actualEndTime"`
  184. }
  185. // StudentRow 是逐行打卡观测值。
  186. type StudentRow struct {
  187. StudentID int64 `json:"studentid"`
  188. Type int `json:"type"`
  189. Cost float64 `json:"cost"`
  190. TeacherReward float64 `json:"teacherReward"`
  191. }
  192. // TargetResult 是 Go 纯函数算出的 target 侧答案。
  193. type TargetResult struct {
  194. Income float64 `json:"income"`
  195. Reward float64 `json:"reward"`
  196. PerRow []float64 `json:"perRow"`
  197. RowStudents []int64 `json:"rowStudents"`
  198. Error string `json:"error,omitempty"`
  199. }
  200. // Step1Income 是三步归因的第一步结果。
  201. type Step1Income struct {
  202. Legacy float64 `json:"legacy"` // round(Σcost, 3)
  203. Target float64 `json:"target"` // Σ ROUND(cost, 2)
  204. Delta float64 `json:"delta"` // target - legacy
  205. EqualCents bool `json:"equalCents"` // 分位是否相等
  206. Explanation string `json:"explanation"`
  207. }
  208. // DiffRow 是报告里的一行差异(每条一行)。
  209. type DiffRow struct {
  210. CaseID string `json:"caseId"`
  211. Table string `json:"table"`
  212. PK string `json:"pk"`
  213. Column string `json:"column"`
  214. Legacy string `json:"legacy"`
  215. Target string `json:"target"`
  216. Delta string `json:"delta"`
  217. Attribution Attribution `json:"attribution"`
  218. }
  219. // CaseResult 是一条 case 的完整结论(含中间量与路径证据)。
  220. type CaseResult struct {
  221. CaseID string `json:"caseId"`
  222. Category string `json:"category"`
  223. Kind CaseKind `json:"kind"`
  224. RecordID string `json:"recordId"`
  225. IsSupplement bool `json:"isSupplement"`
  226. Attribution Attribution `json:"attribution"`
  227. Oracle *OracleResult `json:"oracle,omitempty"`
  228. Target *TargetResult `json:"target,omitempty"`
  229. Step1 *Step1Income `json:"step1,omitempty"`
  230. Rows []DiffRow `json:"rows,omitempty"`
  231. Note string `json:"note,omitempty"`
  232. }
  233. // Report 是整轮双跑的产物(JSON + Markdown 同源)。
  234. type Report struct {
  235. Stage string `json:"stage"`
  236. GeneratedAt string `json:"generatedAt"`
  237. CaseSet string `json:"caseSet"`
  238. DryRun bool `json:"dryRun"`
  239. FrozenEpoch int64 `json:"frozenEpoch"`
  240. FrozenAt string `json:"frozenAt"`
  241. Guard map[string]string `json:"guard"`
  242. Assertions map[string]string `json:"assertions"`
  243. CaseCounts map[string]int `json:"caseCounts"`
  244. Summary map[Attribution]int `json:"summary"`
  245. Cases []CaseResult `json:"cases"`
  246. DryRunCases []Case `json:"dryRunCases,omitempty"`
  247. Idempotency []IdempotencyCheck `json:"idempotency,omitempty"`
  248. MutationNotes []string `json:"mutationNotes,omitempty"`
  249. }
  250. // IdempotencyCheck 记录同一 case 连跑两次是否结论一致。
  251. type IdempotencyCheck struct {
  252. CaseID string `json:"caseId"`
  253. First Attribution `json:"first"`
  254. Second Attribution `json:"second"`
  255. Same bool `json:"same"`
  256. RowsEqual bool `json:"rowsEqual"`
  257. }