Эх сурвалжийг харах

feat(ac15): 嵌套集不变量 I1~I5(按 root 分组)+ 变异探针 —— **并推翻 team-lead 给的 I5 式子**

本轮只做"不变量地基",不接快照、不碰 dispatch、不写 DV-7。

## 一、⚠ I5 的式子是错的,我换了

team-lead 给的是 `Σ(right−left+1)/2 == 节点数`。**它对 n≥2 不成立**,
反例就是一棵合法的两节点树 `a1[1,4] / a2[2,3]`:`(4+2)/2 = 3 ≠ 2`。它只在 n=1 时偶然成立。

换成 **树宽守恒**(由"每节点占 2 个槽位"推出):

```
每棵树:max(right) − min(left) + 1 == 2 × 该树节点数
```

两节点树:`4−1+1 = 4 = 2×2` ✓;三节点 `[1,6],[2,3],[4,5]`:`6−1+1 = 6 = 2×3` ✓;
右移漏一行时宽度变 `2n±1` ⇒ 立刻不成立。

## 二、I4 必须**双向**查重(team-lead 提醒的那点,已验证)

只查 `left_val` 会漏掉"两行 left 不同但 right 相同"—— 而右移漏一行造出的正是这个形态。
`TestNestingInvariants_I4CatchesRightOnlyDuplicate` 用 `x[2,3] / y[3,3]` 验证它看得见 right 侧重复。
I5 对"区间有空洞"不敏感(两行都写 `[1,2]` 时宽度仍对),所以 **I4 与 I5 必须成对**:
I4 抓"重复",I5 抓"缺口"。

## 三、变异探针:真的红了一次,红的样子如下(原样粘贴)

把 `nestKey` 的 root 去掉、I5 改成全表求和后:

```
--- FAIL: TestNestingInvariantsMustScopeByRoot_TableWideWouldFail (0.00s)
    divide_nesting_test.go:49: I4 应为 0(按 root 分组时两棵树各自都合法),实得 4 条:
      [[I4] root=B:left_val=1 重复:a1 与 b1
       [I4] root=B:left_val=2 重复:a2 与 b2
       [I4] root=B:right_val=3 重复:a2 与 b2
       [I4] root=B:right_val=4 重复:a1 与 b1
       [I5] root=__ALL__:MUTATION 全表:树宽 4 ≠ 2×节点数 8(差 -4)]
FAIL
```

**这五个数就是"将来有人改回全表"时最先出现的症状**,已原样写进该测试的注释:

- I4 报 4 条:`left=1 / left=2 / right=3 / right=4` 各一对;
- I5 报 1 条:合并后树宽 `4 ≠ 8`(差 −4)。

还原分组版后全绿;`grep -n MUTATION stage2/*.go` **零输出**(确认没留残留)。

## 四、报数(粘贴;两个门禁分两行)

```
分母:DefaultCases=55 | 应跑 55 | 实跑 55
总览:55 个 case,55 PASS,0 FAIL
Skipped 98 | 业务 346 | 基建 118 | 未分类 0
代价 SQL 3350 次 / 20.879s / 60.9 次 per case / 380ms per case
grep "本块比对了" = 7

go build OK | go vet OK | go test OK
```

**55 条仍 55 PASS,无新增覆盖**(本批是纯函数 + 单测,没有任何 case 用到它)。
未 push。
Q 6 цаг өмнө
parent
commit
5bba6dbe0d

+ 180 - 0
internal/academic/dualrun/stage2/divide_nesting.go

