code wiki / _hdl_build / nx_treediverge.nx
nx_treediverge.nx source
↩ module page · 317 lines · 15978 B
1// nx_treediverge.nx -- THE MISSING CROSS-TREE DIVERGENCE DETECTOR (debt 1785438829, sev-8).
2//
3// THE GAP IT CLOSES, in that debt's own words: "nx_dup_source_check finds dups WITHIN buildroot but
4// there is NO laptop-vs-NAS divergence detector -- I did it BY HAND four times today. BUILD THAT: a
5// cross-tree census emitting per-file laptop_bytes / nas_bytes / delta / direction, so divergence is a
6// REPORT rather than an archaeology exercise; size alone would have caught all four of today's instances."
7//
8// WHY IT MATTERS (not tidiness): /api/build compiles from buildroot, so a fix living only in the laptop
9// SSOT NEVER REACHES A RUNNING BINARY, and a fix living only in buildroot is DESTROYED by the next sync
10// from the SSOT. The divergence is BIDIRECTIONAL -- neither tree is a superset -- so a one-way sync in
11// EITHER direction silently loses work. You cannot merge safely without first knowing the direction
12// per file. That is exactly what this emits.
13//
14// COMPOSES, DOES NOT DUPLICATE: nx_treediff already emits one tree's manifest as "<bytes> <relpath>"
15// per .nx file and says in its own usage that it is "for cross-tree comparison" -- it just never
16// performed the comparison. This is the missing half. Run nx_treediff on each tree, then:
17// nx_treediverge <manifestA> <manifestB> [labelA] [labelB]
18//
19// VERDICTS, per file: A-ONLY / B-ONLY / A-AHEAD / B-AHEAD / SAME. "AHEAD" is BY BYTES, which is the
20// signal that caught all four historical instances; it is a FLOOR, not proof of content (equal size can
21// still differ -- see the same-size-rewrite caveat on expect=<size> CAS). Declared, never implied.
22//
23// NON-VACUITY: an empty or unreadable manifest REFUSES (exit 3) instead of reporting "0 divergent",
24// because a comparator that reads nothing and prints GREEN is the self-ceiling defect this ecosystem
25// has banned repeatedly. A zero-divergence verdict is only emitted when BOTH sides actually had rows.
26import "nx_syscalls.nx"
27const TV_MAGIC_5381: i64 = 5381
28
29const TV_HBUCKETS: i64 = 65536 // hash buckets; power of two so the mask is an AND
30const TV_MAXENT: i64 = 200000 // per-tree entry ceiling (corpus is ~17.7k .nx today, 11x headroom)
31const TV_BUFCAP: i64 = 16777216 // 16 MiB per manifest (17.7k rows x ~80B ~= 1.4 MiB, 11x headroom)
32
33static tv_keyp: *i64 // B-side: pointer to each relpath (NUL-terminated in place)
34static tv_size: *i64 // B-side: bytes for that relpath
35static tv_next: *i64 // B-side: hash chain
36static tv_head: *i64 // B-side: bucket heads (-1 = empty)
37static tv_seen: *i64 // B-side: 1 once matched by an A row (to find B-ONLY)
38static tv_nent: *i64
39// COUNTS-ONLY MODE. Set by the `counts` verb; tv_emit becomes a no-op so the ENVELOPE survives.
40// MEASURED 2026-08-16: the laptop-vs-nas comparison emits 191,459 bytes, the transport caps a captured
41// read at 163,840, and this organ prints thousands of DETAIL rows BEFORE its counts -- so the summary is
42// the first casualty of its own verbosity, on exactly the comparison big enough to need one.
43// ★AN ORGAN THAT PRINTS ITS EVIDENCE BEFORE ITS VERDICT LOSES THE VERDICT FIRST WHEN TRUNCATED.
44static tv_quiet: i64
45
46func tv_puts(s: *u8) -> i64 {
47 var n: i64 = 0
48 while s[n] != (0 as u8) { n = n + 1 }
49 sys_write(1, s, n)
50 return 0
51}
52func tv_putn(v: i64) -> i64 {
53 let b: *u8 = sys_mmap(32)
54 var m: i64 = v
55 if m == 0 { b[0] = 48 as u8; sys_write(1, b, 1); return 0 }
56 if m < 0 { tv_puts("-" as *u8); m = 0 - m }
57 let t: *u8 = sys_mmap(32)
58 var k: i64 = 0
59 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 }
60 var i: i64 = 0
61 while i < k { b[i] = t[k - 1 - i]; i = i + 1 }
62 sys_write(1, b, k)
63 return 0
64}
65
66// whole-file read; outn[0] = bytes, or -1 when the path is unreadable (REPORTED, never silently 0)
67func tv_readall(path: *u8, outn: *i64) -> *u8 {
68 let fd: i64 = sys_openat_rd(path)
69 if fd < 0 { outn[0] = 0 - 1; return 0 as *u8 }
70 let buf: *u8 = sys_mmap(TV_BUFCAP)
71 var tot: i64 = 0
72 var go: i64 = 1
73 while go == 1 {
74 let r: i64 = sys_read(fd, ((buf as i64) + tot) as *u8, TV_BUFCAP - tot - 1)
75 if r <= 0 { go = 0 } else { tot = tot + r }
76 if tot >= TV_BUFCAP - 1 { go = 0 }
77 }
78 sys_close(fd)
79 buf[tot] = 0 as u8
80 outn[0] = tot
81 return buf
82}
83
84func tv_hash(s: *u8) -> i64 {
85 var h: i64 = TV_MAGIC_5381
86 var i: i64 = 0
87 while s[i] != (0 as u8) { h = ((h * 33) + (s[i] as i64)) & 0x7fffffff; i = i + 1 }
88 return h & (TV_HBUCKETS - 1)
89}
90func tv_streq(a: *u8, b: *u8) -> i64 {
91 var i: i64 = 0
92 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
93 if b[i] != (0 as u8) { return 0 }
94 return 1
95}
96
97// Parse one manifest line "<bytes> <relpath>" IN PLACE: NUL-terminate the relpath, return its pointer;
98// size lands in szout[0]. Returns 0 for a blank/garbled line so the caller can skip without aborting.
99// NOTE: written WITHOUT `break` on purpose -- this dialect's loop-exit idiom is a go-flag, and a
100// hand-rolled pseudo-break is exactly what span forever in the seq95ad autofix intake gate.
101func tv_parse(buf: *u8, start: i64, end: i64, szout: *i64) -> *u8 {
102 var i: i64 = start
103 var v: i64 = 0
104 var any: i64 = 0
105 var go: i64 = 1
106 while go == 1 {
107 if i >= end { go = 0 } else {
108 let c: i64 = buf[i] as i64
109 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48); any = 1; i = i + 1 } else { go = 0 } } else { go = 0 }
110 }
111 }
112 if any == 0 { return 0 as *u8 }
113 if i >= end { return 0 as *u8 }
114 if buf[i] != (32 as u8) { return 0 as *u8 }
115 let p: i64 = i + 1
116 if p >= end { return 0 as *u8 }
117 buf[end] = 0 as u8
118 szout[0] = v
119 return ((buf as i64) + p) as *u8
120}
121
122// index the B manifest into the hash table; returns row count
123func tv_index(buf: *u8, n: i64) -> i64 {
124 var i: i64 = 0
125 var ls: i64 = 0
126 var cnt: i64 = 0
127 let szp: *i64 = sys_mmap(16) as *i64
128 while i < n {
129 if buf[i] == (10 as u8) {
130 if cnt < TV_MAXENT {
131 let rel: *u8 = tv_parse(buf, ls, i, szp)
132 if (rel as i64) != 0 {
133 let h: i64 = tv_hash(rel)
134 tv_keyp[cnt] = rel as i64
135 tv_size[cnt] = szp[0]
136 tv_next[cnt] = tv_head[h]
137 tv_seen[cnt] = 0
138 tv_head[h] = cnt
139 cnt = cnt + 1
140 }
141 }
142 ls = i + 1
143 }
144 i = i + 1
145 }
146 return cnt
147}
148
149// find a relpath in the B index; returns entry index or -1
150func tv_find(rel: *u8) -> i64 {
151 let h: i64 = tv_hash(rel)
152 var e: i64 = tv_head[h]
153 while e >= 0 {
154 if tv_streq(rel, tv_keyp[e] as *u8) == 1 { return e }
155 e = tv_next[e]
156 }
157 return 0 - 1
158}
159
160func tv_emit(tag: *u8, rel: *u8, a: i64, b: i64) -> i64 {
161 if tv_quiet == 1 { return 0 }
162 tv_puts(tag); tv_puts(" " as *u8); tv_puts(rel)
163 tv_puts(" a=" as *u8); tv_putn(a)
164 tv_puts(" b=" as *u8); tv_putn(b)
165 tv_puts(" delta=" as *u8); tv_putn(a - b)
166 tv_puts("\n" as *u8)
167 return 0
168}
169
170func main(argc: i64, argv: *i64) -> i64 {
171 if argc < 3 {
172 tv_puts("usage: nx_treediverge <manifestA> <manifestB> [labelA] [labelB]\n" as *u8)
173 tv_puts(" manifests come from: nx_treediff <dir> <outfile> (one per tree)\n" as *u8)
174 tv_puts(" verdicts per file: A-ONLY / B-ONLY / A-AHEAD / B-AHEAD (by BYTES, a FLOOR)\n" as *u8)
175 sys_exit(2)
176 return 2
177 }
178 // `counts` as argv[1] shifts everything by one and suppresses the per-file rows. The whole token is
179 // compared, not its first byte: a flag that accepts any spelling cannot report a typo, and silently
180 // treating a MANIFEST PATH as the verb would compare the wrong two files and answer confidently.
181 var base: i64 = 0
182 tv_quiet = 0
183 let a1: *u8 = argv[1] as *u8
184 if a1[0] == (99 as u8) { if a1[1] == (111 as u8) { if a1[2] == (117 as u8) { if a1[3] == (110 as u8) { if a1[4] == (116 as u8) { if a1[5] == (115 as u8) { if a1[6] == (0 as u8) {
185 tv_quiet = 1
186 base = 1
187 } } } } } } }
188 if tv_quiet == 1 { if argc < 4 {
189 tv_puts("usage: nx_treediverge counts <manifestA> <manifestB> [labelA] [labelB]\n" as *u8)
190 sys_exit(2)
191 return 2
192 } }
193 let pa: *u8 = argv[1 + base] as *u8
194 let pb: *u8 = argv[2 + base] as *u8
195 var la: *u8 = "A" as *u8
196 var lb: *u8 = "B" as *u8
197 if argc >= 4 + base { la = argv[3 + base] as *u8 }
198 if argc >= 5 + base { lb = argv[4 + base] as *u8 }
199
200 let na: *i64 = sys_mmap(16) as *i64
201 let nb: *i64 = sys_mmap(16) as *i64
202 let ba: *u8 = tv_readall(pa, na)
203 let bb: *u8 = tv_readall(pb, nb)
204 // NON-VACUITY: refuse rather than report a comforting zero off an empty read.
205 if (ba as i64) == 0 { tv_puts("TREEDIVERGE REFUSED: cannot read manifest A\nverdict=REFUSED\n" as *u8); sys_exit(3); return 3 }
206 if (bb as i64) == 0 { tv_puts("TREEDIVERGE REFUSED: cannot read manifest B\nverdict=REFUSED\n" as *u8); sys_exit(3); return 3 }
207 if na[0] <= 0 { tv_puts("TREEDIVERGE REFUSED: manifest A is EMPTY -- a comparator that read nothing must not print zero divergence\nverdict=REFUSED\n" as *u8); sys_exit(3); return 3 }
208 if nb[0] <= 0 { tv_puts("TREEDIVERGE REFUSED: manifest B is EMPTY -- a comparator that read nothing must not print zero divergence\nverdict=REFUSED\n" as *u8); sys_exit(3); return 3 }
209
210 tv_head = sys_mmap(8 * TV_HBUCKETS) as *i64
211 tv_keyp = sys_mmap(8 * TV_MAXENT) as *i64
212 tv_size = sys_mmap(8 * TV_MAXENT) as *i64
213 tv_next = sys_mmap(8 * TV_MAXENT) as *i64
214 tv_seen = sys_mmap(8 * TV_MAXENT) as *i64
215 var h: i64 = 0
216 while h < TV_HBUCKETS { tv_head[h] = 0 - 1; h = h + 1 }
217
218 let bn: i64 = tv_index(bb, nb[0])
219
220 tv_puts("=== nx_treediverge " as *u8); tv_puts(la); tv_puts(" vs " as *u8); tv_puts(lb); tv_puts(" ===\n" as *u8)
221
222 var same: i64 = 0
223 var aahead: i64 = 0
224 var bahead: i64 = 0
225 var aonly: i64 = 0
226 var an: i64 = 0
227 let szp: *i64 = sys_mmap(16) as *i64
228 var i: i64 = 0
229 var ls: i64 = 0
230 let nn: i64 = na[0]
231 while i < nn {
232 if ba[i] == (10 as u8) {
233 let rel: *u8 = tv_parse(ba, ls, i, szp)
234 if (rel as i64) != 0 {
235 an = an + 1
236 let e: i64 = tv_find(rel)
237 if e < 0 { aonly = aonly + 1; tv_emit("A-ONLY " as *u8, rel, szp[0], 0 - 1) }
238 else {
239 tv_seen[e] = 1
240 let bs: i64 = tv_size[e]
241 if szp[0] == bs { same = same + 1 }
242 else { if szp[0] > bs { aahead = aahead + 1; tv_emit("A-AHEAD" as *u8, rel, szp[0], bs) } else { bahead = bahead + 1; tv_emit("B-AHEAD" as *u8, rel, szp[0], bs) } }
243 }
244 }
245 ls = i + 1
246 }
247 i = i + 1
248 }
249 var bonly: i64 = 0
250 var e2: i64 = 0
251 while e2 < bn { if tv_seen[e2] == 0 { bonly = bonly + 1; tv_emit("B-ONLY " as *u8, tv_keyp[e2] as *u8, 0 - 1, tv_size[e2]) } e2 = e2 + 1 }
252
253 tv_puts("\n--- ENVELOPE ---\n" as *u8)
254 tv_puts("rows_" as *u8); tv_puts(la); tv_puts("=" as *u8); tv_putn(an); tv_puts(" rows_" as *u8); tv_puts(lb); tv_puts("=" as *u8); tv_putn(bn); tv_puts("\n" as *u8)
255 tv_puts("same=" as *u8); tv_putn(same)
256 tv_puts(" " as *u8); tv_puts(la); tv_puts("_ahead=" as *u8); tv_putn(aahead)
257 tv_puts(" " as *u8); tv_puts(lb); tv_puts("_ahead=" as *u8); tv_putn(bahead)
258 tv_puts(" " as *u8); tv_puts(la); tv_puts("_only=" as *u8); tv_putn(aonly)
259 tv_puts(" " as *u8); tv_puts(lb); tv_puts("_only=" as *u8); tv_putn(bonly)
260 tv_puts("\n" as *u8)
261 let div: i64 = aahead + bahead + aonly + bonly
262 tv_puts("divergent=" as *u8); tv_putn(div); tv_puts("\n" as *u8)
263
264 // A PARTITION IS A CLAIM: CHECK THAT THE PARTS SUM, AND PRINT THE CHECK RATHER THAN LEAVING THE
265 // READER TO DO THE ARITHMETIC. MEASURED 2026-08-16 on the live laptop-vs-nas run: the A side
266 // reconciled exactly (15816+1178+1679+136 = 18809 = rows_laptop) while the B side came up TWO short
267 // of rows_nas. Nobody had noticed, because nothing printed the sum -- and an unexplained residual is
268 // a leak, not a rounding. Every shared bucket is counted on BOTH sides; only the *_only buckets differ.
269 let suma: i64 = same + aahead + bahead + aonly
270 let sumb: i64 = same + aahead + bahead + bonly
271 tv_puts("partition_" as *u8); tv_puts(la); tv_puts("=" as *u8); tv_putn(suma)
272 tv_puts("/" as *u8); tv_putn(an)
273 tv_puts(" partition_" as *u8); tv_puts(lb); tv_puts("=" as *u8); tv_putn(sumb)
274 tv_puts("/" as *u8); tv_putn(bn)
275 var recon: i64 = 0
276 if suma == an { if sumb == bn { recon = 1 } }
277 if recon == 1 { tv_puts(" partition=RECONCILES\n" as *u8) }
278 else { tv_puts(" partition=RESIDUAL -- the parts do NOT sum to the rows scanned; duplicate relpaths in a manifest are the usual cause, and a registry with two rows for one path has no single answer\n" as *u8) }
279
280 // ROWS, NOT BYTES. The non-vacuity guard at the top of main asks "did I READ anything?"; this asks
281 // the question that actually decides the verdict: "did I PARSE anything?".
282 // MEASURED 2026-08-16: handed treecanon_laptop_hash.mf (1,767,988 bytes, 16k+ rows) instead of
283 // treecanon_laptop.mf, this organ read every byte, parsed ZERO rows -- that family is
284 // `<sha> <bytes> <relpath>` while tv_parse expects `<bytes> <relpath>` -- and printed
285 // verdict=CONVERGED. A caller asking "do the trees agree?" got a confident YES from a comparison
286 // that examined nothing. The two manifests differ by one word in the filename, so it is a one-word
287 // mistake to make, and I made it on the first attempt.
288 // ★A NON-VACUITY GUARD THAT MEASURES BYTES CANNOT SEE AN UNPARSEABLE FILE -- a file full of bytes it
289 // cannot read sails straight through the check written to catch exactly this.
290 // ★A COMPARATOR THAT PARSED NOTHING MUST NOT PRINT A VERDICT ABOUT ANYTHING, and CONVERGED is the
291 // most dangerous thing it could say: it is the answer that ends the investigation.
292 if an <= 0 {
293 tv_puts("TREEDIVERGE REFUSED: parsed ZERO rows from manifest " as *u8); tv_puts(la)
294 tv_puts(" -- it was readable and NON-EMPTY, so this is a FORMAT mismatch, not an empty tree.\n" as *u8)
295 tv_puts(" This organ reads `<bytes> <relpath>` (nx_treediff output). The *_hash.mf family is\n" as *u8)
296 tv_puts(" `<sha> <bytes> <relpath>` and belongs to a different reader.\nverdict=REFUSED\n" as *u8)
297 sys_exit(3); return 3
298 }
299 if bn <= 0 {
300 tv_puts("TREEDIVERGE REFUSED: parsed ZERO rows from manifest " as *u8); tv_puts(lb)
301 tv_puts(" -- it was readable and NON-EMPTY, so this is a FORMAT mismatch, not an empty tree.\n" as *u8)
302 tv_puts(" Expected `<bytes> <relpath>` (nx_treediff output).\nverdict=REFUSED\n" as *u8)
303 sys_exit(3); return 3
304 }
305
306 // ★THE VERDICT THAT MATTERS: BIDIRECTIONAL means a one-way sync in EITHER direction destroys work.
307 if aahead > 0 { if bahead > 0 {
308 tv_puts("verdict=BIDIRECTIONAL -- NEITHER TREE IS A SUPERSET. A one-way sync in EITHER direction DESTROYS WORK; merge per-file, never push wholesale.\n" as *u8)
309 sys_exit(1); return 1
310 } }
311 if div == 0 { tv_puts("verdict=CONVERGED -- byte-identical on every shared path (a FLOOR: equal size is not proof of equal content)\n" as *u8); sys_exit(0); return 0 }
312 if bahead == 0 { if bonly == 0 { tv_puts("verdict=A-SUPERSET -- safe to sync A->B\n" as *u8); sys_exit(0); return 0 } }
313 if aahead == 0 { if aonly == 0 { tv_puts("verdict=B-SUPERSET -- safe to sync B->A\n" as *u8); sys_exit(0); return 0 } }
314 tv_puts("verdict=DIVERGENT-ONE-WAY -- one side leads on content but the other has unique paths; reconcile the *_only lists before syncing.\n" as *u8)
315 sys_exit(1)
316 return 1
317}