Quellcode durchsuchen

feat(ac15): RS-4 声明体(A21 扇出 per-side 期望)+ Divergence 最小支持

分步提交:本轮**只落声明**,不落种子 / 求值 / 单测(下一轮)。

- rs_bill_fanout.go:RS-4 声明体
  - 常量:k=2、signed 100.00/条、used 期初 40.00 / 期末累计 65.00
  - `rsFanoutExpected()` 由声明算**两侧各自**的 9 列:
    legacy previousGiveCost=280.00 / currentGiveCost=330.00 / currentUsedGiveCost=−50.00
    target 240.00 / 265.00 / −25.00(used 只计 1 次)
  - `Divergence`:分歧面限定 3 列 + 理由;其余 6 列按相等处理
  - Columns 照抄 RS-2 的 9 列映射(未改)
  - ⚠ **暂不注册**进 ResultSetCases:Divergence 求值未实现,注册即 FATAL
- resultset_compare.go:`Divergence` 类型 + `ResultSetInput.Divergence` 字段;
  `compareResultSet` 开头**显式 panic**:声明了分歧却无求值逻辑 ⇒ FATAL,
  拒绝静默按普通 case 比(那会得到一条必然红的假红)

已知约束(写进文件头,下一轮落地前需定形态):target 的
`t_courses_activity_signed.uk_user_activity(user_id,activity_id)` 唯一键使
"同一活动 k=2 条报名"**无法表达**(实测 1062),两侧同构种子在 target 做不到。

门禁:go build ./... | go vet ./internal/academic/dualrun/stage2/ |
go test ./internal/academic/dualrun/stage2/ -count=1 | resultsets 3 PASS / 0 FAIL(RS-4 未注册)。
Q vor 5 Stunden
Ursprung
Commit
baf185638b

+ 42 - 0
internal/academic/dualrun/stage2/resultset_compare.go

@@ -144,6 +144,34 @@ type TargetOnlyColumn struct {
 	Note   string
 }
 