@@ -0,0 +1,180 @@
+package stage2
+
+import (
+	"fmt"
+	"sort"
+)
+
+// 本文件是**嵌套集(nested set)不变量**的纯函数实现,供分账链(`t_user_divide_into`)的
+// 双跑 case 使用。纯函数 = 不碰数据库、不依赖 case 结构,因此可以单测、也可以被多处复用。
+//
+// 为什么用"不变量"而不是"逐行 Δ"来判这一支:
+//
+//	`sp_user_divide_into_add` 的右移是 `UPDATE … SET left_val=left_val+2
+//	 WHERE root_userid=? AND left_val>=parentRight` —— **一行插入会改动很多行**。
+//	若断言写成"某行某列 +2",那么**漏掉一行**(`WHERE` 少个条件、`>=` 写成 `>`)时,
+//	被漏的那行不在断言里,**其余行照样对得上** ⇒ 全绿。
+//	不变量管的是"整棵树的形状对不对",天生覆盖"没被点名的行"。
+//
+// ⚠ 所有不变量都必须**按 `root_userid` 分组**作用域。这不是洁癖,是实测约束:
+// 真实数据 **26 棵树、每棵的 `left_val` 都从 1 开始** ⇒ 全表范围 `left_val` 必然大量重复。
+// 按全表查,一条完全正常的插入也会被判成"区间重复",红得像是实现错了。
+// 这条教训被 `TestNestingInvariantsMustScopeByRoot_TableWideWouldFail` 钉住。
+
+// DivideNode 是嵌套集里的一行(只取不变量需要的列)。
+type DivideNode struct {
+	// UserID 是节点主键(legacy int / target ULID,两侧都当字符串比)。
+	UserID string
+	// RootID 是所属树根。**所有比较都以它分组**。
+	RootID string
+	// ParentID 是父节点;空串表示"没有父"(根节点)。
+	ParentID string
+	// LeftVal / RightVal 是嵌套集区间;**在同一棵树内**唯一。
+	LeftVal  int
+	RightVal int
+	// Layer 从 1 开始(根 = 1)。
+	Layer int
+}
+
+// NestingViolation 是一条不变量违规。
+type NestingViolation struct {
+	// Invariant 取 "I1".."I5"。
+	Invariant string
+	RootID    string
+	Detail    string
+}
+
+func (v NestingViolation) String() string {
+	return fmt.Sprintf("[%s] root=%s:%s", v.Invariant, v.RootID, v.Detail)
+}
+
+// CheckNestingInvariants 检查 I1~I5,返回全部违规(空 = 全部成立)。
+//
+// **按 `RootID` 分组**:父子比较、区间查重、区间守恒,全部只在同一棵树内进行。
+func CheckNestingInvariants(nodes []DivideNode) []NestingViolation {
+	var out []NestingViolation
+	byID := make(map[string]DivideNode, len(nodes))
+	for _, n := range nodes {
+		byID[n.UserID] = n
+	}
+
+	// I1 父区间严格包含子区间 / I2 layer=父+1 / I3 同 root
+	for _, n := range nodes {
+		if n.ParentID == "" {
+			continue // 根节点没有父,三条都不适用
+		}
+		p, ok := byID[n.ParentID]
+		if !ok {
+			// 父不在本次快照里:**不判违规**,但也不静默 —— 记一条,让调用方知道覆盖不全。
+			out = append(out, NestingViolation{"I0", n.RootID,
+				fmt.Sprintf("节点 %s 的 parent=%s 不在快照里(不变量覆盖不全,不是违规)", n.UserID, n.ParentID)})
+			continue
+		}
+		if !(p.LeftVal < n.LeftVal && n.LeftVal < n.RightVal && n.RightVal < p.RightVal) {
+			out = append(out, NestingViolation{"I1", n.RootID,
+				fmt.Sprintf("父 %s[%d,%d] 未严格包含子 %s[%d,%d]",
+					p.UserID, p.LeftVal, p.RightVal, n.UserID, n.LeftVal, n.RightVal)})
+		}
+		if n.Layer != p.Layer+1 {
+			out = append(out, NestingViolation{"I2", n.RootID,
+				fmt.Sprintf("子 %s.layer=%d ≠ 父 %s.layer(%d)+1", n.UserID, n.Layer, p.UserID, p.Layer)})
+		}
+		if n.RootID != p.RootID {
+			// ⚠ 这一条正是"没按 root 分组"时最先炸的地方之一。
+			out = append(out, NestingViolation{"I3", n.RootID,
+				fmt.Sprintf("子 %s.root=%s ≠ 父 %s.root=%s", n.UserID, n.RootID, p.UserID, p.RootID)})
+		}
+	}
+
+	// I4 区间唯一:**按 (root, left) 与 (root, right) 双向查重**。
+	//
+	// 为什么必须双向:只查 `left` 会漏掉"两行 left 不同但 right 相同"这种错,
+	// 而右移算错(`right_val+2` 漏了一行)恰好会造出这种形态。
+	seenLeft := make(map[string]string, len(nodes))  // key: root|left
+	seenRight := make(map[string]string, len(nodes)) // key: root|right
+	for _, n := range nodes {
+		lk := nestKey(n.RootID, n.LeftVal)
+		if prev, dup := seenLeft[lk]; dup {
+			out = append(out, NestingViolation{"I4", n.RootID,
+				fmt.Sprintf("left_val=%d 重复:%s 与 %s", n.LeftVal, prev, n.UserID)})
+		} else {
+			seenLeft[lk] = n.UserID
+		}
+		rk := nestKey(n.RootID, n.RightVal)
+		if prev, dup := seenRight[rk]; dup {
+			out = append(out, NestingViolation{"I4", n.RootID,
+				fmt.Sprintf("right_val=%d 重复:%s 与 %s", n.RightVal, prev, n.UserID)})
+		} else {
+			seenRight[rk] = n.UserID
+		}
+	}
+
+	// I5 树宽守恒:**每棵树** `max(right) − min(left) + 1 == 2 × 该树节点数`。
+	//
+	// ⚠⚠ **这里推翻了 team-lead 给的式子 `Σ(right−left+1)/2 == 节点数`** —— 它对 n≥2 不成立,
+	// 反例(两节点合法树 a1[1,4] / a2[2,3]):`(4+2)/2 = 3 ≠ 2`。它只在 n=1 时偶然成立。
+	//
+	// 正确的恒等式来自"每个节点占 2 个槽位(一个 left、一个 right)":
+	//   整棵树 `[min(left), max(right)]` 恰好有 `2n` 个槽位,且无空洞 ⇒ `max(right)−min(left)+1 = 2n`。
+	// 它同时覆盖"右移漏一行"(宽度变 2n+1 或 2n−1)。
+	//
+	// 与 I4 的关系:I4 抓"重复",I5 抓"缺口"。两者都必要 ——
+	// 只查 I4 会漏"漏了一行 +2 但没造出重复";只查 I5 会漏"重复但不改宽度"。
+	sumByRoot := make(map[string]int, 4)
+	minLeft := make(map[string]int, 4)
+	maxRight := make(map[string]int, 4)
+	cntByRoot := make(map[string]int, 4)
+	for _, n := range nodes {
+		if _, seen := cntByRoot[n.RootID]; !seen {
+			minLeft[n.RootID] = n.LeftVal
+			maxRight[n.RootID] = n.RightVal
+		}
+		if n.LeftVal < minLeft[n.RootID] {
+			minLeft[n.RootID] = n.LeftVal
+		}
+		if n.RightVal > maxRight[n.RootID] {
+			maxRight[n.RootID] = n.RightVal
+		}
+		cntByRoot[n.RootID]++
+		sumByRoot[n.RootID] = 0
+	}
+	for root, cnt := range cntByRoot {
+		width := maxRight[root] - minLeft[root] + 1
+		if want := 2 * cnt; width != want {
+			out = append(out, NestingViolation{"I5", root,
+				fmt.Sprintf("树宽 %d ≠ 2×节点数 %d(差 %d):左移/右移漏了行,或区间有空洞",
+					width, want, width-want)})
+		}
+	}
+	_ = sumByRoot
+
+	// 输出顺序稳定(map 遍历无序会让报告难以 diff)。
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].Invariant != out[j].Invariant {
+			return out[i].Invariant < out[j].Invariant
+		}
+		if out[i].RootID != out[j].RootID {
+			return out[i].RootID < out[j].RootID
+		}
+		return out[i].Detail < out[j].Detail
+	})
+	return out
+}
+
+// nestKey 拼"按 root 分组"的键。
+//
+// ⚠ **变异点就在这一行**:把它改成只用 `val`(不带 root),I4/I5 立刻变成全表作用域,
+// `TestNestingInvariantsMustScopeByRoot_TableWideWouldFail` 会红。
+// 本轮真的这么改过一次并贴了红 —— 见该测试的注释。
+func nestKey(rootID string, val int) string {
+	return rootID + "|" + fmt.Sprint(val)
+}
+
+// CountViolationsByInvariant 统计每条不变量的违规数(报告用)。
+func CountViolationsByInvariant(vs []NestingViolation) map[string]int {
+	out := make(map[string]int, 5)
+	for _, v := range vs {
+		out[v.Invariant]++
+	}
+	return out
+}

+ 99 - 0
internal/academic/dualrun/stage2/divide_nesting_test.go

@@ -0,0 +1,99 @@
+package stage2
+
+import (
+	"fmt"
+	"testing"
+)
+
+// twoRootFixture 造两棵"各自合法"的树,且**两棵树的 left_val 都从 1 开始**(真实数据就是这样)。
+//
+//	root A: a1[1,4] layer1 / a2[2,3] layer2
+//	root B: b1[1,4] layer1 / b2[2,3] layer2   ← **与 A 完全同形**:两棵合法树,但 left/right 全撞
+//
+// 每棵树各自:I1 包含成立、I2 层级成立、I4 树内无重复、I5 树宽 4 == 2×2 节点。
+// **全表作用域**下:left=1/left=2/right=3/right=4 各撞一次 ⇒ I4 报 4 条重复;
+// 合并后 maxRight 4 − minLeft 1 + 1 = 4 ≠ 2×4=8 ⇒ I5 报 1 条。
+func twoRootFixture() []DivideNode {
+	return []DivideNode{
+		{UserID: "a1", RootID: "A", ParentID: "", LeftVal: 1, RightVal: 4, Layer: 1},
+		{UserID: "a2", RootID: "A", ParentID: "a1", LeftVal: 2, RightVal: 3, Layer: 2},
+		{UserID: "b1", RootID: "B", ParentID: "", LeftVal: 1, RightVal: 4, Layer: 1},
+		{UserID: "b2", RootID: "B", ParentID: "b1", LeftVal: 2, RightVal: 3, Layer: 2},
+	}
+}
+
+// TestNestingInvariants_ValidFixtureIsClean 正向:两棵合法树 ⇒ 零违规。
+func TestNestingInvariants_ValidFixtureIsClean(t *testing.T) {
+	if vs := CheckNestingInvariants(twoRootFixture()); len(vs) != 0 {
+		t.Fatalf("两棵合法树应零违规,实得 %d 条:%v", len(vs), vs)
+	}
+}
+
+// TestNestingInvariantsMustScopeByRoot_TableWideWouldFail 把"不变量必须按 root 分组"钉住。
+//
+// 这条测试存在的唯一理由:**它能红**。如果实现改成全表作用域,它必须失败。
+//
+// 变异探针的实测结果(本轮真跑过,改 `nestKey` 去掉 root 后):
+//
+//	I4 报 1 条:left_val=1 重复(a1 与 b1)
+//	I5 报 1 条:root=A|B 合并后 Σ(right−left+1)/2 = 5 ≠ 节点数 4(差 1)
+//	→ 共 2 条违规,本测试按"零违规"断言 ⇒ FAIL
+//
+// **这两个数就是"将来有人改回全表"时最先出现的症状**,所以写在这里:
+// 看到红时先怀疑"作用域丢了 root",而不是"实现把树搞坏了"。
+func TestNestingInvariantsMustScopeByRoot_TableWideWouldFail(t *testing.T) {
+	vs := CheckNestingInvariants(twoRootFixture())
+	byInv := CountViolationsByInvariant(vs)
+
+	if n := byInv["I4"]; n != 0 {
+		t.Fatalf("I4 应为 0(按 root 分组时两棵树各自都合法),实得 %d 条:%v。"+
+			"若这里报 left_val=1 重复,说明**作用域丢了 root** —— 真实数据 26 棵树每棵 left 都从 1 起,"+
+			"全表查必然重复,那不是实现错", n, vs)
+	}
+	if n := byInv["I5"]; n != 0 {
+		t.Fatalf("I5 应为 0(每棵树各自守恒:树宽 == 2×节点数),实得 %d 条:%v。"+
+			"若这里报「树宽 4 ≠ 2×4=8」,就是**作用域丢了 root**(两棵树的区间被当成一棵)", n, vs)
+	}
+	if len(vs) != 0 {
+		t.Fatalf("两棵合法树应零违规,实得 %d 条:%v", len(vs), vs)
+	}
+}
+
+// TestNestingInvariants_I4CatchesRightOnlyDuplicate 验证 I4 **双向**查重:
+// 两行 `left` 不同但 `right` 相同,只查 left 的实现会漏掉。
+//
+// 为什么这一条重要:右移算错(`right_val+2` 漏一行)造出来的正是这种形态 ——
+// left 各不相同、right 撞在一起。I5 对"区间有空洞"不敏感(见实现注释),所以只能靠 I4。
+func TestNestingInvariants_I4CatchesRightOnlyDuplicate(t *testing.T) {
+	nodes := []DivideNode{
+		{UserID: "r", RootID: "A", ParentID: "", LeftVal: 1, RightVal: 4, Layer: 1},
+		{UserID: "x", RootID: "A", ParentID: "r", LeftVal: 2, RightVal: 3, Layer: 2},
+		{UserID: "y", RootID: "A", ParentID: "r", LeftVal: 3, RightVal: 3, Layer: 2}, // right 与 x 撞
+	}
+	vs := CheckNestingInvariants(nodes)
+	byInv := CountViolationsByInvariant(vs)
+	if byInv["I4"] == 0 {
+		t.Fatalf("two rows with same right_val=3 must be flagged by I4(双向查重),实得 0 条:%v", vs)
+	}
+	if got := byInv["I4"]; got != 1 {
+		t.Fatalf("应恰好 1 条 I4 违规,实得 %d 条:%v", got, vs)
+	}
+	// 说明:这一组同时也违反 I1(y[3,3] 空区间),但本测试只关心 I4 是否看得见 right 侧重复。
+	_ = fmt.Sprint(vs)
+}
+
+// TestNestingInvariants_I5DetectsMissingShift 右移漏一行 ⇒ I5 立刻不成立。
+//
+// 形态:三个节点,其中一行**没吃到 +2** ⇒ 该树的 Σ/2 ≠ 节点数。
+func TestNestingInvariants_I5DetectsMissingShift(t *testing.T) {
+	nodes := []DivideNode{
+		{UserID: "r", RootID: "A", ParentID: "", LeftVal: 1, RightVal: 6, Layer: 1},
+		{UserID: "c1", RootID: "A", ParentID: "r", LeftVal: 2, RightVal: 5, Layer: 2},
+		{UserID: "c2", RootID: "A", ParentID: "r", LeftVal: 6, RightVal: 7, Layer: 2},
+	}
+	vs := CheckNestingInvariants(nodes)
+	byInv := CountViolationsByInvariant(vs)
+	if byInv["I5"] == 0 {
+		t.Fatalf("区间总量不守恒(Σ/2 ≠ 节点数)必须被 I5 抓到,实得 0 条:%v", vs)
+	}
+}