Przeglądaj źródła

feat(ac15): 补 I6「真父一致」—— 区间合法但 parent 指错祖先的盲区

## 为什么加这条

I1~I5 只检 `left/right/layer/root` 的**区间合法性**,检不出 `parent_userid` 指错祖先:
把 parent 指成**祖父**时,`p.left<n.left<n.right<p.right` 照样成立、root 也相同 ⇒ **I1~I3 全过**。
而 `update_quota` 的子树范围、以及上行 BFS(SE-1 `seedChain` 踩过的坑)**只认 `parent_userid`、不认区间**
⇒ 指错祖先 = 读错整棵子树。

## 判据(③ 才是重点)

每个非根节点 `n`,其 `parent_userid` 指向 `p` 必须:
① `p.root == n.root`;
② `p.left < n.left < n.right < p.right`;
③ **`p` 是"包含 `n` 的区间中最窄的那个"** —— 否则 parent 指向了祖父;
只写 ①② 的话,**指向祖父也能过**。

## 三个用例

- `TestNestingInvariants_I6CatchesGrandparentParent`:`r[1,6] → p[2,5] → c[3,4]`,`c.parent=r`(祖父)⇒ I6 抓到;
- `TestNestingInvariants_I6CatchesCrossTreeParent`:区间上包含、但属于另一棵树 ⇒ I6 抓到;
- `TestNestingInvariants_I6AcceptsCorrectParent`:两棵合法树零违规(防误报)。

## I5 定稿说明(team-lead 已确认我的更正)

**不加强成"每棵子树各自成立"** —— 子树版多抓的东西已被 I4 双向查重覆盖("漏一行"的症状就是重复值),
两条判据重叠不增加检出、只增加维护面。
并把 team-lead 原式的**反例**写进测试注释,防止将来有人"优化"回去:
`a1[1,4]/a2[2,3]` ⇒ `(4+2)/2 = 3 ≠ 2`(嵌套节点跨度互相包含,求和重复计数)。

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

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

go build OK | go vet OK | go test OK(7 条不变量单测)
```

**55 条仍 55 PASS,无新增覆盖**(纯函数 + 单测)。
未 push(现 3 个未推,等 DV-7 一起)。
Q 6 godzin temu
rodzic
commit
50ea225833

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

@@ -86,6 +86,46 @@ func CheckNestingInvariants(nodes []DivideNode) []NestingViolation {
 		}
 	}
 
+	// I6 真父一致:`parent_userid` 必须指向"包含自己的**最窄**区间"。
+	//
+	// 为什么单有这个不变量:I1~I5 只检区间本身的合法性,**检不出"parent 指错祖先"** ——
+	// 把 parent 指成祖父时,`p.left<n.left<n.right<p.right` 照样成立、root 也相同,
+	// I1~I3 全过。而 `update_quota` 的子树范围、以及上行 BFS(SE-1 的 `seedChain`)
+	// **都只认 `parent_userid`,不认区间** ⇒ 指错祖先 = 读错整棵子树。
+	//
+	// 判据:若存在另一个节点 `q`(≠ 父、≠ 自己)也包含 `n`,且 `q` 的区间**比父更窄**,
+	// 则父指错了 —— 真正的父应当是最窄的那个。这一条是 I6 的重点,
+	// 只查"父包含子 + 同 root"的话,指向祖父也能过。
+	for _, n := range nodes {
+		if n.ParentID == "" {
+			continue
+		}
+		p, ok := byID[n.ParentID]
+		if !ok {
+			continue // 已在上面记过 I0
+		}
+		if p.RootID != n.RootID {
+			out = append(out, NestingViolation{"I6", n.RootID,
+				fmt.Sprintf("节点 %s 的 parent=%s 属于另一棵树 root=%s", n.UserID, p.UserID, p.RootID)})
+			continue
+		}
+		pWidth := p.RightVal - p.LeftVal
+		for _, q := range nodes {
+			if q.UserID == p.UserID || q.UserID == n.UserID || q.RootID != n.RootID {
+				continue
+			}
+			// q 也包含 n?
+			if q.LeftVal < n.LeftVal && n.RightVal < q.RightVal {
+				if qWidth := q.RightVal - q.LeftVal; qWidth < pWidth {
+					out = append(out, NestingViolation{"I6", n.RootID,
+						fmt.Sprintf("节点 %s 的 parent 应为 %s[%d,%d](更窄),实际指向 %s[%d,%d]",
+							n.UserID, q.UserID, q.LeftVal, q.RightVal, p.UserID, p.LeftVal, p.RightVal)})
+					break
+				}
+			}
+		}
+	}
+
 	// I4 区间唯一:**按 (root, left) 与 (root, right) 双向查重**。
 	//
 	// 为什么必须双向:只查 `left` 会漏掉"两行 left 不同但 right 相同"这种错,

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

@@ -45,6 +45,11 @@ func TestNestingInvariants_ValidFixtureIsClean(t *testing.T) {
 // ⇒ 只要作用域丢了 root,I4 会一次报 100+ 条、I5 差 −130。**这个量级不可能是实现错**,
 // 一眼就能定位到"作用域"。这组数字比合成 fixture 更能说明问题,所以写在这里。
 //
+// ⚠ **不要"优化"回 `Σ(right−left+1)/2 == 节点数`**(team-lead 原式,已确认有误):
+// 反例是一棵完全合法的两节点树 `a1[1,4] / a2[2,3]` ⇒ `(4+2)/2 = 3 ≠ 2`。
+// 原因:嵌套节点的跨度**互相包含**,求和会重复计数;原式只在 n=1 时偶然成立。
+// 正确恒等式是"每个节点占 2 个槽位"⇒ 整棵树宽度 == 2n。见到有人改回原式,把这两个数摆给他算。
+//
 // 合成 fixture(两棵树同形)上的变异探针(本轮真跑过,改 `nestKey` 去掉 root 后):
 //
 //	I4 报 1 条:left_val=1 重复(a1 与 b1)
@@ -109,3 +114,42 @@ func TestNestingInvariants_I5DetectsMissingShift(t *testing.T) {
 		t.Fatalf("区间总量不守恒(Σ/2 ≠ 节点数)必须被 I5 抓到,实得 0 条:%v", vs)
 	}
 }
+
+// TestNestingInvariants_I6CatchesGrandparentParent 负向:parent 指成**祖父**。
+//
+// 形态:r[1,6] → p[2,5] → c[3,4],但 c.parent 写成 r(祖父)。
+// 区间三条件(I1 包含、I2 层级、I3 同 root)里,I1/I3 **都能过**;
+// 唯一能抓的是 I6 —— 因为 p 才是"包含 c 的最窄区间"。
+// 这一条正是"区间完全合法但 parent_userid 指错"的典型。
+func TestNestingInvariants_I6CatchesGrandparentParent(t *testing.T) {
+	nodes := []DivideNode{
+		{UserID: "r", RootID: "A", ParentID: "", LeftVal: 1, RightVal: 6, Layer: 1},
+		{UserID: "p", RootID: "A", ParentID: "r", LeftVal: 2, RightVal: 5, Layer: 2},
+		{UserID: "c", RootID: "A", ParentID: "r", LeftVal: 3, RightVal: 4, Layer: 3}, // ← 指向祖父
+	}
+	vs := CheckNestingInvariants(nodes)
+	if CountViolationsByInvariant(vs)["I6"] == 0 {
+		t.Fatalf("parent 指成祖父必须被 I6 抓到(区间合法性全过),实得:%v", vs)
+	}
+}
+
+// TestNestingInvariants_I6CatchesCrossTreeParent 负向:parent 指到**另一棵树**。
+func TestNestingInvariants_I6CatchesCrossTreeParent(t *testing.T) {
+	nodes := []DivideNode{
+		{UserID: "a_root", RootID: "A", ParentID: "", LeftVal: 1, RightVal: 6, Layer: 1},
+		{UserID: "b_root", RootID: "B", ParentID: "", LeftVal: 1, RightVal: 2, Layer: 1},
+		// 区间上 a_root 确实包含 c,但 c 属于 B 树 ⇒ root 不一致
+		{UserID: "c", RootID: "B", ParentID: "a_root", LeftVal: 3, RightVal: 4, Layer: 2},
+	}
+	vs := CheckNestingInvariants(nodes)
+	if CountViolationsByInvariant(vs)["I6"] == 0 {
+		t.Fatalf("parent 指向另一棵树必须被 I6 抓到,实得:%v", vs)
+	}
+}
+
+// TestNestingInvariants_I6AcceptsCorrectParent 正向:父子关系正确时 I6 不报。
+func TestNestingInvariants_I6AcceptsCorrectParent(t *testing.T) {
+	if vs := CheckNestingInvariants(twoRootFixture()); len(vs) != 0 {
+		t.Fatalf("两棵合法树的父子关系应零违规,实得 %d 条:%v", len(vs), vs)
+	}
+}