|
@@ -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
|
|
|
|
|
+}
|