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}