Guard the index.html ↔ play.html twin: a one-sided edit now fails the commit - #375
Merged
Conversation
index.html (editor) and play.html (app player + export) share 696 hand-maintained byte-identical lines longer than 40 characters. Every engine change is a 2-surface edit. The existing twin guards each pin one named function (resizePlan, the pricing resolver, MP4CAT, cost accrual). check-js-parity and check-njs-*-delegation pin each surface against the sibling nanoodle-js bundle. Nothing compared the two files as wholes, so a one-sided edit to any other shared line shipped silently. scripts/check-twin-drift.mjs measures the shared set and pins it against scripts/twin-drift-baseline.json: - A line that leaves the set because 1 surface moved and the other did not FAILS, with both file:line positions and the 2 line versions. - The count is a ratchet. Extraction lowers it freely. New duplication cannot raise it silently. - Generated regions are blanked first (the njs-engine bundle, the probed PROMPT-CAPS table, the i18n maps, the Runware AIR table), so the library is never booked as hand duplication. - A correctly mirrored edit passes. Membership moves, the count holds, and the guard asks for a refresh in a note. Refresh: TWIN_DRIFT_UPDATE=1 node scripts/check-twin-drift.mjs docs/twin-drift.md ranks the 90 duplicated blocks and gives a verdict for each: covered by the bundle path, extractable, or surface-specific. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
nanoodle | b2a245b | Commit Preview URL Branch Preview URL |
Jul 30 2026, 01:20 AM |
The region that blanks the generated nanoodle-js bundle over-matched. The lazy regex /<script id="njs-engine"[\s\S]*?<\/script>/ also matched the STRING LITERAL at play.html:11240, where the export builder re-emits the tag. Every </script inside RUNTIME_JS is written escaped as <\/script, so that second match never closed until the last real </script> in the file. It blanked play.html:11240-13637: 2,398 lines, 17.6% of the file, all of it hand-written app-player code. Renaming usd_balance at play.html:12698 (a byte-identical twin of the live balance parse at index.html:8101) passed the guard. So did every other one-sided edit to boot(), the share and export builder, the balance cache, the __appready__ handoff and the model picker. The guard now matches <script id="njs-engine" data-hash="..."> and re-derives the hash. scripts/gen-js-engine.mjs:165 writes data-hash = sha256(bundle).slice(0,16), so a quoted string inside RUNTIME_JS cannot forge a body that hashes to its own declared hash. A block whose content does not match its data-hash now fails outright. The real hand-maintained duplication is 893 distinct lines, not 696. That number in the commit message of 7a28dbb is wrong; this commit supersedes it. docs/twin-drift.md and the PR body are corrected, and every count in the document is re-derived from the fixed measurement. Two more holes are closed: - A one-sided DELETION no longer passes. Deleting play.html:7330 (if(scale>1) scale = 1;) left the editor clamping upscale and the player not, and the guard printed "1 deduplicated onto one surface" and exited 0. Deduplicated asserts the code moved; it had not. A departure is now real extraction only when the line left BOTH surfaces or moved into the generated bundle. A line that is gone from 1 surface and still live on the other fails. - Occurrence counts are tracked per surface, not just presence. 74 of the 893 lines appear more than once inside a surface. Editing 1 of 2 identical copies left the hash present, so nothing left the shared set. index.html:5630, the pricing resolver's per_duration branch, is 1 of them. The baseline now stores "<hash> <n index> <n play>" per line, and a count that drops on 1 surface only, or rises at all, fails. docs/twin-drift.md block 8 claimed the guard protects the og-card lines. The og and twitter title, description and url are deliberately different per page, so they were never in the shared set and cannot leave it. The block now says exactly which head lines are shared (og:image, the twitter card, the icons) and which are not. Sandbox tests, all reproduced before and after: - one-sided edit at play.html:12698 before pass, now fails - one-sided edit at index.html:8101 before pass, now fails - one-sided deletion at play.html:7330 before pass, now fails - multi-occurrence edit at index.html:5630 before pass, now fails - new shared line fails, both before and after - mirrored edit passes with a refresh note - deletion from both surfaces passes with an extraction note - extra copy of a shared line now fails - edit inside the generated bundle ignored, correctly - tampered data-hash now fails - unrelated 1-surface line passes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check 41 comment said ~700 hand-maintained lines. The real number is 893. It also described only the drift and growth failures. The guard now also fails a one-sided deletion and a per-surface occurrence-count drop, and it finds the generated njs-engine block by its data-hash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s cost Five follow-ups on the review of this branch. 1. The pre-commit hook still said "~700" shared lines at the top of the file. The real number is 893. The previous round fixed only the second occurrence. Both now read 893, and a word-boundary sweep of the whole branch diff for 696, 700 and 90 comes back empty. 2. The extraction exemption was text-presence based, so it disabled the one-sided-deletion rule on 15% of the guarded set. Any departing line whose text appeared anywhere inside the generated njs-engine bundle was booked as "moved into the bundle" and passed. 131 of the 893 baseline lines are byte-identical to a line already inside that bundle while BOTH hand copies stay live, because the library ships MP4CAT, the pricing resolver and the resize geometry too. Deleting play.html:6850 (live MP4CAT code) from play.html alone exited 0 with an extraction note. Extraction now means what it says: the line left BOTH hand-maintained surfaces. That case was already detected separately, so the bundle test moved into it and now only picks the wording of the note. A line still live on one surface always fails. Verified: the same one-sided deletion now exits 1 and names index.html:9292, and deleting it from both surfaces still passes with the extraction note. 3. The runtime claims were wrong at the boundary. The header said "well under 2 seconds" and the hook said "~0.2s". A clean tree is 0.2s, but bestMatch rebuilt the bigram profile of every candidate line on every call, so at the MAX_CLASSIFY ceiling of 199 departures the guard took 36-40s. bestMatch now builds each candidate profile once, keys bigrams as packed integers in sorted typed arrays, and binary-searches the length band. The same 199-departure case measures 5-6s under the same load, 1.5s idle. The classification output is byte-identical before and after. The header, the hook and docs/twin-drift.md all state the measured numbers now. 4. The ratchet had a hand-edit escape: the digest hashes only the "lines" array, so raising "count" by hand raised the ratchet with no digest mismatch. The ratchet number is now DERIVED from "lines" and never read from the field. count, occurrencesIndexHtml and occurrencesPlayHtml stay in the JSON for a human reader, and the guard fails if any of them stops describing "lines". Verified: count hand-set to 1200 fails; count deleted outright still catches a line added to both surfaces. 5. docs/twin-drift.md now opens the ranked list with what it does NOT cover: 16 of 87 blocks are ranked, seekVideo falls between 2 block ranges so it is in no block's sig, and the deliberately per-page og/twitter title, description and url lines are guarded by nothing at all. New: TWIN_DRIFT_STATS=1 prints every figure the document quotes about the shared set (893, 957, 1011, 74/28/65, 131), so the document is re-verifiable with one command instead of trusted. 52 of 52 scripts/check-*.mjs pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit measured the worst case at 199 departures. The ceiling is 200: the guard classifies line by line while `left.length <= MAX_CLASSIFY` and reports totals at 201. Confirmed both sides of the boundary. Re-measured at 200 departures on the same machine, which held a load average of 25 to 34 throughout: clean tree 0.9s wall (0.6s CPU); 0.2s on a quiet machine 200 departures 3.5-5.7s wall (3.1-4.9s CPU); 1.5s at its fastest 200 departures, before 23-30s wall (22-27s CPU) The guard header, the hook comment and docs/twin-drift.md now all carry those figures. The earlier "36-40s" for the old code was measured while 2 benchmark processes competed for the machine, so it overstated the regression. 52 of 52 scripts/check-*.mjs pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…loor Recomputed the extraction work list from the ranges the document itself quotes. Every `sig` figure was right (25, 5, 23, 9, 68, 123, 64, 140, 24). The cumulative "Baseline after" column was not: it was 1 too high from row 3, and 5 too high from row 8. was 868 863 841 832 764 641 578 442 418 now 868 863 840 831 763 640 577 437 413 Row 7 is the only row that overlaps an earlier row, 1 line of its 64. The table now carries a "new" column so the arithmetic is visible instead of implied, and the headline reads 253 lines freed by rows 1 to 6 (893 down to 640), not 252. Two corrections to the seekVideo gap: - It sits AFTER block 4's range (ends index.html:8870) and BEFORE block 12's (starts index.html:9009). The old text named the wrong 2 blocks. - The full twin is index.html:8995-9004 and play.html:6639-6648. Only 2 of its 10 lines are longer than 40 characters, so only 2 are in the shared set at all. That second point is a general gap and the document now says so plainly: the 40-character floor buys the guard its low false-positive rate and it costs coverage on every block. 8 of seekVideo's 10 byte-identical lines are guarded by nothing. 52 of 52 scripts/check-*.mjs pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard exited 0 on the drift it exists to catch. Edit index.html:9292 to a+s.durIDX and play.html:6850 to a+s.durPLAY: the twin leaves both surfaces, the text-presence rule read that as extraction, and the run printed "1 left both surfaces and are carried by the generated bundle" and exited 0. The 2 engines genuinely differed. Leaving both surfaces is not what makes a departure an extraction. Leaving both surfaces while the 2 surfaces still AGREE is. A departure from both now fails when index.html holds a near-identical replacement, play.html holds one too, and those 2 replacements look like each other. The third condition is measured, not decorative: an earlier draft failed a single-sided match and raised a FALSE failure on the MP4CAT extraction control, where a departing banner comment scored 0.840 against an unrelated surviving banner because a run of dashes carries the bigram profile. The test needs the departed line's TEXT, which no longer exists in either file, so the baseline stores it: each entry is now "<hash> <n index> <n play> <the line>". The hash doubles as a checksum of the text, so a stored line cannot be hand-swapped. 25 KB to 102 KB. The digest was checked as `if (baseline.digest && ...)`, so DELETING the field switched it off. Inflate 1 line's counts from "1 1" to "2 2" (a drop on both surfaces reads as a mirrored deletion and is allowed), move the 2 occurrence totals to match, delete digest: clean tree, exit 0, ratchet silently raised. A missing or malformed digest is now an error. Records: - "byte-identical" was wrong about more than half the set. play.html nests the same code deeper: 415 of the 893 lines are byte-identical in the files, 478 match only after the leading/trailing whitespace strip. TWIN_DRIFT_STATS prints the split. - docs/twin-drift.md defined "Baseline after" as the fully-inside-the-block count and then subtracted sig. They disagree on rows 3, 7 and 8, so every cell from row 3 down was wrong. scripts/twin-drift-worklist.mjs owns the table now: 868, 863, 841, 832, 764, 641, 578, 442, 418, and rows 1-6 free 252 lines, not 253. No row overlaps another; the earlier overlap claim was the AudioContext line, which neither row deletes. - The work list quoted play.html:7277-7290 for maskToSource. That range is audioInputPart. maskToSource is play.html:7301-7319, and with the wrong range the row measured sig 0. - The guard classifies up to 200 departures but NAMES the first 12 of each failure list. The doc and the hook now say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 2-sided divergence rule added last round over-fires. Deleting the block ranges docs/twin-drift.md itself plans, from BOTH surfaces, failed 4 of its own 9 planned extractions: row 1 with 2 false divergences, row 3 with 1, row 7 with 3, row 8 with 2. Row 1 is a pure mirrored removal (sig = deletes = 25) and the guard called it drift. Cause: "the surfaces still agree" was inferred from whatever survived. A mirrored block deletion and a divergent 2-sided edit leave trees that differ in exactly 1 way — the divergent edit ADDS text — so a rule with no memory of what was there before must match a coincidence sooner or later. There are already 796 look-alike pairs over the 893 baseline lines. The baseline now stores a per-surface LOOK-ALIKE SET per line: the hash of every other line that already scored DRIFT_SIMILARITY against it when the baseline was written. A candidate replacement out of that set replaced nothing. All 9 documented extractions now pass except row 8, whose 5 one-sided reports are true (their other copy is in unrelated code), and the index.html:9292 / play.html:6850 divergent edit still fails. - scripts/check-twin-drift-cases.mjs: 15 mutations with the verdict the guard must return — the 9 extractions, a 2-sided divergent edit, a 1-sided edit, a 1-sided deletion, a mirrored edit and fresh duplication. Neither direction can regress alone now. - scripts/twin-drift-bench.mjs: the 3 timing cases, so every runtime figure is reproducible. - .githooks/pre-commit: touches_twindrift was the only touches_* variable missing from the early-exit condition, so staging ONLY the baseline skipped the guard — the one file to edit to defeat the ratchet was the one file that skipped the check. - docs/twin-drift.md: rows 3, 7 and 9 published ranges that pair regions which are not twins; the guard reported them as one-sided deletions and they are corrected here. - Every runtime figure re-measured on this machine, 5 or 10 samples each, and marked as machine-dependent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e about itself The guard header pointed at scripts/twin-drift-sandbox.mjs, which does not exist, and said the look-alike sets "take all 9 to exit 0". Row 8 exits 1, and correctly so: 5 twins inside the 2 share blocks keep their other copy in unrelated code on 1 surface, so deleting the blocks leaves each of them live on exactly 1 surface. No choice of ranges mirrors that row. The header now names the real file (scripts/check-twin-drift-cases.mjs) and states the real verdict: 0 divergences on all 9 rows, 8 rows fully silent, row 8 reporting 5 one-sided deletions plus 1 occurrence drift. The pre-commit hook said the same wrong thing at its touches_twindriftcases comment. "796 look-alike pairs over the 893 baseline lines" was loose. The baseline stores 796 look-alike hashes across the 893 lines, and 218 of those lines carry at least 1. Every timing is re-measured on this machine, and every earlier figure is discarded rather than carried forward. scripts/twin-drift-bench.mjs, 130 samples of each case (26 runs of 5) on a shared 18-core machine with the 1-minute load average between 2.56 and 12.11: clean tree 0.13-0.48 s wall (0.15-0.57 s CPU) 200 departures, gone from both 2.07-6.02 s wall (2.15-6.23 s CPU) 200 departures, gone from 1 surface 0.98-2.89 s wall (1.03-3.09 s CPU) Across those 130 the 6.02 s is a single outlier: the next slowest of that row was 4.52 s. The baseline refresh took 9.22-19.05 s over 26 samples, and the sandbox matrix 9.6-17.8 s over 25. Unrelated load on this shared machine, not the guard, is what makes each range as wide as it is, and 1 measurement makes the point better than the sentence does: a 26th matrix run, made while a Rust build held the box at a load average of 23.5 and 6 GB into swap, took 65.9 s on the same commit for the same 15 verdicts. It is reported next to the range rather than folded into it, because the range states the load it was measured under. Every figure now carries its load average and tells the reader to measure their own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t was measured in
docs/twin-drift.md said that at the MAX_CLASSIFY ceiling the guard "names at most 24" of the 200
departures, "12 under each heading that fires". The 12 is right and the 24 is not. A departure lands
under 1 of THREE headings — TWIN DIVERGENCE, TWIN DRIFT or ONE-SIDED DELETION — and each prints its
own first 12. 24 assumed only 2 of the 3 could fire at once.
All 3 do fire at once, and the matrix now proves it. New case, "all 3 departure headings at once":
edit index.html:9292 and play.html:6850 to different text (divergence), rename play.html:12698 on 1
surface (drift), and delete play.html:7330 from 1 surface (one-sided deletion). The guard reports all
3 separately, so the ceiling is 12 x 3 = 36. The matrix is 16 cases now.
Every timing is re-measured over the whole session, not a single quiet window, because the box was
shared with a Rust build whose load average moved between 2.6 and 14.8 and dragged every figure with
it. Each range is now the FULL spread of every sample taken, and each carries the load band it was
measured in, because outside that band it says nothing:
bench, 160 samples of each case (32 runs of 5), load average 2.6-12.1
clean tree 0.13-0.57 s wall (0.15-0.67 s CPU)
200 departures, gone from both 2.07-8.21 s wall (2.15-8.38 s CPU)
200 departures, gone from 1 surface 0.98-4.64 s wall (1.03-5.14 s CPU)
baseline refresh 9.22-28.15 s wall, 36 samples, load average 2.6-9.5
sandbox matrix 12.2-29.9 s wall, 25 samples, load average 3.0-14.8
The 15-case matrix figures (9.6-17.8 s over 25 samples, and the 65.9 s run at a load average of 23.5
and 6 GB into swap) are kept, labelled as 15-case, as the evidence that the load matters more than
the code here. No earlier figure is carried forward unmeasured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table of what the first divergence rule did to the 9 planned extractions gave row 7 three false divergences, at index.html:6328 / play.html:6946, an onerror pair. Re-measured by running this guard with its look-alike memory switched off — one line in bestMatch(), which is exactly what the first rule was — that figure does not reproduce. Against the ranges the work list publishes today, row 7 raises ONE false divergence, at index.html:6605 / play.html:10791, a createElement pair. The 3 is not invented. It belongs to row 7's OLD ranges, play.html:6616-6660 + 6960-7150, which the work list stopped publishing when those ranges were corrected in an earlier commit on this branch. The table was measured then and not re-measured after the ranges moved. Both readings are now stated, each against the ranges it belongs to. Rows 1, 3 and 8 re-measure exactly as stated (2, 1 and 2), and so do the positions they name. The headline — 4 of the 9 planned extractions failed the first rule — holds under either set of ranges. The doc now carries the one-line patch that reproduces the table, so the next reader does not have to take the row on trust. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e moved surfaces #374 and #376 both changed index.html and play.html, so the two artifacts this guard pins had to be re-derived rather than merged. Nothing here loosens a rule. Baseline. The guard reported exactly one change and it is #376's: `async genVideo(model, prompt, opts, imageDataUrl, onProgress){` gained a second copy in index.html at 9046, the DEMO_CTX override that lets a signed-out sample reach a video result. The distinct count held at 893, nothing entered the shared set and nothing left it, so the only ratchet movement is index.html occurrences 957 -> 958 (and multi-occurrence lines 74 -> 75). The refresh also recorded 2 new look-alike hashes, both from the same commit: index.html:9022 `if(!r.ok) throw new Error(String(r.status));` and index.html:9025's FileReader line. 796 -> 798 hashes over 219 lines. Every delta is attributable to #376; none is a one-sided edit. Sandbox matrix. Its ranges are absolute and #376 pushed index.html down by up to 111 lines and #374 pushed play.html down by 19, so all 25 ranges moved. Each was re-derived and checked byte for byte against its pre-merge content. Left stale, 9 of the 16 cases return the wrong verdict — measured, not assumed. All 16 now pass, row 8 still reporting the same 5 one-sided deletions and 1 occurrence drift at their new positions. docs/twin-drift.md and scripts/twin-drift-worklist.mjs carry the same ranges, so all 216 file:line references in them moved too; the work-list table is unchanged (893, and rows 1 to 6 still free 255 lines). Hook. #372 landed touches_llmsfull and #373 landed touches_launchfacts on the same early-exit line this PR adds touches_twindrift and touches_twindriftcases to. All 4 are unioned in, and each was proved to fire by staging one file for it and watching which check ran; an unrelated file still early-exits. The 2 new checks are renumbered 43 and 44, main's launch-facts and llms-full keeping 41 and 42. Re-measured, and figures corrected where the merge moved them: timings (30 samples per case, load average 3.19-5.68), the baseline byte sizes, the raw unblanked occurrence count, and the guard-suite total, now 55 of 55 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mikkel
added a commit
that referenced
this pull request
Aug 1, 2026
…ns one (#379) * Ask for both try-on images, and stop pretending a 4-image model returns one (#379) Two live-probed image quirks that end the same way: a paid call the app could have predicted, or a bill that doesn't match what the UI said. flux-pro/v1/vto needs exactly two images — a person, then a garment. The catalog says max_input_images: 2 and nothing else; there is no min_items field anywhere in the 215-model catalog, only a free-text provider note. So the editor drew ONE image port, the user wired one photo, and the run 400'd ("FLUX Virtual Try-On requires two input images…", uncharged). A curated IMG_INPUT_ROLES map now draws both ports LABELLED person/garment, warns on the node while a slot is empty, and refuses the run before any request. Slot order is load-bearing — a reversed pair still renders but drifts the identity toward the garment photo's model — so port re-packing is skipped for these models (unwiring the person could otherwise promote the garment into her slot), and the guard checks the raw ports rather than the array length, because image + image3 is "two images" with the garment slot empty. fixed_image_count models (midjourney/text-to-image, higgsfield-soul) always return four images and bill for four. The variations control offered 1..4 and defaulted to "1", the node chip said $0.020/img, and only the estimate quietly used 4. Variations is now locked to the real number with the reason stated under it, the chip reads $0.080/run ×4, and the request asks for what it is charged for. No override list: a 64-model probe found nothing else that ignores n=1, so the catalog field is the whole truth here. Also routes hidream-e1-1, wan-2.6-image-edit and vidu-q2-reference to the Edit picker. All three are typed "text+image->image" and hard-fail without a source image, but so are nano-banana-2-lite, seedream-v4.5 and qwen-image-3, which generate fine from a bare prompt — modality cannot separate them, and none of the three ids carries an upscal/img2img tell, so it is a dated id list. docs/NANOGPT-image-input-count-metadata.md files the upstream ask (min_items, roles, a fully-populated fixed_image_count) and records what is simply broken right now: riverflow-2-fast/-standard 400 on an invalid AIR identifier while riverflow-2.0-pro works, five more models fail fast and uncharged, and a data-URL MIME mismatch surfaces as a nonsense-dimensions 413 IMAGE_INPUT_TOO_LARGE rather than a format error. Guards: check-image-ports gains the role cases (both ports render, no recompaction, edit.run throws pre-fetch at one image and at a port hole); check-run-compat asserts zero fetches for an under-wired vto and person-first order for a complete one; check-js-parity pins the refusal on BOTH engines. Delegation to nanoodle-js is vetoed for role models in both engines so the curated lists can never disagree about what a run costs. check-twin-drift-cases is re-anchored: its 9 extraction ranges and 3 edit anchors were left pointing at pre-#378 line numbers, so every extraction row was failing on main. Re-derived by content from #375, the last tree where all 16 verdicts were correct. Update: Try-on models now ask for both photos by name, and models that always return four images say so before you run them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Cover the forced-count half with guards, and stop offering a try-on model a port hole can't run Review follow-ups on the commit above. Nothing here changes what a correct run sends; two of the five are real behavior fixes at the edges. The play swap menu asked the wrong question. modelOptionsFor counted how many image links a node had and compared that to the number of roles, so an app wired image + image3 had "two images" and kept flux-pro/v1/vto on the menu — while the runner guard twenty lines away refuses exactly that graph, because image2 (the garment) is empty. Same length-is-not-slot-coverage reasoning the rest of the commit applies; the menu now checks that every role's own port is wired, so the dead end stops being offered rather than being sold and then refused. fixed>1 is now the single predicate for "the model dictates the count". dimDefs, the image run() and livePrice all read it that way; the two estimate sites (nodeUnitUsd and its play twin) still read fixed>0, so a model declaring fixed_image_count: 1 alongside max_output_images: 4 would request and bill 3 variations while quoting 1. The catalog carries the field on 14 models and >1 on exactly two, so this only bites if one of the other twelve also advertises max_output_images > 1 — and then it quotes what the run actually asks for. Both sites now state the invariant in a comment, so the next reader doesn't have to re-derive which of the two thresholds is the right one. The forced-count half had no guard at all: mutation-testing it, dimDefs could stop emitting the locked def, run() could stop asking for N, and livePrice could drop the ×N with all fifteen named guards still green. check-image-ports now drives the REAL dimDefs, image.run() and livePrice out of index.html and pins all three, plus the fixed_image_count:1 fall-through; check-run-compat gains the same two run scenarios against the exported runtime and lifts fillDimLists into a paper DOM to pin the pinned-and-captioned variations box (and its removal when the model no longer forces a count). Each of the five sites was neutered in turn and the matching guard fails on every one. The .fhint and :disabled rules shipped in the app.css TEMPLATE, which is frozen per app at creation — so every app exported before this feature would render the "always returns 4 images" sentence as unstyled body text next to a control with no sign it's locked. They move to RUNTIME_WIDGET_CSS, which bundle() injects ahead of the stored css on both paths (PR #246), and which is interpolated back into the template so new apps are unchanged. Finally the editor's locked-variations sentence renders full width beneath the dimensions row instead of inside the half-width .two column it was sharing with the size control. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Re-anchor check-twin-drift-cases to the post-fix-round line map The fix round's line shifts rotted the matrix's hardcoded anchors; the guard only runs from pre-commit when a twin-drift file is staged, so the rot surfaced in the full-suite run, not the commit. 16 verdicts green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks today
index.html(editor) andplay.html(app player + the single-file export) share 893 distincthand-maintained lines longer than 40 characters. They occur 958 times in
index.htmland 1,011times in
play.html. Every engine change is a 2-surface edit."Identical" here means identical after
String.prototype.trim().play.htmlnests most of theshared code 1 or 2 levels deeper than
index.html, so only 415 of the 893 lines are byte-identicalin the files; the other 478 match only after the leading and trailing whitespace is stripped.
seekVideois the visible case:index.html:9106starts at column 0,play.html:6658at column 2.The guard is right to strip — an indentation change is not drift — and earlier versions of this body
and of
docs/twin-drift.mdsaid "byte-identical" throughout, which was wrong about more than halfthe set.
TWIN_DRIFT_STATS=1now prints the split.origin/maincarries 53scripts/check-*.mjsguards. 24 of them name bothindex.htmlandplay.html, but each targeted twin guard pins 1 named function:check-resize-planliftsresizePlan,check-pricinglifts the pricing resolver,check-combineruns MP4CAT over fixtures,and so on.
check-js-parityandcheck-njs-*-delegationpin each surface against the siblingnanoodle-jsbundle. Nothing comparedindex.htmlagainstplay.htmlas whole files.So a one-sided edit to any shared line those guards do not name ships silently. Dual-engine drift is
the dominant historical bug class in this repo (PRs #64, #74, #88; the node audit found it dominant).
Merged with the current main, and both artifacts re-derived
This PR was held until last in its batch on purpose. Its 2 artifacts are pinned to the exact content
of
index.htmlandplay.html, and #374 and #376 both changed both surfaces.origin/mainis nowmerged into this branch, so CI proves the re-derivation against the real base.
The guard reported exactly 1 change, and it is #376's.
async genVideo(model, prompt, opts, imageDataUrl, onProgress){gained a second copy inindex.html, at 9046 — theDEMO_CTXoverridethat lets a signed-out sample reach a video result.
play.htmlhas noDEMO_CTX, so that third copyis deliberate and editor-only:
The distinct count held at 893: nothing entered the shared set and nothing left it, so no drift,
no one-sided deletion and no divergence. The only ratchet movement is
index.htmloccurrences957 → 958, and lines appearing more than once inside a surface 74 → 75 (29 in
index.html,65 in
play.html). The refresh also stored 2 new look-alike hashes, both from the same commit —index.html:9022if(!r.ok) throw new Error(String(r.status));andindex.html:9025'sFileReaderline — taking the look-alike sets from 796 hashes on 218 lines to 798 on 219. Every delta is
attributable to #376, and none is a one-sided edit, which is why refreshing was the right move here
and not a paper-over.
The sandbox matrix needed all 25 of its ranges re-derived. #376 pushed
index.htmldown by up to111 lines and #374 pushed
play.htmldown by 19. Each range was re-derived and then checked bytefor byte against its pre-merge content, so no range silently slid onto different code. That the
re-derivation is load-bearing is measured, not assumed — with the stale ranges, 9 of the 16 cases
return the wrong verdict:
docs/twin-drift.mdandscripts/twin-drift-worklist.mjsquote the same ranges, so all 216file:linereferences across the 4 files moved with them. The work-list table is unchanged —893, and rows 1 to 6 still free 255 lines — because no insertion from either PR landed inside any
block range.
What this change does
scripts/check-twin-drift.mjs(new)An offline guard. No network, no API spend. It computes the shared set, blanks the generated regions
first, and pins the result against
scripts/twin-drift-baseline.json.It fails when:
The keeper still carries the original; the mover carries a near-identical variant. The message
names both
file:linepositions and prints the 2 versions of the line.it, and the line is still live on the other surface. The code did not move. 1 engine stopped doing
the work and the other still does it.
than once inside a surface (29 in
index.html, 65 inplay.html), so presence alone is notenough: editing 1 of 2 identical copies leaves the hash present.
new, different replacement of it. See rounds 4 and 5 below.
It passes on a real extraction (note only) and on a correctly mirrored edit (both
surfaces changed to the same new text; membership moves, the count holds).
Every departure is classified up to the
MAX_CLASSIFYceiling of 200. Each failure list names itsfirst 12 lines and reports the rest as a count. A departure lands under 1 of 3 headings —
TWIN DIVERGENCE,TWIN DRIFTorONE-SIDED DELETION— and all 3 can fire in the same run, so atthe ceiling the guard classifies 200 departures and names at most 36, 12 under each.
Three figures about that ceiling were wrong in earlier revisions of this body. All three are
withdrawn rather than restated:
between the headings depends on which lines you delete: a different selection gives a different
split, and the sign-off run measured 14 / 186 on its own selection. No split belongs here.
docs/twin-drift.md. It assumed only 2 of the 3headings could fire at once. All 3 fire together — edit
index.html:9403andplay.html:6869todifferent text, rename
play.html:12717on 1 surface, and deleteplay.html:7349from 1surface — so the ceiling names up to 12 x 3 = 36. That mutation is now case 16 of the sandbox
matrix, so the 36 cannot quietly go wrong again.
The only stable claim, and the only one now made anywhere, is "200 classified, at most 36 named".
Round 4: a 2-sided DIVERGENT edit exited 0. That was the guard's largest hole
Edit
index.html:9403toa+s.durIDXandplay.html:6869toa+s.durPLAY. The 2 engines nowgenuinely differ, which is exactly the drift this guard exists to catch. Before this round:
The cause was the same text-presence reasoning as round 3, one level down: a line that also appears
in the generated bundle was treated as an extraction whenever it left both surfaces, with no check
that the 2 surfaces still agreed.
The rule now. Leaving both surfaces is not what makes a departure an extraction. Leaving both
surfaces while the 2 surfaces still agree afterwards is. A departure from both surfaces fails as
divergence when all 3 of these hold, and passes otherwise:
index.htmlcarries a line near-identical to the departed line,play.htmlcarries one too,"Near-identical" is one number in one place:
DRIFT_SIMILARITY = 0.72, Dice over character bigrams,the same test the drift rule already used. A surviving pair is always different, because an
identical pair would be in the shared set. After the fix:
Round 5: that divergence rule over-fired, and it failed the work this document plans
The rule above shipped without asking whether the "replacement" it found was new. Deleting the
block ranges
docs/twin-drift.mditself plans, from BOTH surfaces, then failed 4 of its own 9planned extractions.
Re-measured after the
origin/mainmerge, by running the current guard with its look-alike memoryswitched off (one line in
bestMatch()), against the ranges the work list publishes today.Positions are given in the files as committed; the guard prints them in the mutated tree, so each
is shifted back by the lines its own row deletes above it:
index.html:7130/play.html:9763, 2 unrelated canvas lines that had been sitting there all alongencodeWavMono+mediaFetchErrorindex.html:9006/play.html:8812, an unrelatedFileReaderpairindex.html:6651/play.html:10810, an unrelatedcreateElementpairThe 4 counts — 2, 1, 1, 2 — are unchanged by the merge, and so are the lines behind them. #376 added
a second
FileReaderline toindex.html(9025) that also looks like row 3's departing line, butrow 3 still raises exactly 1, because
bestMatchreturns one candidate rather than all of them.Row 7 is corrected here. Earlier revisions of this body and of
docs/twin-drift.mdgave row 73 false divergences at
index.html:6374/play.html:6965, anonerrorpair. That figure isreal, but it belongs to row 7's old ranges (
play.html:6635-6679+6979-7169), which the worklist stopped publishing once those ranges were corrected earlier on this branch — the table was
measured then and not measured again after the ranges moved. Re-confirmed this round: against the old
ranges the same rule still raises exactly 3, at those 2 positions; against the corrected ranges it
raises 1. The headline holds under either set of ranges.
Row 1 is the plainest mirrored removal there is —
sig=deletes= 25, every occurrence of everytwin inside the 2 ranges — and the guard called it divergence. A guard that fails a correct mirrored
deletion is a guard that gets bypassed, which is worse than the hole it closed.
Cause. "The surfaces still agree" was inferred from whatever survived. A mirrored block deletion
and a divergent 2-sided edit leave trees that differ in exactly 1 way: the divergent edit adds
text. Nothing else separates them, so a rule with no memory of what was there before must
eventually match a coincidence — and coincidences are not rare in 26,000 lines of one house style.
The baseline stores 798 look-alike hashes over the 893 lines, on 219 of them, in the tree as it
stands, before anything is edited.
Fix. The baseline now stores, per line and per surface, the look-alike set: the hash of every
other line that already scored
DRIFT_SIMILARITYagainst it when the baseline was written. Acandidate replacement out of that set replaced nothing; it was there first. Conditions 1 and 2 above
now read "carries a new near-identical line". The same filter applies to the drift and one-sided
paths, so those messages name a replacement only when a replacement really appeared.
scripts/check-twin-drift-cases.mjs(new) pins both directions in one table — 16 mutations ofthe 2 surfaces with the verdict the guard must return for each, run against scratch copies. On the
merged branch:
Row 8 is the one planned extraction that cannot be silent, and the guard is right about it.
Deleting the 2 share blocks leaves 5 twins live on exactly 1 surface, because their other copy is in
unrelated code:
index.html:10825,10827are an inline copy ofplay.html'sexplicitLang()(
play.html:11381-11394);index.html:10742,10747twinplay.html:9764,9771, a thumbnail helperoutside the block;
play.html:13075twinsindex.html:7983,8064, the canvas fit bounds. No choiceof ranges fixes that — the extra copies are somewhere else entirely — so whoever does row 8 refreshes
the baseline as part of the move, which is what the guard's own remedy line asks for. The matrix pins
the exact counts, so that extraction cannot start reporting something else unnoticed.
3 of the 9 rows also reported one-sided deletions, and those were TRUE. The ranges this document
published paired regions that are not twins, and the guard is what found it:
index.html:9120-9181.play.html:6516-6633also carries the twins oftrimAudioToWavUrlandextractAudioToWavUrl, so the short range deleted 3 shared lines fromplay.htmlonly: 3 one-sided deletions. Corrected to9120-9195.play.html:6635-6679+6979-7169. The first swallowedtoLocalMediaUrl,seekVideoand MP4CAT's opening 4 lines, whichindex.html:9489-9741does not hold; the secondstarted after
prepClipandrecordClip(play.html:6954-6994), which it does hold: 11one-sided deletions and 2 occurrence drifts. Corrected to
6635-6640+6668-6675+6954-7167.play.html:13281-13519, which swallowed the whole agent-pill popover(
13283-13362) and the model-picker search — codeindex.htmlkeeps at11694-11710and10480: 6 one-sided deletions and 2 occurrence drifts. Corrected to13281+13363-13415+13519.All 3 corrected pairs are in the matrix, and all 3 are silent there.
The remaining exemption, stated exactly. A departure from both surfaces where at most 1
surface grew a NEW look-alike line still passes with a note. That includes a case the guard would
ideally fail: 1 engine edits the line while the other drops it outright. Condition 3 is what keeps
that case out, and it is there for a measured reason. An earlier draft of this fix failed a
single-sided match, and against a genuine extraction control — delete the whole MP4CAT block,
index.html:9210-9487andplay.html:6676-6953, 123 shared lines gone from both surfaces at once —that draft raised 1 false failure out of the 123: the departing banner comment
/* ---- Lossless in-browser mp4 concatenation (Combine node) ----…scored 0.840 against theunrelated banner still at
index.html:9197,/* ---- in-browser video concatenation (the Combine node) ----…, because a run of dashes carriesthe bigram profile. A guard that fails a correct extraction is a guard that gets bypassed.
How many lines does the exemption cover today? Zero — it is a rule about future departures and
nothing departs on a clean tree. Its size on any commit is the number of departures from both
surfaces in that commit where fewer than 2 NEW look-alike replacements appeared.
docs/twin-drift.mdand the guard header state this in the same words. The look-alike sets close the MP4CAT banner case
on their own — the surviving banner
7f8cbc2c5d8093fbis the wholeindex.htmllook-alike set ofthe departing one
48b64f163787aa57, and both that and the 0.840 were re-verified this round — butcondition 3 stays: it is the cheaper guarantee and it holds even when the baseline is stale.
The baseline now stores the text and the look-alike sets, and the digest is required
The divergence test needs the departed line's text, and after a 2-sided edit no copy of it exists in
either file. It also needs to know which look-alikes were already there. Each
linesentry is now<hash> <n index.html> <n play.html> <look-alikes in index.html> <look-alikes in play.html> <the stripped line>, each look-alike field being-or a comma-separated list of hashes.The baseline is 118,273 bytes today, and each field was measured as it landed, on the same 893
lines: 101,630 bytes carrying the stored text but no look-alike fields, then 118,241 once the
796 look-alike hashes were added, then 118,273 now that the merge took those hashes to 798. An
earlier revision of this body also quoted 25,747 for a counts-only format that predates the stored
text. That revision of the file is no longer on hand, so that one figure is withdrawn rather than
restated; the other three are re-verified against the snapshots they were taken from.
The hash doubles as a checksum of the text: the guard re-hashes every stored line and refuses an
entry whose text does not hash to its own hash, so a stored line cannot be hand-swapped for a
friendlier one.
The digest check was
if (baseline.digest && …), so deleting the field switched it off. Inflate1 line's stored counts from
1 1to2 2(the guard reads a drop on both surfaces as a mirroreddeletion and allows it), move
occurrencesIndexHtmlandoccurrencesPlayHtmlto match, deletedigest, and a clean tree exited 0 with that line's ratchet silently raised. Reproduced against thepre-fix guard, then fixed: a missing or malformed
digestis now an error.The ratchet number itself is still derived from
linesand never read from thecount:field.Finding the generated bundle is the load-bearing part
Generated regions are blanked before measuring, so the library is never booked as hand duplication:
the
njs-enginebundle, the probe-writtenPROMPT-CAPStable thatscripts/probe-prompt-caps.mjswrites into both files, the generated i18n maps, and the Runware AIR table. Blanking replaces
each region with the same number of newlines, so every
file:linethe guard prints is real.A lazy
/<script id="njs-engine"[\s\S]*?<\/script>/is not a safe way to find that bundle.play.html:11259holds the export builder's string literal for the same tag:Every
</scriptinsideRUNTIME_JSis written escaped, so a match that starts on that literal doesnot close until the last real
</script>in the file. Re-measured this round: that regex's secondglobal match swallows
play.html:11259-13656— 2,398 lines, 17.6% of the file, all hand-writtenplayer code — and blanking it hides 197 shared lines. That is exactly the gap between the 696 the
first version of this guard reported and the real 893.
The guard matches
<script id="njs-engine" data-hash="…">and re-derives the hash.scripts/gen-js-engine.mjs:165writesdata-hash = sha256(bundle).slice(0,16). A quoted stringcannot forge a body that hashes to its own declared hash. A hand edit anywhere inside the bundle
fails outright — re-measured this round: appending 1 space to
play.html:5267printsthe generated njs-engine block does not match its own data-hash (declared 8d8bef5d2178a9d5, content hashes to 10e987849e217bf2). The content hash depends on the edit; the declared one does not.The bundle is never an exemption (round 3, still true)
The first version exempted any departing line whose text appeared anywhere inside the generated
bundle. That reads a property of the tree today as evidence that something moved, and it is not:
131 of the 893 baseline lines are identical to a line already inside the bundle while both hand
copies are still live, because the library ships MP4CAT, the pricing resolver and the resize
geometry too. On those 131 lines — 15% of the guarded set — a one-sided deletion passed, and, as
round 4 found, so did a 2-sided divergent edit. Bundle presence now only picks the wording of a note
on a departure that is already classified as extraction.
Deliberate refresh, same shape as the CSP golden in
check-deploy-config.mjs:Every figure this PR and
docs/twin-drift.mdquote about the shared set is printed by the guard, sonone of them has to be trusted:
False positives were the main design constraint. Lines of 40 characters or fewer never enter the set
(short lines and lone braces collide between unrelated code). A drift candidate must reach 0.72 Dice
over character bigrams and must not itself be a shared line. Above
MAX_CLASSIFY= 200departures the guard reports totals instead of guessing line by line.
Runtime, re-measured after the merge
Every timing in the guard header, the hook comment and
docs/twin-drift.mdwas measured again onthis machine, on the merge commit. Nothing is carried forward from any earlier revision.
The headline finding of the previous round still stands, and this round is its cleanest evidence: on
this box the load matters more than the code. Every range below came in faster and narrower
than the same commands produced last round, on identical guard code, because the box was quieter.
Every range is the FULL spread of every sample taken, and every range is scoped to the load band it
was measured in — outside that band it says nothing, and none of it is a promise about anyone
else's machine.
scripts/twin-drift-bench.mjs, 30 samples of each case (6 runs of 5), 1-minute load average 3.19to 5.68:
CPU beats wall on the clean row because Node starts on more than 1 thread. Two costs sit outside that
table, both paid on purpose:
all 893 shared lines against every line of both surfaces to build the look-alike sets. Also
verified idempotent: 6 consecutive refreshes left the baseline byte-identical;
guard 16 times over mutated copies of 1.7 MB of HTML.
Withdrawn, not restated. Last round's 0.13-0.57 s / 2.07-8.21 s / 0.98-4.64 s (160 samples at a
load average of 2.6-12.1), its 9.22-28.15 s refresh and its 12.2-29.9 s matrix are withdrawn: they
were measured under a load band this round could not reproduce, and every one has been replaced by a
fresh measurement rather than adjusted. So are the 0.19-0.23 s / 3.29-3.83 s / 1.61-2.12 s of one
earlier revision and the 0.08-0.20 s / 0.98-2.53 s / 0.51-1.55 s and 7.3-10.9 s / 5.4-10.9 s of the
revision after it. An even earlier round measured the 200-departure ceiling at 23-30 s, before
bestMatchgot its candidate index — different code, at a load average of 25 to 34, and notcomparable to anything above. The one figure kept from an earlier round is labelled as such: a
15-case run of the matrix taken while an unrelated Rust build held this box at a load average of
23.5 and 6 GB into swap took 65.9 s — same verdicts, roughly 5 times today's slowest sample.
It is kept only to make the point that these numbers measure the load band, not the code.
Reproduce any row:
node scripts/twin-drift-bench.mjs 5 # 5 samples of each case, prints the load average too.githooks/pre-commit: the guard was wired in, but the hook skipped ittouches_twindriftwas defined but was the onlytouches_*variable missing from the early-exitcondition. A commit that staged only
scripts/twin-drift-baseline.jsontherefore returned beforeany check ran — the one file to edit to defeat the ratchet was the one file that skipped the check.
Both twin-drift variables are in the condition now.
#372 landed
touches_llmsfulland #373 landedtouches_launchfactson that same line. All 4 clausesare unioned in, and each was proved to fire by staging one file for it in a throwaway
git initrepo whose
scripts/check-*.mjsare stubs that print their own name:scripts/check-twin-drift.mjscheck-twin-drift,check-twin-drift-casesscripts/check-twin-drift-cases.mjscheck-twin-drift-casesscripts/twin-drift-baseline.jsoncheck-twin-drift,check-twin-drift-casesllms-full.txtcheck-llms-full(#372)growth/show-hn-draft.mdcheck-launch-facts(#373)unrelated.txtAgainst
origin/main's hook all 3 twin-drift files early-exit with no check at all, which is thehole this clause closes.
Check 43 runs the guard when
index.html,play.html, the guard or the baseline is staged.Check 44 runs the sandbox matrix when the RULES change — the guard, the baseline or the matrix
itself — and not on every surface edit, because it costs 10.38-13.13 s against the guard's
0.14-0.18 s. They are 43 and 44, not 41 and 42, because main's
check-launch-factsandcheck-llms-fulltook those numbers first.scripts/twin-drift-worklist.mjs(new) anddocs/twin-drift.mdThe work-list table was hand arithmetic that mixed 2 incompatible definitions: the prose defined
"Baseline after" as "893 minus the lines whose every occurrence, on both surfaces, sits inside the
block", and the column subtracted the
signumber instead. The 2 rules disagree wherever a twin hasa copy outside the quoted ranges — rows 3, 7 and 8 — so every cell from row 3 down was wrong.
One definition now, and a script owns the column.
node scripts/twin-drift-worklist.mjsprints thetable below verbatim, from the same shared set the guard pins, and checks itself against the
baseline. The merge did not move a single cell:
maskToSourceencodeWavMono+mediaFetchErrorRows 3 and 7 moved earlier on this branch, when their ranges were corrected (see round 5 above).
sig— shared lines with a hit inside the row'sindex.htmlrange and a hit inside itsplay.htmlrange. How much of the set the block touches; not a deletion count.deletes— shared lines whose every occurrence, on both surfaces, sits inside the ranges. Onlythese leave the set when the block goes.
new—deletesminus everything the rows above already deleted.Baseline after— 893minus the running union of
new.No row overlaps another. The rows where
siganddeletesdisagree are 3 (26/25), 7 (68/66) and8 (140/136), and every line in the gap is named in
docs/twin-drift.md. Two of them:const AC = window.AudioContext || window.webkitAudioContext;atindex.html:9162,9577,9639andplay.html:6556,6584,7023,7074— rows 3 and 7 each hold some of those copies and neither holds all,so neither row deletes it — and
const vid = document.createElement("video");atindex.html:6368,9511/play.html:6963,7608, which is the rest of row 7's gap.Rows 1 to 6 sit behind 1 architecture decision, and answering it frees 255 of the 893 lines
(893 down to 638).
While recomputing this I found a wrong range: the work list quoted
play.html:7296-7309formaskToSource. That range isaudioInputPart.maskToSourceisplay.html:7320-7338, and with thewrong range the row measures
sig0. Block 6's ranges are now listed as explicit surface-to-surfacepairs so the mistake cannot be repeated.
The document is otherwise the extraction map: 538 of the 893 lines sit in 87 contiguous blocks of 4
lines or more (
node scripts/twin-drift-worklist.mjsprints those 3 figures too), the document ranks16 of those 87, and each gets a verdict — covered by the nanoodle-js bundle path, safely
extractable through
gen-js-engine.mjs, or genuinely surface-specific.index.htmlplay.htmlnanoodle-js/src/share.mjsdecodes share links; it has no packer.nanoodle-js/src/mp4cat.mjsis already in the bundle, butbrowser.mjsdoes not re-exportMP4CAT.nanoodle-js/src/estimate.mjs, exported fromindex.mjsonly, so it is absent from the browser bundle.browser.mjsalready exportsresizePlanandresizeCropImage.The "What this map does NOT cover" section is still there, because the map is not an exhaustive
partition:
like the ranked ones, but this document gives them no verdict.
sig.seekVideo(
index.html:9106-9115,play.html:6658-6667) is the known example.og:title,og:description,og:url,twitter:titleandtwitter:descriptionare deliberatelydifferent per page. They were never in the shared set, so they cannot leave it, and no whole-file
twin guard can cover them. Nothing else covers them either.
og:image, the twitter card andimage tags, the icons and the manifest link are byte-identical on both pages (column 0 on both,
measured), so those are in the shared set and a 1-page change to any of them fails.
seekVideoshows the trade:10 lines, identical on both surfaces after the strip, and only 2 of them longer than 40 characters.
What I did NOT do, and why
I did not delete any block. Both surfaces run the bundle behind a flag.
njsOn()reads?engine=js,?engine=playandlocalStorage.njs_engine.index.html:8253appendsvendor/njs-engine.jsasynchronously and only when the flag is on;index.html:8234-8239statesthat until it loads, the built-in runners execute. So each built-in copy is a live fallback path, not
dead code. Deleting the MP4CAT hand copies today breaks
?engine=playand every run that startsbefore the bundle arrives.
Extraction needs 1 architecture decision first: does
index.htmlload the bundle unconditionally,and does the flag-off fallback survive? The guard ships first, so the number is measured and cannot
grow while the decision is pending.
How I verified it
Raw measurement, independent of the guard, over both files with no blanking at all: 1,009 distinct
shared lines, 1,079 occurrences in
index.html, 1,267 inplay.html. The guard reports 893because 116 of those lines live inside generated regions. Re-measured after the merge, as was every
other figure in this body: nothing here is carried forward unmeasured.
Scenario matrix. The rows marked committed are now
scripts/check-twin-drift-cases.mjs, whichruns them on every commit that changes the guard, the baseline or the matrix; the rest were re-run by
hand against this branch head:
✓ 893 shared lines, unchangedusd_balance→usdBalanceatplay.html:12717TWIN DRIFT, both positions namedindex.html:8147insteadTWIN DRIFT, both positions namedplay.html:7349(if(scale>1) scale = 1; // never upscale)ONE-SIDED DELETION … still live at index.html:6958play.html:6869, a line the bundle also carriesONE-SIDED DELETION … still live at index.html:9403durIDXinindex.html,durPLAYinplay.htmlTWIN DIVERGENCE, both replacements named (exited 0 before this round)index.html:5664(pricingpickByRes)TWIN DRIFT (occurrence count)seekVideobanner comment atindex.html:9104copied intoplay.htmlduplication went UP: 893 → 894 shared lines (1 entered, 0 left)index.html1 shared line(s) gained a copydivergence 1, drift 1, oneSided 1— this is why the ceiling names 36, not 24 (committed)scripts/twin-drift-bench.mjs)njs-enginebundlebaseline.counthand-raised to 1200"count" says 1200, but its own "lines" array says 893baseline.countdeleted, then a line added to both surfacesduplication went UP: 893 → 894baseline.digestDELETED + 1 line's counts inflated1 1→2 2baseline has no usable "digest" field(exited 0 before this round)the stored text does not hash to the stored hashindex.htmlonlyRow 8 was checked independently of the matrix as well, by reading occurrence positions straight out
of both files rather than by asking the guard: after deleting row 8's 2 documented ranges, exactly
5 twins are left live on exactly 1 surface, and they are the same 5 the guard names, at their new
positions. Row 8's failure is real, not a false divergence. Across all 9 rows the divergence count
is 0.
The 3 corrected ranges were re-checked against their OLD values this round, and each reproduced the
count this body claims: row 3 old
index.html:9120-9181gives 3 one-sided deletions, row 7 oldplay.html:6635-6679+6979-7169gives 11 one-sided deletions and 2 occurrence drifts, row 9 oldplay.html:13281-13519gives 6 one-sided deletions and 2 occurrence drifts.Full guard suite, re-run on the merge commit: 55 of 55
check-*.mjschecks pass, 0 fail (53 onorigin/main, pluscheck-twin-drift.mjsandcheck-twin-drift-cases.mjs), withcheck-js-parity.mjsconfirmed not to have skipped.scripts/twin-drift-worklist.mjsandscripts/twin-drift-bench.mjsare document-support scripts,not guards, so they are not in that count and the hook does not run them.
The pre-commit hook ran on every commit here with no
--no-verify, andsh -n .githooks/pre-commitpasses.
lora-models.jsonis restored to its committed content and is not part of any commit on thisbranch — a full-suite run bumps its
generateddate, so it was restored after every run and kept outof every commit.
Commit messages on this branch carry figures that later measurement replaced, and a commit message on
a branch under review cannot be corrected without a force push. This body is the authority for every
number, and the corrections are here:
and 87 blocks, of which 16 are ranked.
round. Every line number in every commit message before the merge commit also predates
origin/mainmoving both surfaces. The "Runtime" table above supersedes the timings, and every linenumber in this body is a post-merge one.