+// Divergence 描述一条**已知有意分歧**:两侧本来就不该相等,且我们知道为什么。
+//
+// 它同时是**双向探针**:若 target 被改坏成与 legacy 一样(缺陷被"补回去")⇒ 两侧相等 ⇒ 判定行红;
+// 若分歧扩散到没声明的列 ⇒ 判定行红(分歧面被声明限定,这本身也是断言)。
+// 详见 `rs_bill_fanout.go`(A21 扇出)。
+type Divergence struct {
+	// Reason 是登记的分歧原因(写进判定行与 Diff 备注,**必填**)。
+	Reason string
+	// Columns 是**允许分歧**的列(dump 列名);其余被认领的列仍必须逐列相等。
+	Columns []string
+	// Legacy / Target 是各侧自己的期望值(列名 → 归一后的字符串)。
+	Legacy map[string]string
+	Target map[string]string
+}
+
+// inColumns 判断某列是否在"允许分歧"的集合里。
+func (d *Divergence) inColumns(col string) bool {
+	if d == nil {
+		return false
+	}
+	for _, c := range d.Columns {
+		if c == col {
+			return true
+		}
+	}
+	return false
+}
+
 // ResultSetInput 是一次结果集比对的全部输入。
 type ResultSetInput struct {
 	CaseName string
@@ -162,6 +190,12 @@ type ResultSetInput struct {
 	// 为什么要有第三腿:只有"两侧相等"时,**两侧同时错**会得到一条完全对称的绿。
 	// 期望值从声明算(不写死实现输出),改种子后期望自动跟着变,"算错了"才会红。
 	Expect map[string]string
+	// Divergence 非 nil 时,本 case 是**已知有意分歧**(两侧本来就不该相等)。
+	//
+	// ⚠ 求值逻辑**尚未落地**(下一轮)。在那之前,一旦有人把它接进来,
+	// `compareResultSet` 会**直接 FATAL**(见该函数开头)—— 不许静默忽略:
+	// 忽略它会让这类 case 退化成"按普通 case 比相等",而它本来就该不相等 ⇒ 一条必然红。
+	Divergence *Divergence
 	// PrecisionCheck=true 时,逐列断言 dump 侧金额列第 3 位小数为 0。
 	PrecisionCheck bool
 	// ExtraAssertions 是该 case 自己的领域断言(账单恒等式 / FLOAT 可表示性 / 命中层级…)。
@@ -230,6 +264,14 @@ func RawCellOf(rs ResultSet, row int, col string) (Cell, bool) {
 
 // compareResultSet 是结果集比对的唯一入口。
 func compareResultSet(in ResultSetInput) ([]Diff, []Assertion) {
+	// 声明了"已知有意分歧"却没有求值逻辑 ⇒ **必须 FATAL**,不能静默按普通 case 比。
+	//
+	// 理由与 `hasDispatch` 同族:这类 case 的两侧**本来就该不相等**,静默走普通比对
+	// 会得到一条永远红、且文案指向"实现错了"的假红 —— 那比漏测更贵(会训练人忽略红)。
+	if in.Divergence != nil {
+		panic(fmt.Sprintf("case %q 声明了 Divergence(%s),但骨架的**分歧求值尚未实现**:"+
+			"拒绝静默忽略(否则会退化成按普通 case 比相等,必然假红)", in.CaseName, in.Divergence.Reason))
+	}
 	var diffs []Diff
 	var asserts []Assertion
 

+ 151 - 0
internal/academic/dualrun/stage2/rs_bill_fanout.go

@@ -0,0 +1,151 @@
+package stage2
+
+// 本文件是 **A21 扇出**(RS-4)的**声明体**。
+//
+// 本轮只落"声明",不落种子 / 求值 / 单测 —— 分步提交,避免再出现"回合结束但零产出"。
+// ⚠ 因此 `rsFanoutCase()` **暂不注册**进 `ResultSetCases()`:它的 `Divergence` 求值还没实现,
+// 注册进去会让 `resultsets` 直接 FATAL(骨架已显式拒绝静默忽略,见 `compareResultSet` 开头)。
+//
+// # 分歧是什么
+//
+// `sp_user_expense_get_bill` 的赠送结余:
+//
+//	SELECT SUM(activity_money), SUM(activity_money_used)
+//	  FROM (SELECT singed.activity_money, usedLog.activity_money_used
+//	          FROM temp_courses_activity_signed singed
+//	          RIGHT JOIN (SELECT activityid, SUM(activity_money_used) activity_money_used
+//	                        FROM temp_courses_activity_used_log
+//	                       WHERE create_time < cutoff GROUP BY activityid) usedLog
+//	          ON singed.activityid = usedLog.activityid) t1;
+//
+// `usedLog` 已按活动聚合成**一行**,再与 signed 逐行 JOIN ⇒ 同一活动有 k 条 signed 时,
+// 这一行被复制 k 份,`SUM(activity_money_used)` 被计入 **k 次**。
+// Go(`giveBalanceBefore`)按 activity 去重,used 只计 **1 次** —— 设计 §3.8-A21 的
+// **DUMP-BUGFIX:刻意不复刻**。
+//
+// # 两条硬约束(下一轮落地时用)
+//
+//  1. **k 必须先被断言再被使用**:机制式 `used×(k−1)` 里的 k 来自声明。若种子静默只落 1 条,
+//     该式退化为 0 ⇒ 两侧相等 ⇒ 判定行红,而归因会误指"缺陷被补回去了"。
+//  2. ⚠ **target 侧当前 schema 无法表达该状态**:`t_courses_activity_signed` 上有唯一键
+//     `uk_user_activity(user_id, activity_id)`(实测插入第二条报 1062)。所以"两侧都种 k=2"
+//     在本库**做不到**;落地形态要与 team-lead 确认是"legacy-only 登记 + target 唯一键拒绝断言"
+//     还是别的。本文件的 per-side 期望按"target 能表达"的假设写,仅为把声明先固定下来。
+
+// 本 case 用独立的活动 id(与 RS-1/RS-2 的 9000xx 不重叠,报告里一眼能分开)。
+const rsFanoutActivityID int64 = 900011
+
+// 扇出条数 k。**它是声明**,不是"从库里读出来直接用"——库里读到的值只用于断言它。
+const rsFanoutK = 2
+
+// 种子的单一声明(金额单位:分)。
+const (
+	rsFanoutSignedMoneyCents int64 = 10000 // 每条 signed 的 activity_money = 100.00
+	rsFanoutUsedPrevCents    int64 = 4000  // 期初前发生的 used = 40.00
+	rsFanoutUsedEndCents     int64 = 6500  // 期末前累计 used = 40.00 + 25.00 = 65.00
+)
+
+// rsFanoutCommonCols 是"仍必须相等"的 6 列(分歧面之外)。
+func rsFanoutCommonCols() []string {
+	return []string{
+		"previousBalance", "currentTotalIncome", "currentTotalActivityMoney",
+		"currentTotalRecommendedAmount", "currentSpend", "currentBalance",
+	}
+}
+
+// rsFanoutDivergentCols 是"允许且要求不相等"的 3 列。
+func rsFanoutDivergentCols() []string {
+	return []string{"previousGiveCost", "currentGiveCost", "currentUsedGiveCost"}
+}
+
+// rsFanoutExpected 由声明算出**两侧各自**应得的值(先算再跑,不跑出来再抄)。
+//
+//	legacy:k 条 signed × (activity_money + used) —— used 被计入 k 次
+//	target:Σ activity_money(k 条都算)+ used(只算 1 次)
+//
+// 其余 6 列两侧相同:期初 1000、窗口内只有一条 −100、无 type=100/107 流水。
+func rsFanoutExpected() (legacy, target map[string]int64) {
+	signedSum := rsFanoutSignedMoneyCents * rsFanoutK
+	usedPrev := rsFanoutUsedPrevCents
+	usedEnd := rsFanoutUsedEndCents
+
+	common := map[string]int64{
+		"previousBalance":               100000,
+		"currentTotalIncome":            0,
+		"currentTotalActivityMoney":     0,
+		"currentTotalRecommendedAmount": 0,
+		"currentSpend":                  -10000,
+		"currentBalance":                90000,
+	}
+
+	legacy = map[string]int64{
+		"previousBalance":               common["previousBalance"],
+		"previousGiveCost":              signedSum + usedPrev*rsFanoutK,
+		"currentTotalIncome":            common["currentTotalIncome"],
+		"currentTotalActivityMoney":     common["currentTotalActivityMoney"],
+		"currentTotalRecommendedAmount": common["currentTotalRecommendedAmount"],
+		"currentSpend":                  common["currentSpend"],
+		"currentUsedGiveCost":           (signedSum + usedPrev*rsFanoutK) - (signedSum + usedEnd*rsFanoutK),
+		"currentBalance":                common["currentBalance"],
+		"currentGiveCost":               signedSum + usedEnd*rsFanoutK,
+	}
+	target = map[string]int64{
+		"previousBalance":               common["previousBalance"],
+		"previousGiveCost":              signedSum + usedPrev,
+		"currentTotalIncome":            common["currentTotalIncome"],
+		"currentTotalActivityMoney":     common["currentTotalActivityMoney"],
+		"currentTotalRecommendedAmount": common["currentTotalRecommendedAmount"],
+		"currentSpend":                  common["currentSpend"],
+		"currentUsedGiveCost":           (signedSum + usedPrev) - (signedSum + usedEnd),
+		"currentBalance":                common["currentBalance"],
+		"currentGiveCost":               signedSum + usedEnd,
+	}
+	return legacy, target
+}
+
+// rsFanoutExpectedNorm 把一侧的期望渲染成 2 位小数字符串(比对口径)。
+func rsFanoutExpectedNorm(side map[string]int64) map[string]string {
+	out := make(map[string]string, len(side))
+	for k, v := range side {
+		out[k] = yuan(v)
+	}
+	return out
+}
+
+// rsFanoutCase 是 RS-4 的声明体。
+//
+// ⚠ **暂不注册**(见文件头):`Divergence` 求值未实现,注册即 FATAL。
+func rsFanoutCase() ResultSetCase {
+	lgExp, tgExp := rsFanoutExpected()
+	return ResultSetCase{
+		Name: "RS-4 账单·A21 扇出(同一活动 k 条报名 ⇒ used 重复计入)INTENTIONAL-STRICT",
+		Note: "两侧**本来就不该相等**:legacy 把 used 计入 k 次(dump 缺陷),target 去重只计 1 次(刻意不复刻)。" +
+			"绿的含义是「分歧已登记且双方符合各自声明」;两侧一旦相等就红(缺陷被补回 / target 被改坏)," +
+			"分歧扩散到声明外的列也红",
+		LegacyUserID:  rsUserID,
+		InstitutionID: rsInstID,
+		CampusID:      rsCampusID,
+		// LegacyCall / Seed* / CallTarget / Preconditions / ExtraAssertions 下一轮补。
+		Compare: func(lg ResultSet, tg TargetPayload) ResultSetInput {
+			common := make(map[string]string, len(rsFanoutCommonCols()))
+			for _, col := range rsFanoutCommonCols() {
+				common[col] = yuan(lgExp[col])
+			}
+			return ResultSetInput{
+				CaseName:   "RS-4",
+				Legacy:     lg,
+				ExpectRows: 1,
+				Target:     tg,
+				Columns:    rsBillColumns(), // 照抄 RS-2 的 9 列映射
+				Expect:     common,          // 其余 6 列按相等处理
+				Divergence: &Divergence{
+					Reason: "A21 扇出:legacy 对同一活动的 k 条 signed 把 used 额重复计入 k 次;" +
+						"target 按 activity 去重只计 1 次(dump 缺陷,刻意不复刻)",
+					Columns: rsFanoutDivergentCols(),
+					Legacy:  rsFanoutExpectedNorm(lgExp),
+					Target:  rsFanoutExpectedNorm(tgExp),
+				},
+			}
+		},
+	}
+}