target.go 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. package dualrun
  2. import (
  3. "fmt"
  4. "math"
  5. "sort"
  6. "yuansiwei/unified-backend/internal/academic/money"
  7. "yuansiwei/unified-backend/internal/academic/service"
  8. )
  9. // yuanToCents 元 → 分(四舍五入,负数对称)。
  10. func yuanToCents(v float64) money.Amount {
  11. return money.FromCents(centsOf(v))
  12. }
  13. func centsOf(v float64) int64 {
  14. if v < 0 {
  15. return -int64(math.Round(-v * 100))
  16. }
  17. return int64(math.Round(v * 100))
  18. }
  19. // centsToYuan 分 → 元(仅用于报告展示)。
  20. func centsToYuan(c int64) float64 {
  21. return float64(c) / 100
  22. }
  23. // ComputeTarget 用**纯函数**算出 target 侧答案。
  24. //
  25. // 关键设计(也是必须写进报告的不对称):
  26. // - target 侧**不连** yuansiwei_2026、不写库,只调 `service.computeRecordReward` 与 `distributeAmount`;
  27. // - 输入全部取自 oracle 侧同一份观测(规则、人数、结算方式);
  28. // - **唯一按迁移口径重算的是 income**:`Σ ROUND(cost,2)`(目标态 `DECIMAL(12,2)`),
  29. // 而 oracle 侧是 `round(Σ cost, 3)`。这个差异正是三步归因第 1 步要量化的东西。
  30. func ComputeTarget(obs *OracleResult) (*TargetResult, error) {
  31. if obs == nil || obs.Inputs.EligibleCount < 0 {
  32. return nil, fmt.Errorf("oracle 观测为空")
  33. }
  34. out := &TargetResult{}
  35. // ---- income:按迁移后口径逐行 ROUND 到分再求和 ----
  36. var sumCents int64
  37. for _, s := range obs.StudentsBefore {
  38. sumCents += centsOf(s.Cost)
  39. }
  40. out.Income = centsToYuan(sumCents)
  41. // ---- 规则:直接取 oracle 侧命中的同一条(隔离公式,不引入规则选择变量)----
  42. if obs.Quote == nil {
  43. // SP 无命中 ⇒ oracle 走退化分支,target 侧按"无方案"处理(3112 语义 → 0)
  44. out.Reward = 0
  45. out.PerRow = nil
  46. return out, nil
  47. }
  48. q := &service.PayrollQuote{
  49. Percentage: fmt.Sprintf("%.1f", obs.Quote.Percentage),
  50. Min: yuanToCents(obs.Quote.Min),
  51. Max: yuanToCents(obs.Quote.Max),
  52. Reward: yuanToCents(obs.Quote.Reward),
  53. }
  54. got, err := service.ComputeRecordRewardForDualRun(
  55. q, money.FromCents(sumCents), int32(obs.Inputs.StudentNumber), obs.Inputs.TeacherRewardType)
  56. if err != nil {
  57. out.Error = err.Error()
  58. return out, err
  59. }
  60. out.Reward = centsToYuan(got.Cents())
  61. // ---- 逐行分摊:只分摊给 type ∈ {1,2},除数 = 该集合大小 ----
  62. eligible := make([]StudentRow, 0, len(obs.StudentsBefore))
  63. for _, s := range obs.StudentsBefore {
  64. if s.Type == 1 || s.Type == 2 {
  65. eligible = append(eligible, s)
  66. }
  67. }
  68. sort.Slice(eligible, func(i, j int) bool { return eligible[i].StudentID < eligible[j].StudentID })
  69. if n := len(eligible); n > 0 {
  70. shares := service.DistributeRecordRewardForDualRun(got, n)
  71. for i := range eligible {
  72. out.RowStudents = append(out.RowStudents, eligible[i].StudentID)
  73. out.PerRow = append(out.PerRow, centsToYuan(shares[i].Cents()))
  74. }
  75. }
  76. return out, nil
  77. }
  78. // TargetIncomeLegacy 计算"按旧库 3 位精度"的 income(= oracle 侧的收入口径),用于第 1 步对比。
  79. func TargetIncomeLegacy(obs *OracleResult) float64 {
  80. if obs == nil {
  81. return 0
  82. }
  83. var sum float64
  84. for _, s := range obs.StudentsBefore {
  85. sum += s.Cost
  86. }
  87. return math.Round(sum*1000) / 1000
  88. }