model.go 11 KB

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