Skip to content

Guard the index.html ↔ play.html twin: a one-sided edit now fails the commit - #375

Merged
mikkel merged 12 commits into
mainfrom
guard/twin-drift
Jul 30, 2026
Merged

Guard the index.html ↔ play.html twin: a one-sided edit now fails the commit#375
mikkel merged 12 commits into
mainfrom
guard/twin-drift

Conversation

@mikkel

@mikkel mikkel commented Jul 29, 2026

Copy link
Copy Markdown
Member

What breaks today

index.html (editor) and play.html (app player + the single-file export) share 893 distinct
hand-maintained lines longer than 40 characters
. They occur 958 times in index.html and 1,011
times in play.html. Every engine change is a 2-surface edit.

"Identical" here means identical after String.prototype.trim(). play.html nests most of the
shared code 1 or 2 levels deeper than index.html, so only 415 of the 893 lines are byte-identical
in the files; the other 478 match only after the leading and trailing whitespace is stripped
.
seekVideo is the visible case: index.html:9106 starts at column 0, play.html:6658 at 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.md said "byte-identical" throughout, which was wrong about more than half
the set. TWIN_DRIFT_STATS=1 now prints the split.

origin/main carries 53 scripts/check-*.mjs guards. 24 of them name both index.html and
play.html, but each targeted twin guard pins 1 named function: check-resize-plan lifts
resizePlan, check-pricing lifts the pricing resolver, check-combine runs MP4CAT over fixtures,
and so on. check-js-parity and check-njs-*-delegation pin each surface against the sibling
nanoodle-js bundle. Nothing compared index.html against play.html as 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.html and play.html, and #374 and #376 both changed both surfaces. origin/main is now
merged 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 in index.html, at 9046 — the DEMO_CTX override
that lets a signed-out sample reach a video result. play.html has no DEMO_CTX, so that third copy
is deliberate and editor-only:

✗ duplication went UP: 1 shared line(s) gained a copy.
      1. index.html 1→2, play.html 1→1  async genVideo(model, prompt, opts, imageDataUrl, onProgress){

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.html occurrences
957 → 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:9022 if(!r.ok) throw new Error(String(r.status)); and index.html:9025's FileReader
line — 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.html down by up to
111 lines and #374 pushed play.html down by 19. Each range was re-derived and then checked byte
for 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
:

✓ clean tree                                     exit 0
✗ extract row 1 … ✗ extract row 9                (all 9 extraction rows wrong)
✗ check-twin-drift-cases: 9 of 16 case(s) returned the wrong verdict

docs/twin-drift.md and scripts/twin-drift-worklist.mjs quote the same ranges, so all 216
file:line references 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:

  • Drift — a baseline line left the shared set because 1 surface edited it and the other did not.
    The keeper still carries the original; the mover carries a near-identical variant. The message
    names both file:line positions and prints the 2 versions of the line.
  • One-sided deletion — a baseline line is gone from 1 surface, nothing near-identical replaced
    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.
  • Occurrence drift — a shared line lost a copy on 1 surface only. 75 of the 893 lines appear more
    than once inside a surface (29 in index.html, 65 in play.html), so presence alone is not
    enough: editing 1 of 2 identical copies leaves the hash present.
  • Divergence — a baseline line left both surfaces and each surface now carries its own
    new, different replacement of it. See rounds 4 and 5 below.
  • Growth — the distinct shared-line count went up, or a shared line gained a copy.

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_CLASSIFY ceiling of 200. Each failure list names its
first 12 lines and reports the rest as a count.
A departure lands under 1 of 3 headings —
TWIN DIVERGENCE, TWIN DRIFT or ONE-SIDED DELETION — and all 3 can fire in the same run, so at
the 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:

  • "200 departures still names every line." False.
  • The 12 / 188 split that replaced it. That is not a fact about the guard. How the 200 divide
    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.
  • "at most 24 named." Also wrong, here and in docs/twin-drift.md. It assumed only 2 of the 3
    headings could fire at once. All 3 fire together — edit index.html:9403 and play.html:6869 to
    different text, rename play.html:12717 on 1 surface, and delete play.html:7349 from 1
    surface — 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:9403 to a+s.durIDX and play.html:6869 to a+s.durPLAY. The 2 engines now
genuinely differ, which is exactly the drift this guard exists to catch. Before this round:

  note: 1 left both surfaces and are carried by the generated bundle — the ratchet allows this…
✓ twin drift: 892 shared lines … exit 0

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:

  1. index.html carries a line near-identical to the departed line,
  2. play.html carries one too,
  3. those 2 replacements are near-identical to each other.

"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:

✗ TWIN DIVERGENCE — 1 line(s) left BOTH surfaces, but the surfaces no longer AGREE…
      1. both surfaces dropped:  const totalTicks = t.samples.reduce((a,s)=>a+s.dur, 0);
         index.html:9403  const totalTicks = t.samples.reduce((a,s)=>a+s.durIDX, 0);
         play.html:6869   const totalTicks = t.samples.reduce((a,s)=>a+s.durPLAY, 0);          exit 1

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.md itself plans, from BOTH surfaces, then failed 4 of its own 9
planned extractions
.

Re-measured after the origin/main merge, by running the current guard with its look-alike memory
switched 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:

Row Verdict The "replacements" it named (positions in the files as committed)
1 Resize and crop geometry exit 1, 2 false divergences index.html:7130 / play.html:9763, 2 unrelated canvas lines that had been sitting there all along
3 encodeWavMono + mediaFetchError exit 1, 1 false divergence index.html:9006 / play.html:8812, an unrelated FileReader pair
7 Local media recorder path exit 1, 1 false divergence index.html:6651 / play.html:10810, an unrelated createElement pair
8 Share packer, card and shorteners exit 1, 2 false divergences the same canvas pair as row 1

The 4 counts — 2, 1, 1, 2 — are unchanged by the merge, and so are the lines behind them. #376 added
a second FileReader line to index.html (9025) that also looks like row 3's departing line, but
row 3 still raises exactly 1, because bestMatch returns one candidate rather than all of them.

Row 7 is corrected here. Earlier revisions of this body and of docs/twin-drift.md gave row 7
3 false divergences at index.html:6374 / play.html:6965, an onerror pair. That figure is
real, but it belongs to row 7's old ranges (play.html:6635-6679 + 6979-7169), which the work
list 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 every
twin 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_SIMILARITY against it when the baseline was written. A
candidate 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 of
the 2 surfaces with the verdict the guard must return for each, run against scratch copies. On the
merged branch:

✓ clean tree                                     exit 0
✓ extract row 1 — resize and crop geometry       exit 0
✓ extract row 2 — maskToSource                   exit 0
✓ extract row 3 — encodeWavMono + mediaFetchError exit 0
✓ extract row 4 — prompt-cap helpers             exit 0
✓ extract row 5 — pricing resolver               exit 0
✓ extract row 6 — MP4CAT                         exit 0
✓ extract row 7 — local media recorder path      exit 0
✓ extract row 8 — share packer, card and shorteners exit 1, oneSided 5, occurrence 1
✓ extract row 9 — share-menu wiring              exit 0
✓ 2-sided DIVERGENT edit                         exit 1, divergence 1
✓ 1-sided edit                                   exit 1, drift 1
✓ 1-sided deletion                               exit 1, oneSided 1
✓ correctly MIRRORED edit                        exit 0
✓ all 3 departure headings at once               exit 1, divergence 1, drift 1, oneSided 1
✓ new duplication                                exit 1, growth 1
✓ twin-drift cases: 16 verdicts, all as expected

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,10827 are an inline copy of play.html's explicitLang()
(play.html:11381-11394); index.html:10742,10747 twin play.html:9764,9771, a thumbnail helper
outside the block; play.html:13075 twins index.html:7983,8064, the canvas fit bounds. No choice
of 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:

  • Row 3 quoted index.html:9120-9181. play.html:6516-6633 also carries the twins of
    trimAudioToWavUrl and extractAudioToWavUrl, so the short range deleted 3 shared lines from
    play.html only: 3 one-sided deletions. Corrected to 9120-9195.
  • Row 7 quoted play.html:6635-6679 + 6979-7169. The first swallowed toLocalMediaUrl,
    seekVideo and MP4CAT's opening 4 lines, which index.html:9489-9741 does not hold; the second
    started after prepClip and recordClip (play.html:6954-6994), which it does hold: 11
    one-sided deletions and 2 occurrence drifts
    . Corrected to 6635-6640 + 6668-6675 +
    6954-7167.
  • Row 9 quoted play.html:13281-13519, which swallowed the whole agent-pill popover
    (13283-13362) and the model-picker search — code index.html keeps at 11694-11710 and
    10480: 6 one-sided deletions and 2 occurrence drifts. Corrected to 13281 +
    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-9487 and play.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 the
unrelated banner still at index.html:9197,
/* ---- in-browser video concatenation (the Combine node) ----…, because a run of dashes carries
the 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.md
and the guard header state this in the same words. The look-alike sets close the MP4CAT banner case
on their own — the surviving banner 7f8cbc2c5d8093fb is the whole index.html look-alike set of
the departing one 48b64f163787aa57, and both that and the 0.840 were re-verified this round — but
condition 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 lines entry 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. Inflate
1 line's stored counts from 1 1 to 2 2 (the guard reads a drop on both surfaces as a mirrored
deletion and allows it), move occurrencesIndexHtml and occurrencesPlayHtml to match, delete
digest, and a clean tree exited 0 with that line's ratchet silently raised. Reproduced against the
pre-fix guard, then fixed: a missing or malformed digest is now an error.

The ratchet number itself is still derived from lines and never read from the count: 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-engine bundle, the probe-written PROMPT-CAPS table that scripts/probe-prompt-caps.mjs
writes 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:line the guard prints is real.

A lazy /<script id="njs-engine"[\s\S]*?<\/script>/ is not a safe way to find that bundle.
play.html:11259 holds the export builder's string literal for the same tag:

const engTag = engText ? '<script id="njs-engine">\n' + engText.replace(/<\/script/gi, "<\\/script") 

Every </script inside RUNTIME_JS is written escaped, so a match that starts on that literal does
not close until the last real </script> in the file. Re-measured this round: that regex's second
global match swallows play.html:11259-136562,398 lines, 17.6% of the file, all hand-written
player 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:165 writes data-hash = sha256(bundle).slice(0,16). A quoted string
cannot 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:5267 prints
the 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:

TWIN_DRIFT_UPDATE=1 node scripts/check-twin-drift.mjs

Every figure this PR and docs/twin-drift.md quote about the shared set is printed by the guard, so
none of them has to be trusted:

TWIN_DRIFT_STATS=1 node scripts/check-twin-drift.mjs
  stats: 893 distinct shared lines, 958 occurrences in index.html, 1011 in play.html
  stats: 75 appear more than once inside a surface (29 in index.html, 65 in play.html)
  stats: 415 are identical WITH their indentation; the other 478
         match only after the leading/trailing whitespace strip, because play.html nests the
         same code deeper
  stats: 131 are ALSO carried by the generated njs-engine bundle while both hand
         copies stay live — that is why bundle text presence is not proof of extraction

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 = 200
departures 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.md was measured again on
this 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.19
to 5.68
:

Case Wall clock CPU time
Clean tree, nothing departs 0.14-0.18 s 0.16-0.20 s
200 departures, all gone from BOTH surfaces 1.99-3.19 s 2.08-3.39 s
200 departures, all gone from 1 surface 0.91-1.64 s 0.94-1.72 s

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:

  • the baseline refresh, 9.50-11.83 s wall (6 samples, load average 3.63-4.17), because it scores
    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;
  • the sandbox matrix, 10.38-13.13 s wall (6 samples, load average 3.18-5.19), because it runs the
    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
bestMatch got its candidate index — different code, at a load average of 25 to 34, and not
comparable 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 it

touches_twindrift was defined but was the only touches_* variable missing from the early-exit
condition
. A commit that staged only scripts/twin-drift-baseline.json therefore returned before
any 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_llmsfull and #373 landed touches_launchfacts on that same line. All 4 clauses
are unioned in, and each was proved to fire by staging one file for it in a throwaway git init
repo whose scripts/check-*.mjs are stubs that print their own name:

Staged, on its own Checks that ran
scripts/check-twin-drift.mjs check-twin-drift, check-twin-drift-cases
scripts/check-twin-drift-cases.mjs check-twin-drift-cases
scripts/twin-drift-baseline.json check-twin-drift, check-twin-drift-cases
llms-full.txt check-llms-full (#372)
growth/show-hn-draft.md check-launch-facts (#373)
unrelated.txt none — the chain still short-circuits, exit 0

Against origin/main's hook all 3 twin-drift files early-exit with no check at all, which is the
hole 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-facts and
check-llms-full took those numbers first.

scripts/twin-drift-worklist.mjs (new) and docs/twin-drift.md

The 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 sig number instead. The 2 rules disagree wherever a twin has
a 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.mjs prints the
table below verbatim, from the same shared set the guard pins, and checks itself against the
baseline. The merge did not move a single cell:

# Block sig deletes new Baseline after
1 Resize and crop geometry 25 25 25 868
2 maskToSource 5 5 5 863
3 encodeWavMono + mediaFetchError 26 25 25 838
4 Prompt-cap helpers 9 9 9 829
5 Pricing resolver 68 68 68 761
6 MP4CAT 123 123 123 638
7 Local media recorder path 68 66 66 572
8 Share packer, card and shorteners 140 136 136 436
9 Share-menu wiring 24 24 24 412

Rows 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's index.html range and a hit inside its
    play.html range. 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. Only
    these leave the set when the block goes.
  • newdeletes minus everything the rows above already deleted. Baseline after — 893
    minus the running union of new.

No row overlaps another. The rows where sig and deletes disagree are 3 (26/25), 7 (68/66) and
8 (140/136), and every line in the gap is named in docs/twin-drift.md. Two of them:
const AC = window.AudioContext || window.webkitAudioContext; at index.html:9162,9577,9639 and
play.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"); at
index.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-7309 for
maskToSource. That range is audioInputPart. maskToSource is play.html:7320-7338, and with the
wrong range the row measures sig 0. Block 6's ranges are now listed as explicit surface-to-surface
pairs 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.mjs prints those 3 figures too), the document ranks
16 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.

Block sig index.html play.html Verdict
Share packer, card and shorteners 140 10725-11041 12823-13142 Extractable, and nothing covers it. nanoodle-js/src/share.mjs decodes share links; it has no packer.
MP4CAT mp4 remux 123 9210-9487 6676-6953 Extractable. nanoodle-js/src/mp4cat.mjs is already in the bundle, but browser.mjs does not re-export MP4CAT.
NanoGPT client fallback 81 8576-8916 6179-6499 Already covered by the delegation path. Do not extract again.
Pricing resolver 68 5571-5715 5929-6058 Extractable. A third copy already exists in nanoodle-js/src/estimate.mjs, exported from index.mjs only, so it is absent from the browser bundle.
Local media, MediaRecorder fallback 68 9489-9741 6635-6640, 6668-6675, 6954-7167 Needs a new browser-only library module. The library uses ffmpeg for this case.
Resize and crop geometry 25 6928-6988 7342-7377, 8822-8836 Extractable. browser.mjs already exports resizePlan and resizeCropImage.
Head metadata and share chrome 21 8-31, 1108-1131 8-31, 470-509 Genuinely surface-specific. 2 separate documents with separate CSP paths.

The "What this map does NOT cover" section is still there, because the map is not an exhaustive
partition:

  • 16 of the 87 blocks are ranked. The other 71 blocks and all 355 scattered lines are guarded exactly
    like the ranked ones, but this document gives them no verdict.
  • A twin can fall between 2 block ranges and be in no block's sig. seekVideo
    (index.html:9106-9115, play.html:6658-6667) is the known example.
  • og:title, og:description, og:url, twitter:title and twitter:description are deliberately
    different 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 and
    image 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.
  • Lines of 40 characters or fewer are outside the shared set by design. seekVideo shows 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=play and localStorage.njs_engine. index.html:8253 appends
vendor/njs-engine.js asynchronously and only when the flag is on; index.html:8234-8239 states
that 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=play and every run that starts
before the bundle arrives.

Extraction needs 1 architecture decision first: does index.html load 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 in play.html. The guard reports 893
because 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, which
runs them on every commit that changes the guard, the baseline or the matrix; the rest were re-run by
hand against this branch head:

Scenario Expected Result
Unmodified tree pass exit 0, ✓ 893 shared lines, unchanged
One-sided rename usd_balanceusdBalance at play.html:12717 fail exit 1, TWIN DRIFT, both positions named
The same rename at index.html:8147 instead fail exit 1, TWIN DRIFT, both positions named
One-sided deletion of play.html:7349 (if(scale>1) scale = 1; // never upscale) fail exit 1, ONE-SIDED DELETION … still live at index.html:6958
One-sided deletion of play.html:6869, a line the bundle also carries fail exit 1, ONE-SIDED DELETION … still live at index.html:9403
That line deleted from both surfaces pass exit 0, extraction note, 893 → 892
2-sided DIVERGENT edit: durIDX in index.html, durPLAY in play.html fail exit 1, TWIN DIVERGENCE, both replacements named (exited 0 before this round)
Edit 1 of the 2 copies of index.html:5664 (pricing pickByRes) fail exit 1, TWIN DRIFT (occurrence count)
The seekVideo banner comment at index.html:9104 copied into play.html fail exit 1, duplication went UP: 893 → 894 shared lines (1 entered, 0 left)
A third copy of an already-shared line inside index.html fail exit 1, 1 shared line(s) gained a copy
Mirrored edit, both surfaces changed identically pass exit 0, note that the count held
MP4CAT block deleted from both surfaces (123 twins depart) pass exit 0, 893 → 770, note only — no false divergence (committed, row 6)
All 9 planned extractions deleted from both surfaces 8 pass, row 8 reports 5 true one-sided deletions as expected on all 9; 0 divergences on every row (committed)
A divergent edit, a 1-sided edit and a 1-sided deletion in ONE tree fail, all 3 headings exit 1, divergence 1, drift 1, oneSided 1 — this is why the ceiling names 36, not 24 (committed)
200 baseline lines deleted from both surfaces, every occurrence pass exit 0, note only (scripts/twin-drift-bench.mjs)
Hand edit inside the generated njs-engine bundle fail exit 1, names the declared and the actual hash
baseline.count hand-raised to 1200 fail exit 1, "count" says 1200, but its own "lines" array says 893
baseline.count deleted, then a line added to both surfaces fail exit 1, duplication went UP: 893 → 894
baseline.digest DELETED + 1 line's counts inflated 1 12 2 fail exit 1, baseline has no usable "digest" field (exited 0 before this round)
A stored line's text swapped while its hash is left alone fail exit 1, the stored text does not hash to the stored hash
New unrelated line added to index.html only pass exit 0, no false positive
All 25 matrix ranges left at their pre-merge values fail exit 1, 9 of 16 cases wrong — the re-derivation is load-bearing

Row 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-9181 gives 3 one-sided deletions, row 7 old
play.html:6635-6679 + 6979-7169 gives 11 one-sided deletions and 2 occurrence drifts, row 9 old
play.html:13281-13519 gives 6 one-sided deletions and 2 occurrence drifts.

Full guard suite, re-run on the merge commit: 55 of 55 check-*.mjs checks pass, 0 fail (53 on
origin/main, plus check-twin-drift.mjs and check-twin-drift-cases.mjs), with
check-js-parity.mjs confirmed not to have skipped.

for f in scripts/check-*.mjs; do node "$f" >/dev/null 2>&1 || echo "FAIL $f"; done

scripts/twin-drift-worklist.mjs and scripts/twin-drift-bench.mjs are 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, and sh -n .githooks/pre-commit
passes. lora-models.json is restored to its committed content and is not part of any commit on this
branch — a full-suite run bumps its generated date, so it was restored after every run and kept out
of 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:

  • 7a28dbb states 696 shared lines and "90 duplicated blocks". The real figures are 893 lines
    and 87 blocks
    , of which 16 are ranked.
  • 4929e35 and eb26e6a quote the timing ranges as they stood before the post-merge measurement
    round. Every line number in every commit message before the merge commit also predates
    origin/main moving both surfaces. The "Runtime" table above supersedes the timings, and every line
    number in this body is a post-merge one.

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

mikkel and others added 5 commits July 28, 2026 22:34
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>
mikkel and others added 2 commits July 29, 2026 01:32
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>
@mikkel
mikkel force-pushed the guard/twin-drift branch from efd9d14 to ea3e4ea Compare July 29, 2026 09:00
mikkel and others added 4 commits July 29, 2026 11:12
…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
mikkel merged commit 43fb520 into main Jul 30, 2026
3 checks passed
@mikkel
mikkel deleted the guard/twin-drift branch July 30, 2026 01:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant