Skip to content

Add REDLINE THEATER: an MCP-driven redline demo that proves itself - #615

Open
JSv4 wants to merge 50 commits into
mainfrom
claude/redline-demo-mcp-lcvm85
Open

Add REDLINE THEATER: an MCP-driven redline demo that proves itself#615
JSv4 wants to merge 50 commits into
mainfrom
claude/redline-demo-mcp-lcvm85

Conversation

@JSv4

@JSv4 JSv4 commented Aug 28, 2026

Copy link
Copy Markdown
Owner

A third demo page at docs/demo/redline.html, with two modes. The Arcade painted frames into one paragraph through raw.replaceXml. Golf made the editing surface the game and the comparison engine the referee. This one puts the agent protocol on screen — and then measures what the comparison engine actually costs.

Mode 1 — the negotiation

REDLINE THEATER

Three counsel negotiate a Master Services Agreement. Every edit that lands is dispatched from a real JSON-RPC 2.0 tools/call frame — the shape docxodus-mcp accepts over stdio — streamed on a wire console beside the document with its measured duration.

Nothing in this mode renders a diff. The session records in render_inline mode, so each call writes native w:ins/w:del into the live package and the editor repaints only the block that changed. What you watch is the file that downloads. Because the three counsel are three values of the session's revision author, the markup is genuinely per-reviewer — the panel's breakdown is read back out of listRevisions(), not tracked separately.

You can see the negotiation layered in the markup: Net 60 struck to Net 30 struck to Net 45; the liability cap struck from trailing fees to 2x to 1.5x. That history is the document, not an annotation on it.

The finale proves the result rather than asserting it. proveRedlineReversibility rebuilds two packages from the redline; docxDiffGetRevisions independently answers the content question. Accept-all reaches the negotiated final, and reject-all restores the baseline with zero content differences. Reject-path package divergences are classified against a closed, written-out set of parts that review comments legitimately explain (comments.xml and the content-types, rels, styles and settings entries that come with it — rejecting cannot remove a comment, because a comment is not a tracked change). Anything outside that set fails the verdict, which is how the note-insertion defect below was caught.

One call is expected to be refused. Converting the carve-outs to a Word-native (a)(b)(c) list means writing w:numPr, which has no reversible tracked-change encoding — so the engine declines rather than writing a mark reject-all could not undo. The wire shows that in amber. An engine that fails closed is the same guarantee the proof verifies, enforced up front.

Mode 2 — diff stress

The first draft of this PR called itself REDLINE THEATER while using the comparison engine exactly once, as a verification cross-check; docxDiffCompare was never called at all. That is a fair thing to be pulled up on — "redline" means two things in this codebase and only one was on screen.

DIFF STRESS

So the page now has a second mode that does the other thing: recompute the redline from scratch after every single edit, baseline against current, and time it. Each frame appends a clause, so the input grows under the engine rather than sitting at one fixed size that would measure nothing.

Three selectable depths, because "how fast is the diff engine" has three honest answers. Measured in-browser on the demo's own agreement, each row a median of three full 40-frame runs:

depth engine calls rate p50 vs. recording
revisions docxDiffGetRevisions ~19/s 52 ms 33×
redline docxDiffCompareProducts → package + revisions ~13/s 74 ms 45×
full + HTML the above, then convertDocxToHtml ~7/s 141 ms 78×

A mutation through the same MCP endpoint costs ~2 ms. So a redline-per-edit loop lives between roughly 7 and 19 frames per second on a 3 KB document. "Can we animate a diff per edit at 60fps" is still no — but it is a much closer no than when this PR opened, where the same loop ran at 2 to 6. The gap is still the whole argument for recording a redline when you are the one editing and computing one when somebody hands you a document they changed: both emit the same native markup, and the meter shows what each costs to get there. The panel reports the ratio it just measured, not the numbers in this table.

The redline depth uses docxDiffCompareProducts rather than docxDiffCompare followed by docxDiffGetRevisions, because one memoized alignment pass yielding both products measured 51 ms against 85 ms for the two calls separately — about a third saved, which is what that API is for.

These numbers have moved five times, which is the point — this branch has merged main repeatedly while sitting green, and every figure shifted without a line of the panel changing. Four movements have a cause; one does not, and separating those is what the list is for:

Those attributions are what the shape of each movement suggests; the panel measures the total and the commit messages are the authority on cause. Where the shape fits nothing in the diff, the honest label is "no cause found". (#629 is a standing example of the same discipline: it landed with #627 but its snapshot reuse is across comparisons, and this loop makes one comparison per frame, so it is not credited.)

benchmarks/docxdiff-stress/FINDINGS.md asks this question far more rigorously — 147 KB certificate of incorporation, stage attribution, allocation figures, medians of nine runs. That harness is the authority on engine performance; this mode is the watchable version on a small document, and the README says so.

A measurement trap worth flagging to a reviewer, since the branch fell into it once: the stress p50 is taken while the document is growing under the loop, so it depends on how far the run got, and one container spreads 5–20% on fixed inputs anyway. It is therefore unfit for comparing engine builds, however much it looks like a benchmark. After #623 the stress readout appeared to show a third-sized regression while a controlled fixed-input measurement showed none — the controlled one was right. Both diff-stress.js and the README say so, and every "did this move?" question above was answered the controlled way (fixed baseline/current, medians of nine, three reps) before the table was touched.

Other details worth knowing: the meter resets per run (carrying frames across a depth change reports a p50 describing neither pipeline — the first draft did exactly that and reported an identical 206 ms for all three depths, which is how the bug was found); fps comes from the median frame rather than the mean, so one hitch does not halve an otherwise-steady reading; leaving the stress pane stops the loop so it cannot burn the engine behind hidden UI; and the appended clauses are distinct real sentences, since repeating one paragraph would be an unrealistically easy alignment problem that flatters the numbers.

How the frames can honestly be called real

tools/mcp-server is a .NET process on stdio, and a browser cannot subprocess it. So docs/demo/mcp-wire.js reimplements the server's front half — envelope parsing, the (tool, action) routing table, the content[].text + isError result shape, and the convention that a business-level failure is a tool result rather than a JSON-RPC error — over DocxSession via WASM instead of over a pipe. The frames are the frames; the transport under them differs.

That correspondence is checked rather than claimed. docs/demo/tools/redline-theater.test.mjs parses the shipped ToolCatalog.cs and fails if any (tool, action) pair, search mode, set_mode value, or argument the script puts on the wire is absent from the real catalog. It earned its keep immediately, catching two mismatches:

  • a batch argument I had called atomic, which the catalog calls mode with an enum;
  • set_mode wire names (accept, render_inline) passed straight to setTrackedChanges, which takes the TrackedChangeMode enum — the mode silently did not switch, so the "clean" baseline was being authored with recording on.

Deliberately not reimplemented: the document store and its containment checks (a tab has no filesystem, so sessions are opened by the host page rather than by docxodus_open with a path), the transaction/retry journal, the delivery-bundle host, and the MCP Apps UI extension.

A defect found while building this, and now fixed upstream

insertFootnote/insertEndnote under render_inline recording wrote the citation and the note definition as ordinary content, so reject-all removed neither: the note's text survived a "full" rejection and the redline was not reversible. Unlike ApplyListFormatRange under the same mode, they did not refuse either. The inconsistency was the defect more than the irreversibility — a silent irreversible mark is worse than a loud refusal, because the redline looks complete until somebody rejects it.

Filed as #614; fixed by #625, then completed by #638. #625 made the citation the reversible unit but left the definition unmarked, compensating with an unconditional prune on the stateless reject — which was ambiguous, because a w:ins citation beside an unmarked definition is also exactly what a comparison emits when the counterpart merely cites a husk the baseline already owned, and that prune ate the husk. #638 marks the definition's content too and guards the prune on it being emptied as well as orphaned.

Act II footnotes the negotiated cap as a result, so the proof exercises that path on every page load. The guard test that had pinned the paragraph workaround is inverted rather than deleted — the script must still contain exactly one note-insertion beat — and the browser assertion checks both halves: the citation run inside a w:ins, and the definition Act II authored carrying insertion markup, identified by its text because the part also holds Word's two reserved separator notes, which carry no markup and must not. The tracked_changes.md "Known gap" section is retired, pointing at the encoding docs #625 and #638 added.

Worth noting for the reviewer: the marker goes at the end of the clause rather than against the "1.5x" figure it qualifies, and that is the engine's constraint rather than a style choice. That figure is itself a tracked insertion, and citing into it would nest a reference run inside a w:ins — a revision with no independently meaningful resolution. InsertFootnote refuses that outright, which is the right answer. The script therefore searches the clause's untouched tail and cites past it, using a bindOffset step field so the offset is looked up rather than hardcoded — a literal would drift the moment any earlier act changed the sentence.

Two things fixed in my own diff

  • Repaint on mutations, not on every successful call. The endpoint fired its repaint callback after every non-erroring call, so a search that changed nothing still scheduled a repaint — 29 refreshes for 30 calls. Worse, the spec's "repaints coalesce" assertion was passing 29 < 30 only because the one deliberately-refused call does not fire the callback: it claimed to measure coalescing while actually measuring the refusal. resultMutated() now decides, and it is 13 repaints for 31 calls.
  • The GIF capture script could not run the way its own header documented. It lives in docs/demo/tools/ but runs from npm/, so a bare import '@playwright/test' resolved against the wrong directory. It now resolves from the working directory.

One observation about the tool surface

Attribution is a session setting (revisionAuthor, taken at docxodus_open), so three counsel in one session is a host-side switch with no tool behind it. The demo does it off-wire and the README says so. Whether the MCP surface should be able to express "these next edits are by a different reviewer" is a real question, but not one this PR answers.

Testing

  • 14 browser assertions (npm/tests/demo-redline.spec.ts): ten for the negotiation (clean baseline, full run, per-counsel attribution, the reversibility proof on screen and independently, the footnote being a real tracked citation with both halves marked, the accepted end state clause by clause, the saved package carrying w:ins/w:del, the wire, a latency budget, a second clean run after reset) and four for the stress mode (a real diff every frame against a growing document, a deeper pipeline measurably costing more — the assertion that catches the depth selector silently not applying — the recorded-vs-computed ratio derived from both halves, and leaving the pane stopping the loop).
  • 61 headless checks across docs/demo/tools/redline-theater.test.mjs (45: frame shapes, bindings, script validation, telemetry, verdict logic, the catalog contract) and diff-stress.test.mjs (16: the meter and its reset, sparkline geometry including flat and single-point cases, the budget classifier, both ratios, and that no two appended clauses repeat).
  • Re-verified after every main merge against a rebuilt WASM engine — including the one that removed the WmlComparer engine entirely (Remove the WmlComparer engine in favour of DocxDiff #643, ~20k lines), where every call this demo makes was checked to still exist before trusting a green run.
  • Conflicts resolved along the way: CHANGELOG.md four times (both sides adding under [Unreleased], both kept), npm/package.json once (both branches extending pretest and test:demo-logic, taken as a union and verified file by file), and docs/demo/README.md once (the v11.0.0 pin and the page count — resolved to seven pages at the current pin, which is both sides). The v11.0.0 merge also needed a manual fix: git's auto-merge swept this demo's changelog entry out of [Unreleased] into the released section, which would have claimed it shipped in a version it is not in.
  • npm run test:package-boundary confirms none of this ships in the npm tarball.

A note for anyone editing the script: ## 8. Governing Law loses its 8. to the markdown ordered-list parser once the ## is stripped, so section headings are written as Section 8 — Governing Law. There is a test for that too.

Both GIFs are regenerable rather than mystery binaries — docs/demo/tools/capture-redline-gif.mjs (MODE=stress for the second) and frames-to-gif.py, neither run by CI. They play at 1.02× real speed; for a performance demo that matters. Both were recut after #653 halved the engine, because a stale meter on a performance demo is misleading rather than merely old. The stress GIF's meter reads higher than the table above — 132 ms against 74 — and that gap is real rather than an error in either: capture runs a 10fps screenshot recorder against the same CPU, and the clip starts after a full negotiation has already grown the document. The README says so at the GIF, so a reader meeting the two numbers does not conclude one of them is lying.

🤖 Generated with Claude Code

https://claude.ai/code/session_01McoXsjiXqjyRh17URzDDEU

claude added 7 commits August 28, 2026 23:46
A third demo page, and a third bet about what is worth showing. The Arcade
painted frames into one paragraph through raw.replaceXml; Golf made the editing
surface the game and the comparison engine the referee. Theater makes the agent
protocol the show.

Three counsel negotiate a Master Services Agreement. Every edit that lands is
dispatched from a real JSON-RPC 2.0 tools/call frame — the shape docxodus-mcp
accepts over stdio — streamed on a wire console beside the document with its
measured duration. Nothing renders a diff: the session records in render_inline
mode, so each call writes native w:ins/w:del into the live package and the editor
repaints only the block that changed. What you watch is the file that downloads,
and because the three counsel are three values of the session's revision author,
the markup is genuinely per-reviewer the way Word's Reviewing pane groups it.

The finale proves the result instead of asserting it. proveRedlineReversibility
rebuilds two packages from the redline while docxDiffGetRevisions answers the
content question independently: accept-all reaches the negotiated final, and
reject-all restores the baseline with zero content differences. Reject-path
package divergences are classified against a closed set of parts that review
comments legitimately explain — comments.xml and the content-types, rels, styles
and settings entries that come with them — because a comment is not a tracked
change and rejection cannot remove one. Anything outside that set fails the
verdict, which is how this caught the footnote gap noted below.

One scripted call is expected to be REFUSED, and that is the most interesting
frame in the show. Converting the carve-outs to a Word-native list means writing
w:numPr, which has no reversible tracked-change encoding, so the engine declines
it rather than writing a mark reject-all could not undo. The wire renders that in
amber rather than red: an engine that fails closed is the same guarantee the
proof at the end verifies, enforced up front.

On performance, which is the question the demo was built to answer: mutations are
single-digit milliseconds because they are in-memory OOXML edits, and the repaint
is frame-dropped, so ops keep flowing while a repaint is in flight and several
coalesce into one refresh. The full 30-call negotiation runs in about three
seconds of wall clock at 4x; the HUD reports measured p50 and calls/s rather than
claiming anything, and MAX removes the pacing entirely so the readout becomes the
engine's own rate.

How the frames can honestly be called real: tools/mcp-server is a .NET process on
stdio and a browser cannot subprocess it, so docs/demo/mcp-wire.js reimplements
the server's front half — envelope parsing, the (tool, action) routing table, the
content[].text + isError result shape, and the convention that a business-level
failure is a tool result rather than a protocol error — over DocxSession via
WASM. The frames are the frames; the transport differs. That correspondence is
checked rather than claimed: the Node test parses the shipped ToolCatalog.cs and
fails if any (tool, action) pair, search mode, set_mode value, or argument the
script puts on the wire is missing from the real catalog. It earned its keep
immediately, catching a batch argument named `atomic` that the catalog calls
`mode`, and set_mode wire names that need mapping to the TrackedChangeMode enum
rather than being passed through as strings.

Also records a defect found while building this. insertFootnote/insertEndnote
under render_inline recording write a note definition that reject-all does not
remove, so the redline is not reversible — and unlike ApplyListFormatRange under
the same mode, they do not refuse. Act II inserts a paragraph instead, a test
guards the step from returning, and docs/architecture/tracked_changes.md explains
why the inconsistency is the defect rather than the irreversibility alone.

Tests: nine browser assertions in npm/tests/demo-redline.spec.ts covering the
clean baseline, the run, per-counsel attribution, the reversibility proof, the
accepted end state, the saved package, the wire, a latency budget, and a second
clean run after reset; 41 headless checks in docs/demo/tools/redline-theater.test.mjs
including the catalog contract. Demo content only — the package-boundary audit
confirms none of it ships in the npm tarball.
Reviewing the diff adversarially before CI got to it: the endpoint fired its
onMutate callback after every call that did not error, so a docxodus_search that
found six matches and changed nothing still scheduled an editor repaint. The
theater then scheduled a second one from dispatchStep, which also knows which
anchor the call touched and is the one that moves the camera.

Two consequences, one cosmetic and one that matters. The wasted repaints are
real work: 29 refreshes for 30 calls, where only 21 of those calls mutate
anything. And the spec's "repaints coalesce" assertion (renderCount < calls) was
passing 29 < 30 — true only because the one deliberately-refused call does not
fire onMutate. It asserted coalescing while actually measuring the refusal, and
would have flipped to failing if that step ever started succeeding.

resultMutated() now decides: a result carrying matches/anchors/comments/revisions
is a read, a failed result changed nothing, an EditResult that reports
created/modified/removed mutated, a batch mutated if any step did, and switching
the recording mode is session configuration rather than a document edit. The
theater drops its redundant onMutate wiring and keeps the anchor-aware
scheduling it already had.

Measured on the same run: 13 repaints for 30 calls, down from 29. The spec now
asserts renderCount <= calls - 9 (the nine read-only frames), which has genuine
margin rather than one.
The demo shipped calling itself REDLINE THEATER while using the comparison
engine exactly once, as a verification cross-check in the finale.
docxDiffCompare was never called at all. That is a fair thing to be pulled up
on: "redline" means two different things in this codebase and only one of them
was on screen.

So the page now has a second mode. Where the negotiation RECORDS its redline —
each MCP call writing w:ins/w:del into the live package as it lands — diff
stress RECOMPUTES one from scratch after every single edit, baseline against
current, and times it. Each frame appends a clause, so the input grows under the
engine rather than sitting at one fixed size that would measure nothing.

Three selectable depths, because "how fast is the diff engine" has three honest
answers. Measured in-browser on the demo's own agreement:

  revisions    docxDiffGetRevisions                 ~5.4/s, p50 184ms
  redline      docxDiffCompareProducts              ~3.1/s, p50 324ms
  full + HTML  the above then convertDocxToHtml     ~1.9/s, p50 515ms

Against a mutation costing ~2ms through the same MCP endpoint, that is 74x to
283x. So the honest answer to "can we animate a diff per edit" is that a
redline-per-edit loop lives between 2 and 6 frames per second on a 3KB document,
and no arrangement of the code was going to make it 60. That gap is the whole
argument for recording a redline when you are the one editing and computing one
when somebody hands you a document they changed: both emit the same native
markup, and the meter shows what each costs to get there. The panel reports the
ratio it just measured rather than the numbers written above.

The redline depth uses docxDiffCompareProducts rather than docxDiffCompare
followed by docxDiffGetRevisions: one memoized alignment pass yielding both
products measured 268ms against 337ms for the two calls separately, which is
what that API exists for.

Details worth knowing. The meter resets per run, because carrying frames across
a depth change reports a p50 describing neither pipeline — the first draft did
exactly that and reported an identical 206ms for all three depths, which is how
the bug was caught. fps comes from the median frame rather than the mean, so one
hitch does not halve a reading that is otherwise steady. Leaving the stress pane
stops the loop, so it cannot keep burning the engine behind a hidden pane. The
proof and author roll-up collapse while the meter is up: they describe the
negotiation and were squeezing the meter off-screen. The appended clauses are
distinct real sentences, since repeating one paragraph would be an
unrealistically easy alignment problem and would flatter the numbers.

Also fixes the GIF capture script, which could not actually run the way its own
header documented: it lives in docs/demo/tools/ but runs from npm/, so a bare
import of @playwright/test resolved against the wrong directory. It now resolves
from the working directory, and takes MODE=stress to record the meter.

Tests: 16 headless checks in docs/demo/tools/diff-stress.test.mjs (the meter and
its reset, sparkline geometry including the flat and single-point cases, the
budget classifier, both ratios, and that no two appended clauses repeat) and 4
browser assertions — a real diff runs every frame against a growing document, a
deeper pipeline measurably costs more (the assertion that would catch the depth
selector silently not applying), the recorded-vs-computed ratio is derived from
both halves, and leaving the pane stops the loop.
Merging main brought in the DocxDiff read-amplification fix, which roughly halves
a two-way compare. Every performance figure this demo publishes was measured
against the engine before that, so they were all quietly wrong the moment the
merge landed.

Re-measured in the browser on the demo's own agreement, before -> after:

  docxDiffGetRevisions                159ms -> 136ms
  docxDiffCompare                     278ms -> 153ms
  compareProducts [redline+revisions] 268ms -> 167ms
  separate compare + getRevisions     337ms -> 247ms

and the live stress readouts, which are what the page actually shows:

  revisions    5.4/s p50 184ms  ->  7.0/s p50 144ms
  redline      3.1/s p50 324ms  ->  4.9/s p50 206ms
  full + HTML  1.9/s p50 515ms  ->  2.4/s p50 422ms

So the headline moves from "74x to 283x the cost of recording" to "75x to 196x",
and the loop from 2-6 frames per second to roughly 2-7. The thesis is unchanged
and the numbers are better; what would have been embarrassing is shipping a page
that quotes the old ones next to the commit that improved them.

The panel itself needed no change — it reports what it just measured, so it
picked the improvement up on its own. That is worth saying out loud in the README
because it is the argument for measuring live rather than printing a table, and
this merge is the first evidence of it.

Also cross-references benchmarks/docxdiff-stress/FINDINGS.md, which arrived in the
same merge and asks the same question far more rigorously — 147KB certificate of
incorporation, stage attribution, allocation figures, medians of nine runs. That
harness is the authority on engine performance; this mode is the one you can
watch, on a 3KB document, in a tab. Saying so keeps the demo from implying it is
the measurement of record.

Updated: the diff-stress.js header, the README table and its compareProducts note,
and the CHANGELOG entry. Verified with a rebuilt WASM engine: 22 browser
assertions across demo-redline and demo-golf, plus the full pretest.
Merging main again brought #623 (byte-reproducible Compare when a redline creates
or imports parts), which touches the IR markup renderers. Rebuilt and re-measured
rather than assuming a determinism fix is performance-neutral.

The first re-measurement said the revisions depth had regressed by a third, 144ms
to 200ms. It had not. Two problems with reading that number:

The stress meter takes its p50 over a run during which the document is GROWING
under the loop, so the value depends on how far the run got before it was
stopped. Two runs of the same build are not comparing the same input. That is
fine for what the meter is for — watching what a redline-per-edit loop costs —
and useless for comparing one engine build against another, which is exactly what
I reached for it to do.

And the container is noisy: repeated medians of nine, on fixed inputs, still
spread 5-20% run to run.

A controlled measurement — fixed baseline and current, medians of nine, three
independent repetitions — shows #623 regressed nothing: getRevisions 136ms ->
115-132ms, compare 153ms -> ~135ms, compareProducts 167ms -> ~153ms, the tracked
HTML render 139ms -> ~125ms. Everything same or slightly better, which is what a
determinism fix to the renderers should do to a path that does not render.

So no published figure changes. What changes is that both the README and the
module header now say plainly what the meter is not: its p50 is confounded by
document growth, the last digit is noise, and builds should be compared with a
fixed input and medians of many — which is what benchmarks/docxdiff-stress does
properly. The near-miss is written down too, because a number that looks precise
and sits right there is going to get reached for again otherwise.

Verified on the rebuilt engine: 13 browser assertions and the full pretest.
claude added 22 commits August 31, 2026 05:26
#625 fixed the defect this demo found (#614): InsertFootnote under
render_inline recording wrote the citation and the note definition as
ordinary content, so reject-all removed neither and the note's text
survived a "full" rejection. Act II had been carrying a plain paragraph
in place of the footnote it wanted, with a comment and a guard test
pinning the workaround. Both come out.

The marker goes at the end of the clause rather than against the figure
it qualifies, and that is the engine's constraint, not a style choice.
The "1.5x" text was written moments earlier as a tracked insertion, and
citing into it nests a reference run inside a w:ins — a revision with no
independently meaningful resolution. InsertFootnote refuses that outright
("offset falls inside a revision or unsupported inline container"), which
is the right answer and cost one iteration to discover. So the script
searches the clause's untouched tail and cites past it, which is also
where Word convention puts a marker.

That needed an offset the script does not know up front, so a step can
now bind one: `bindOffset` stores the character just past a search
match, and `offsetAfterMatch` is the arithmetic. It keeps the demo's
rule that an agent looks everything up rather than hardcoding it — a
literal offset would drift the moment any earlier act changed the
sentence.

The guard test is inverted rather than deleted: the script must still
contain exactly one note-insertion beat, because the reversibility proof
running over a footnote on every page load is the end-to-end check that
#625 works through the wire, and a silent removal would take it away.
A new browser assertion reads the citing paragraph's XML out of the
redline and requires the footnoteReference to sit inside a w:ins, so the
proof cannot pass vacuously by never writing a note; the reject-all test
gained the matching negative, that the side-letter text is gone.

Verified: the proof panel reads REVERSIBLE with zero content
differences, 26 revisions across 3 authors, 31 tool calls at a 5.1 ms
median. 14/14 browser assertions and 61/61 node checks pass.

Also retires the known-gap section in tracked_changes.md, which is now
false — it points at the encoding docs #625 added and keeps only the
account of how the closed-set classification surfaced it.

Republished figures after #626. Measured the same way the table is read,
three full 40-frame runs per depth on one container: revisions 152 ms
(was 144, inside the documented noise band), redline 181 ms (was 206),
full+HTML 352 ms (was 422). The ratio against the mutation path is now
60x to 154x, so the loop runs between 3 and 7 fps rather than 2 and 6.
The compareProducts-vs-two-calls pair, measured the controlled way
(fixed inputs, medians of nine, three reps), is 126 ms against 196 ms.
The two deeper rows moving 10-18% while the shallow one held is what a
load-path change looks like, though the panel measures the total, not
the attribution.
The theater GIF predated the footnote beat, so it showed Act II inserting
a plain paragraph — a step that no longer exists. The new capture shows
the footnote rendering at the foot of the document and the proof panel
reading REVERSIBLE over it, which is the whole point of putting the beat
back. 31 calls, 26 revisions, 4.9 ms median.

The stress GIF predated #616 and #626 and read slower than the engine now
runs. Recut on the current build.

Its meter still reads higher than the table in the README (236 ms against
a published 181 ms for the same depth), and that gap is real rather than
a mistake in either: the capture runs a 10fps screenshot recorder against
the same CPU, and the clip starts after a full negotiation has already
grown the document. Both are the confound the README already describes,
so it now says so at the GIF rather than leaving a reader to find a
contradiction and trust neither number.
Third engine movement this branch has tracked, and the first where all
three depths moved together: revisions 152 -> 124 ms, redline 181 -> 157,
full+HTML 307 from 352, each a median of three 40-frame runs. Ratio
against the mutation path is 64x to 165x; the loop runs 3 to 8 fps. The
controlled compareProducts pair (fixed inputs, medians of nine) is 106 ms
against 170 ms.

A saving uniform across depths is the signature of a change on the path
every depth shares, which is what #627 is -- it stopped giving the diff
engine's reads an identity nothing asks for. That reads differently from
#626, whose saving grew with pipeline depth, as a load-path change does.
The README now says both, and says plainly that the shape of the movement
is what suggests the attribution while the commits are the authority on
it; the panel only ever measures the total.

#629 landed in the same merge and is deliberately NOT credited. Its
snapshot reuse is across comparisons, and the stress loop makes one
comparison per frame against a document that changed, so there is nothing
for it to reuse. Crediting it because it arrived at the same time would
be the same mistake as reading a regression off the stress p50.

Also resolves the CHANGELOG conflict from the merge, keeping both
Unreleased entries -- #617's snapshot feature and the demo -- neither
supersedes the other.

Verified on the merged engine: 14/14 browser assertions, 61/61 node
checks. #629 changed docxodus_compare's catalog entry (adding mode and
outputPaths), which the contract test parses; additive, and the demo does
not call that tool.
#638 completes what #625 started on the path this demo exercises. #625
made the citation the reversible unit but left the definition unmarked,
compensating with an unconditional prune on the stateless reject. That
made the redline ambiguous to a stateless consumer: a w:ins citation
beside an unmarked definition is also exactly what a comparison emits
when the counterpart merely cites a husk the baseline already owned, and
the unguarded prune ate that husk. Now both halves record and the prune
is guarded on the definition being emptied as well as orphaned.

The demo's comments and the tracked_changes.md pointer both described the
#625 mechanism, so both were describing an encoding the engine no longer
writes. Corrected.

The browser assertion is extended rather than left alone, because the old
one would still pass against the ambiguous shape: it checked the citation
run sits inside a w:ins and stopped there. It now also pulls the note
definitions out of the redline and requires the one Act II authored to
carry insertion markup. Identifying it by its text matters — the part
also holds Word's two reserved separator notes, which carry no markup and
must not.

No re-measurement. #634 and #638 are on the note-lifecycle and reject
paths; the stress loop times docxDiffGetRevisions and
docxDiffCompareProducts, which neither touches. Published figures stand
as measured at #627.

Verified on the rebuilt engine: 14/14 browser assertions, 61/61 node
checks. Also merges #633 and #637, both test-only.
…cp-lcvm85

# Conflicts:
#	CHANGELOG.md
#	npm/package.json
#620 merged its WmlToHtmlConverter / MarkupSimplifier conversion
speedup, which is the third stage of the full+HTML depth, so this one met
the re-measure bar. Every row then came out 5-15% SLOWER than the #627
reading: revisions 124 -> 143, redline 157 -> 170, full+HTML 307 -> 324.

Nothing in that merge can do that. revisions never calls the converter at
all. Decomposing the controlled numbers says the opposite of a
regression: the conversion stage on its own (full - redline) went 154 ms
-> 143 ms, cheaper, exactly as #620 intends, while the compare stage read
dearer with no cause anywhere in the diff.

So the reading that changed is the machine, on a different day. The #627
figures were a single measuring session; these are the pooled median of
nine controlled medians across three sessions, and 5-15% sits inside the
spread this container already shows on fixed inputs. The table is
refreshed anyway, because its job is to say what the panel will show a
viewer rather than to hold the best number ever recorded.

The attribution list in the README gains an entry that names no PR. That
is the point of keeping the list: three movements have a cause and this
one does not, and writing "no cause found" is more useful than pinning it
on whichever PR happened to land first. The list now says so in general
terms too, so the next reading is not reflexively attributed.

Also resolves two merge conflicts. npm/package.json had both sides
extending pretest and test:demo-logic; taken as a union, verified by
listing every demo file and test both branches stage. CHANGELOG kept both
[Unreleased] blocks, library entries first, as with #617.

Verified on the merged engine: 14/14 browser assertions, 61/61 node
checks, package boundary clean.
#643 removes the WmlComparer engine entirely — 81 files, ~20k lines out
— and #644 is a render-fidelity round touching the converter. Both land
on paths this demo exercises, so neither was taken on trust.

Every engine call the demo makes still exists after the removal
(docxDiffGetRevisions, docxDiffCompareProducts, convertDocxToHtml,
proveRedlineReversibility, openDocxSession); the two exports that went,
compareDocumentsWithLog and compareDocumentsToHtmlWithLog, are ones it
never used. Nothing in docs/demo/ or the spec named WmlComparer.

Rebuilt and re-ran: 14/14 browser assertions pass on the
WmlComparer-free engine, including the reversibility proof and the
footnote citation check.

Re-measured, because #643 and #644 both touch measured paths, and the
answer is that nothing moved: controlled medians came back revisions
105 -> 101, redline 140 -> 134, full+HTML 282 -> 278, compareProducts
126 -> 125, and the conversion stage in isolation 143 -> 144 ms. All
inside the container's own spread. The published table is therefore left
alone rather than churned by a few percent in either direction — the
same restraint the README's attribution list asks for.

CHANGELOG conflicted again on both sides adding under [Unreleased];
resolved keeping both, library entries first.
…cp-lcvm85

# Conflicts:
#	docs/demo/README.md
#653 compiles the browser build's hot paths ahead of time from a
recorded profile, and it is the largest movement this page has recorded.
revisions 143 -> 52 ms, redline 170 -> 74, full+HTML 324 -> 141;
compareProducts on fixed inputs 125 -> 51, and the conversion stage in
isolation 144 -> 60. Roughly 2x to 2.45x on every measure.

A claimed 2x deserves more evidence than the one session that made an
earlier baseline look better than it was, so both methods were run and
the controlled figures pooled over two sessions before anything was
published. They agree.

Uniform across depths again, but the shared thing this time is not a code
path — it is the .NET execution underneath all of them, which is what
ahead-of-time compilation buys. That also explains why the ratio against
the recording path moved rather than holding: recording got faster too,
so 68-157x becomes 33-78x.

The headline claim changes with it and is rewritten rather than quietly
left. "No arrangement of the code was going to make it 60fps" is still
true, but it is a much closer no: the loop now runs 7-19 fps where a
month of engine work ago it was 2-6, and the shallow depth is inside the
range a viewer reads as motion rather than as a series of updates.

Both GIFs recut, because a 2x speedup makes a committed meter actively
misleading rather than merely stale. The stress GIF still reads higher
than the table (132 against 74) for the documented reasons, and the note
at the GIF now carries the current pair.

Also in this merge:

- v11.0.0 shipped, and git's auto-merge swept the REDLINE THEATER entry
  out of [Unreleased] and into the released 11.0.0 section — which would
  have claimed the demo shipped in a version it is not in. Moved back
  under [Unreleased]; the 11.0.0 section is left exactly as cut.
- redline.html still pinned docxodus@10.0.0 while the release re-pinned
  every sibling page to @11.0.0, because this page is not on main yet to
  be re-pinned. Now at @11.0.0, confirmed with a real fetch against
  jsDelivr first, as the release procedure requires.
- The demo README conflicted on the pin AND the page count; resolved to
  seven pages at @11.0.0, which is both sides rather than either.

14/14 browser assertions and 61/61 node checks pass on the rebuilt
engine.
Annotation-only, but RevisionProcessor.cs is what the reversibility
proof resolves through, so it got a rebuild and the full browser run
rather than a wave-through: 14/14 pass, proof still clean.

No re-measure. Nullable annotations do not change codegen in a way the
stress loop would see, and RevisionProcessor is not on it — the loop
times docxDiffGetRevisions, docxDiffCompareProducts and
convertDocxToHtml.
Skipped the local rebuild deliberately. #661 touches only
HtmlToWmlCssApplier.cs and HtmlToWmlCssParser.cs — the HTML->DOCX
direction, which this demo never calls; it drives DocxSession and the
DOCX->HTML converter. Annotation-only, zero overlap, and CI runs the full
browser spec on this merge regardless. Neither a rebuild nor a
re-measure earns its cost here.
#663-#666 continue the #650 nullable run. Two of them land on this
demo's measured path: #665 and #666 annotate WmlToHtmlConverter.cs and
.Charts.cs, which are the conversion stage of the full+HTML depth. They
are not cosmetic either — removing the #nullable disable header added 27
null-handling lines to the converter, which is real runtime code, so
this got a rebuild and the full browser run rather than a wave-through.
14/14 pass.

Measured, and the numbers are NOT republished, deliberately. A single
controlled session read 12-25% faster across most rows (revisions 50->38,
redline 67->53, full 127->106, conversion stage in isolation 60->53) —
but oneCall, which IS the redline depth's call, came back flat at 51->50.
Redline cannot drop 21% while docxDiffCompareProducts holds; the pattern
contradicts itself. Added null-guards should also cost time rather than
save it, so the direction is wrong for the change too.

That is variance, on one session, and the published figures came from a
pooled two-session controlled measurement. The README asks for pooling
before believing a movement and for labelling one that fits nothing as
the machine; publishing this would break both rules on my own page.

Also merges #663 (HtmlToWmlConverter/Core) and #664 (DocumentBuilder),
neither of which this demo calls.
FormattingAssembler.cs is the last of the legacy #nullable disable files
and the converter resolves formatting through it, so this is on the
full+HTML path. 13 real null-guards added, not cosmetic, so it got a
rebuild and the full browser run: 14/14 pass.

No measurement this time, and that is a change of approach rather than
an omission. The #650 nullable run has now touched the converter twice
(#665/#666, #675), and measuring after each one produces a single
session apiece — which is exactly the sample size that gave an
incoherent reading last merge (redline down 21% while its own underlying
call held flat). Single sessions through a long mechanical run add noise,
not signal.

The published figures are pooled and stable and nothing here is expected
to move them: null-guards cost time rather than save it, and the effect
of thirteen of them is far below what this container can resolve. When
the run finishes, the figures are due one deliberate pooled
re-establishment rather than eleven piecemeal nudges.
#676 retires the NoWarn list and fixes the bugs CS8073 was masking. Two
of its ten sites are in WmlToHtmlConverter.cs, which is on this demo's
conversion path — but its own commit message establishes both as
provably no-op: the following `as ImagePart` / null check already
subsumes the not-found case, so the dead check was redundant rather than
wrong, and is deleted rather than repaired. The eight genuine behavior
fixes are all in DocumentBuilder (merge/split) and MetricsGetter, which
this demo never calls; confirmed by grep before deciding.

So no local rebuild and no measurement — CI runs the full browser spec on
this merge, and neither the changed converter lines nor the fixed paths
can move what the demo does. #677 is the complex-form benchmark's exit
contract, unrelated.

Release-merge checklist re-run since #676 touched CHANGELOG: the REDLINE
THEATER entry is still inside [Unreleased], not swept into a released
section.
The pooled re-establishment deferred through the #650 nullable run, now
that the run is over (#675 was the last annotated file, #676 retired the
NoWarn list, and main has moved on to docs). Three 40-frame stress runs
plus two controlled sessions, which is the sample size the earlier
single-session readings lacked — and this time the stress reps land
within 3 ms of each other and both methods agree.

They came out 21-28% faster than the published figures on every absolute:
revisions 52 -> 41 ms, redline 74 -> 55, full+HTML 141 -> 101. Nothing
merged since #653 can explain a quarter — the nullable run added
null-guards, which cost time, and #676 removed two provably redundant
checks.

What makes the reading worth keeping is the column that did NOT move. The
ratios against the recording path are 33 -> 34x, 45 -> 45x, 78 -> 80x.
The mutation path scaled by the same factor as the diff path, so the
ratio held while both halves got a quarter faster together. That is the
container being faster this hour, and it is the clean counterexample to
#653, which announced itself precisely BY moving the ratio (68-157x down
to 33-78x) because recording and computing changed by different amounts.

So the README gains a sharper diagnostic than "is the movement uniform
across depths": when the absolutes move and the ratio holds it is the
machine; when the ratio itself moves, the two paths changed by different
amounts and something real happened. That is a better test because it
needs no knowledge of what merged.

The table keeps its original pooled figures rather than adopting the
newer ones. Both are honest pooled measurements of the same build; taking
whichever is faster would be chasing weather, and the panel recomputes the
ratio live regardless.

14/14 browser assertions and 61/61 node checks pass on the current head.
Also merges #679 and #680, both docs-only; CHANGELOG conflicted the same
both-added way and was resolved keeping both, and the release-merge check
confirms this demo's entry is still inside [Unreleased].
#678 resolves footnote and endnote links against the finished HTML tree and
drops the ones whose target was never emitted. That lands on the demo's path:
Act II inserts a footnote, and the full+HTML depth renders the whole document,
which is exactly where the new sweep runs (BuildBlockConverterSettings opts the
incremental renderer out via RendersDocumentFragment; the theater does not).

Verified rather than assumed. All 14 browser assertions pass, including the two
that read both halves of the note — the citation wrapped in w:ins and the
definition content recording alongside it — and the reject-all check that the
side-letter text is gone once the redline is reversed. 61 node checks pass.

The sweep is two extra full Descendants() passes over the output tree, so it is
a fair question whether it shows up in the conversion stage. It does not.
Decomposing full minus redline across two pooled sessions puts that stage at
41-45 ms against 46 ms in the previous session -- flat, with oneCall steady at
43-46 ms confirming the redline row underneath it. Every absolute drifted down
together again, which is the machine and not the engine, so the published
figures stay where they are.
The README's diagnostic says a moving ratio means the two paths changed by
different amounts and something real happened. True, but it never said how much
movement counts, which makes it unfalsifiable in the direction that matters: any
wobble can be read as a finding.

Two consecutive full + HTML runs on one build within one hour read 77x and 84x.
That is the noise floor measured rather than guessed, and #653 -- which halved
the ratio outright -- is the other end of the scale. Stating both puts a number
on the rule: under about 10% is noise, approaching a halving or doubling is the
engine, and in between you measure again.

Found while checking #684, which changed the OPC part serializer so a save keeps
whatever byte-order-mark convention a part already had. That is the save path,
so it is the denominator of every ratio in the table, and the extra three-byte
read per part write predicted a slower recording path. It did not happen:
avgMutateMs came in at 1.39-1.65 ms against the ~2 ms the table publishes, the
ratios held at 33x / 47x / 77-84x against a published 33 / 45 / 78, and the
absolutes moved in both directions at once. Nothing to republish. 14 browser
assertions and 61 node checks pass on the merged build.

JSv4 commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

CI note: the test job is red on fb193b8, and it is not this PR's failure.

The failing test is npm/tests/docxodus.spec.ts:480 — "CZ Comparison Tests (tracked changes) › move detection exposes MoveGroupId and IsMoveSource in revisions" — which times out at 60 s inside compareDocuments on all three attempts. This PR's diff is 16 files of documentation, demo JavaScript, one Playwright spec and CHANGELOG; it contains no C# and cannot reach the comparison engine.

Bisected rather than assumed. Building and running the same single test on each side of the merge:

So #693 took that comparison from 2.7 s to over 60 s. Filed with the bisect and a pointer at the likely cause as #695#693's own perf(diff) commit documents the same failure mode on a different pair ("49 seconds where the comparison had taken 1.3") and adds a prefilter that this pair appears to slip past.

I have not ported a fix because none exists yet, and the fix belongs in the diff engine's alignment logic rather than in a demo PR. I am also not re-running the check: it reproduces deterministically on three attempts in CI and again locally, so a re-run would burn ~20 minutes to fail identically.

This PR's own suite is unaffected — 14/14 browser assertions and 61/61 headless checks pass on the merged build. I will keep it watched and merge main again once #695 is fixed.


Generated by Claude Code

The release cut is the reason this merge needed hands. main emptied
[Unreleased] into a 12.0.0 section, and git's auto-merge put this demo's entry
back at the position that text now occupies -- inside the released section,
where it would have claimed the demo shipped in a version it is not in. Moved
it back under [Unreleased], and dropped the Office Math entry that came with it,
because that one did ship in 12.0.0 and would otherwise appear twice.

Re-pinned docs/demo/redline.html from 11.0.0 to 12.0.0. The release re-pins
every sibling demo page but not this one, since this one is not on main yet;
what is new is that #654's engine-pin guard globs docs/demo/*.html and requires
them all to name one version, so a stale pin here is now a failing check rather
than a quiet inconsistency. Confirmed jsDelivr actually serves 12.0.0 before
moving it. The README conflict was the same event seen from the other side:
seven pages (this branch adds one) at the new pin.

#697 fixes the comparison timeout that made the last CI run red. That failure
was #693's, bisected and filed as #695; the fix warms the WASM engine before
the first real comparison rather than touching the aligner, so my guess at the
cause in that issue was wrong even though the bisect was right. Verified here:
the test that timed out at 60 seconds now passes.

Build and pretest clean, 61 node checks, 14 browser assertions, and the
engine-pin guard passing on all seven pages. Not re-measured: #698 changes
token-stream arrangement for regions with no block correspondence, which the
demo's 3 KB document does not exercise.
…onflict

The v12.0.0 merge taught this shape, so this time it was checked rather than
noticed: main cut v12.0.1 and emptied [Unreleased] into it. What differs is that
CHANGELOG.md AUTO-MERGED — no conflict, no marker, nothing to resolve — and the
demo's entry still ended up inside the released section, at line 106 under
12.0.1's own heading. A merge that reports success is exactly when this fails
silently, so the check runs on every merge that touches the file, not only on
the ones git stops for. Moved back under [Unreleased] and confirmed there is
still only one copy.

Re-pinned docs/demo/redline.html 12.0.0 -> 12.0.1 after confirming jsDelivr
serves it, and resolved the README the way this conflict always resolves: seven
pages (this branch adds the seventh) at the new pin, which is both sides rather
than either. All seven demo pages now name one version, which is what the
engine-pin guard requires.

Build and pretest clean, 61 node checks, 14 browser assertions. Not re-measured:
12.0.1 is the warm-up fix and a python version bump, neither of which the demo's
document or its recording path exercises differently from 12.0.0, which was
measured.
)

An ordinary conflict this time rather than a release cut: main added a
"### Fixed" entry under [Unreleased] while this branch already had its
"### Added" there. Both belong, so both stay, with main's fix first. The
placement check still ran — it is cheap and the v12.0.1 merge showed the sweep
can arrive through a clean auto-merge — and confirms one copy of each entry
under [Unreleased].

Rebuilt and verified because #702 changes npm/src/editor.ts, which is bundled
into the embed bundle the demo loads: build and pretest clean, 61 node checks,
14 browser assertions. Not re-measured — clipping a drag handle to the editor's
viewport instead of the window is UI geometry, nowhere near the diff, save or
conversion paths the published figures describe.
A clean auto-merge, so the changelog placement check ran on its own merits
rather than to settle a conflict — the same reason it ran for v12.0.1. It
confirms one copy of each [Unreleased] entry with this branch's REDLINE THEATER
entry inside the section (line 47, ahead of the next released heading at 88).
The missing blank line before "### Fixed" is main's own shape, not something
this merge introduced, so it stays as main wrote it.

Verified but not rebuilt and not re-measured. #703 touches the arcade demo's
own JavaScript, its node checks and a new mobile spec — no npm/src, so the
embed bundle the redline demo loads is byte-identical, and nothing near the
diff, save or conversion paths the published figures describe. The shared
surfaces are the changelog, docs/demo/README.md and the demo-logic test list,
which is why the checks still ran: 61 redline node checks (45 theater, 16
stress), the arcade and engine-pin checks main just changed, and 14 browser
assertions.
Main cut v12.1.0, moving the accumulated "### Changed" (#703) and "### Fixed"
(#702) out of [Unreleased] under a dated heading. Textually that move is just a
heading inserted above entries this branch also carries, so git auto-merged it
with three insertions and no conflict — and left this branch's "### Added" sitting
underneath the new "## [12.1.0]" heading, filed into a version that has already
shipped, with [Unreleased] empty above it.

That is exactly the failure the placement check exists to catch, and it caught it:
the check prints the REDLINE THEATER line only while it is still inside
[Unreleased], and after the auto-merge it printed nothing. The entry is moved back
under [Unreleased]; the released section keeps main's two entries, in main's order,
with the blank line main restored before "### Fixed". The diff against main is now
purely additive — this branch adds its own entry and touches nothing of main's.

Worth stating plainly: a clean auto-merge is not evidence the changelog is right.
This is the second release cut in a row where the sweep earned its keep, and the
first where the damage was invisible in the merge output.

The demo pin needs no move yet. All eleven references across the seven pages still
read docxodus@12.0.1, so they agree and the pin check passes; jsDelivr answers 404
for 12.1.0 because Publish is still in flight. Re-pinning waits for main's own
re-pin and a 200.

Verified: 61 redline node checks (45 theater, 16 stress), 3 pin checks, 14 browser
assertions. Nothing rebuilt or re-measured — the merge carries changelog prose only.
Main's re-pin commit moved the six pages it knows about to 12.1.0 and left this
branch's redline.html behind at 12.0.1, which is what made the PR un-mergeable:
docs/demo/README.md states both the page count and the pin in one sentence, and
the two sides disagreed on both halves — "Seven ... 12.0.1" here against
"Six ... 12.1.0" there. Each side is right about the half it owns, so the
resolution is both: seven pages at 12.1.0. redline.html is re-pinned here rather
than by main because main does not have the file yet.

The pin moved only after jsDelivr actually served it: a request for
docxodus@12.1.0/dist/embed.bundle.js answered 200 before the edit and again
after. That check matters more than usual this time — the v12.1.0 Publish run
is red, but it is red at the documented @docxodus/export bootstrap step, which
CLAUDE.md says to expect until that package is created by hand. The docxodus
package itself published, which is the one these pages load.

All eleven references across the seven pages now read 12.1.0, and no 12.0.1 pin
survives anywhere in the tree.

Verified: 61 redline node checks, 3 pin checks, and 20 browser assertions — the
14 redline ones plus social-demo.spec.ts, the guard that proves the demo pages
load the pinned bundle rather than a 404. Rebuilt through the full pretest
because docs/demo changed; not re-measured, since main's npm/src edits here are
comments and one test constant.
…r focus fix (#705)

Eight stacked PRs landed a new Docxodus/History subsystem overnight — package
change sets and their codec, blob and head stores, exact DOCX snapshots,
immutable version records, and guarded restores — plus #705 on the editor's find
bar. Triaged as rebuild-yes, re-measure-no.

Rebuild, because #705 changes npm/src/editor.ts and ribbon.ts, and those are
bundled into the embed bundle this demo loads. Verified through the full pretest
so the WASM engine is rebuilt with the History code compiled in.

No re-measure, because nothing in the range changes behaviour on the diff, save
or conversion paths the published figures describe. History is additive: every
file under Docxodus/History is new and nothing existing calls into it yet. The
one edit to a file this demo's finale does exercise —
Verification/PackageManifestGenerator.cs — only widens two members from private
to internal so the History code can reuse the entry-name canonicalisation. Same
code, same results.

CHANGELOG conflicted in the ordinary shape, main's "### Fixed" against this
branch's "### Added" under [Unreleased]; both kept, main's first. The placement
check confirms one copy of each with the demo entry still inside the section.

Verified: 61 node checks, 3 pin checks, 14 browser assertions.
The second batch of the stack rippled History outward: sequence replay and
timestamp lookup in the core, a HistoryClientOps facade, then the bridges and
clients — wasm/DocxodusWasm/HistoryBridge.cs, npm/src/history.ts, the python-host
bindings, docx_scalpel/history.py, and a new history tool in the MCP server.
Rebuild-yes, re-measure-no, and a clean auto-merge with no CHANGELOG in the range.

The risk worth naming: tools/mcp-server/ToolCatalog.cs gained 58 lines. This
demo's redline-theater.test.mjs parses that file and fails if any (tool, action)
pair, search mode, set_mode value or argument the script puts on the wire is
missing from the real catalog — so a catalog edit is the one upstream change that
can break the demo's central claim without touching a line of demo code. Ran that
check first, before the rebuild, because it needs no build to answer: 45/45. The
new history tool is purely additive; nothing the script calls was renamed or
removed.

Rebuilt because npm/src/embed.ts, index.ts and types.ts all changed and a new
history.ts joined them, all bundled into the embed bundle this demo loads.

Not re-measured. The edits to files outside History are wiring — one line in the
MCP dispatcher, three in its Program, five in SessionStore — and nothing in the
range touches DocxDiff, the session save path or the HTML converter behaviourally.

Verified: 61 node checks, 3 pin checks, 14 browser assertions.
…727)

The third batch of the stack: atomically published request receipts, a
HistoryRequestJournal with durable retry identities, backend reconciliation that
preserves conflicts, publication ancestry, a standalone recovery probe, and fuzz
harnesses for versions and the backend model. Clean auto-merge with no CHANGELOG
in the range, so nothing to sweep — but the check ran anyway and confirms the
demo entry still sits inside [Unreleased].

tools/mcp-server/ToolCatalog.cs gained a line, so the catalog contract check ran
first, before the rebuild, because it parses that file and needs no build to
answer: 45/45. Rebuilt because npm/src/history.ts changed.

Not re-measured. Everything else in the range is under Docxodus/History, its
tests, its docs and its schemas — nothing touches DocxDiff, the session save path
or the HTML converter.

Worth noting for anyone reading this branch's history: main also re-measured
CLAUDE.md's warning baselines in this range (113/707 -> 175/788, which had
drifted about thirty behind) and now distinguishes what a new .cs file costs
depending on whether it carries a StyleCop file header. No consequence here —
this branch adds demo JavaScript, specs and docs, and not one C# file.

Verified: 61 node checks, 3 pin checks, 14 browser assertions.
…asuring

Main's range 6f140f0..2a1e6f9 is mostly two large-but-inert changes — #728
corrects misattributed Microsoft copyright headers across ~200 post-fork files,
and #721 adds the ASCII DOOM showcase — plus the tail of the history archive
work. npm/package.json conflicted in the usual shape (both sides extending
pretest and test:demo-logic) and was resolved as a verified union: main's
doom-ascii copy and check alongside this branch's four redline entries, split on
" && ", diffed list against list, and revalidated with a JSON parse. The demo
logic suite is eight checks now.

Two things in that range could move the published diff figures, so this merge
re-measured rather than assuming: the WASM project now passes -Oz to the SDK's
Binaryen post-link pass, and #721 re-recorded docxodus.aotprofile (1.34 MB to
1.65 MB). Profile-guided AOT is exactly what halved these numbers in #653, so
both deserved a reading rather than a guess.

The figures do not move, and the table stays put for the third time. A pooled
reading — two controlled fixed-input sessions plus a three-depth stress run —
came out at 46 / 56 / 103 ms with ratios 32x / 41x / 89x. That is the band the
README already records for its second session (41 / 55 / 101 at 34x / 45x / 80x),
not a new one, and two of three ratios held. Only full + HTML crossed the noise
threshold, and it moved UP while its absolute moved down — the recording path got
quicker too, from about 2 ms to 1.3 ms, so the deepest pipeline merely failed to
keep pace. That is the opposite of a faster-engine signature. The README gains a
paragraph recording the reading, because "a size-optimizing link pass and a wider
AOT profile did not move the ratio" is worth knowing precisely because both sound
like they should.

One correction to this branch's own recent history: the last few merge messages
said they rebuilt "through the full pretest". They did not. pretest runs the demo
checks, typechecks and copies already-built artifacts into dist/wasm; npm run
build is what compiles the WASM engine and the bundles. dist/wasm/Docxodus.wasm
was dated 2026-09-06T01:05 — the #702 merge — so #703, v12.1.0 and all three
history batches were verified against that engine rather than a fresh one. The
specs passed, but they did not test what those messages claimed. This merge runs
the real build: Docxodus.wasm 3.47 MB to 3.80 MB, framework total up 708 KB,
which is the history subsystem and the wider AOT profile arriving at last. The
measurement above is therefore of everything since #702, not of this range alone,
and it is reported that way.

Verified on the genuinely rebuilt engine: 61 redline node checks, 3 pin checks,
14 browser assertions.
Both of this branch's recurring merge traps fired in one range, and both were
caught by the checks that exist for them.

The release cut stranded this branch's entry again. Main moved its accumulated
"### Added" and "### Fixed" under a dated 12.2.0 heading; textually that is a
heading inserted above entries this branch also carries, so git auto-merged it
and left this branch's own "### Added" sitting underneath the released heading
with [Unreleased] empty above. The placement check prints the REDLINE THEATER
line only while it is still inside [Unreleased], and it printed nothing. The
entry is moved back; the released section keeps main's two entries untouched,
and the diff against main is purely additive with no deletions.

The re-pin conflicted in the one sentence that states page count and pin
together — "Seven ... 12.1.0" here against "Six ... 12.2.0" there. Each side is
right about the half it owns, so both: seven pages at 12.2.0. redline.html is
re-pinned here because main does not have that file, and only after jsDelivr
actually served docxodus@12.2.0/dist/embed.bundle.js with a 200. All eleven
references across the seven pages now read 12.2.0 and no 12.1.0 pin survives.

tools/mcp-server/ToolCatalog.cs changed again in this range (#732's portable-file
controls), so the catalog contract check ran before anything else, since it parses
that file and needs no build: 45/45. The new file actions are additive.

Rebuilt for real, because npm/src/history.ts, index.ts and embed.ts all changed:
Docxodus.wasm is 4.02 MB now, up from 3.80. Not re-measured. The only build-script
change in the range raises the wire-size budget gate from 5.0 to 5.25 MB to admit
the portable-history archive reader the browser bindings now reach — a budget
number, not a compilation flag, so nothing about how the engine executes moved.

Verified: 61 redline node checks, 3 pin checks, and 20 browser assertions — the 14
redline ones plus social-demo.spec.ts, the guard that proves the pages load the
pinned bundle rather than a 404, which is the one that matters after a re-pin.
… measured

Main's range c13ef75..f3985d9 lands #737-#740: durable browser checkpoints,
accessible version/collaboration controls, a portable-history editor example, and
a Doom ASCII performance pass. CHANGELOG conflicted with both sides adding bullets
under the same "### Added" — kept as a union, main's first, one copy of each, and
the diff against main has no deletions. ToolCatalog.cs is untouched this range, but
the catalog contract ran anyway: 45/45.

Rebuilt for real, because npm/src/ribbon.ts changed and three new history modules
joined the bundle. The ribbon change is the demo's own surface: a `require("save")`
that threw when the button was absent becomes a `control(...)` null check, so a host
that hides the file actions no longer breaks rendering.

Re-measured, because Docxodus/Internal/HtmlConversionOps.cs — the facade that owns
DOCX to HTML, which is exactly the "full + HTML" depth's conversion stage — gained a
memoization cache for dense-text pictures, and the AOT profile moved again. Reading
the guard suggested the demo could not reach the cache: it requires denseText.Count
== 1 and targets.Count == 1 before it even scans the body, and the body scan then
admits only plain-text elements, which a document carrying w:ins and w:del cannot
satisfy. But this branch has been wrong predicting performance effects before, so
the guard was checked by measuring rather than by reading.

The controlled method — fixed baseline and current, medians of nine, three reps, the
instrument that is actually build-comparable — says no move. revisions holds at ~34
ms and redline at ~51 against ~50; full + HTML reads ~100 against ~91, with this
session spanning 96-104 and the previous one 83-102. Those ranges overlap heavily,
so the deepest row sits inside the band already recorded rather than in a new one.
The published table is unchanged for the fourth time.

Verified: 61 redline node checks, 3 pin checks, 14 browser assertions.
…ared loader

Main's #741-#744 restructured the demo site: every host now boots the engine staged
beside it rather than a CDN pin, `npm/scripts/stage-web.mjs` replaces the hand-written
copy chain, and v12.3.0 and v12.4.0 were both cut. All three known conflict shapes
fired at once, plus a fourth thing that needed real work.

npm/package.json is NOT a union this time. Main replaced pretest's copy chain with
stage-web.mjs, which stages docs/demo by iterating the directory — so this branch's
four `cp ../docs/demo/redline*` entries are not merged forward, they are obsolete,
and taking main's build and pretest wholesale is what keeps the demo staged. Confirmed
by looking: demo-redline.html, redline-theater.js, mcp-wire.js and diff-stress.js all
land in dist/wasm from that loop. test:demo-logic is still a union — main's five plus
this branch's two — and wad2cart.test.mjs is dropped because main deleted it.

The changelog trap fired for the third consecutive release cut, this time on 12.4.0,
stranding this branch's entry inside the released 12.3.0 section. Rescued; one copy,
no deletions against main.

docs/demo/README.md is not the usual pin conflict either. Main replaced the whole
"N static pages ... pinned docxodus@X.Y.Z" opening with a description of the one-build
model, so this branch's sentence is not a half to preserve — it is obsolete prose, and
main's replacement is taken whole. The table row for redline.html survives and its
"same ?engine= split as its siblings" wording is corrected to name the shared loader.

docs/demo/redline.html now imports loadDemoEngine from ./engine.js like every other
host, dropping its jsDelivr default. That is required, not cosmetic: the rewritten
engine-pin check scans every docs/demo/*.html for a docxodus@X.Y.Z pin and fails if
they disagree, so a page left on 12.2.0 while the docs moved to 12.4.0 would break it.
It now runs four checks and passes.

One local test failure investigated and attributed elsewhere: social-demo.spec.ts's
"floating controls steer the game" times out here waiting for the arcade to reach
'playing'. It fails identically with main's own index.html in place of this branch's,
so the one line this branch adds to that page — a nav link — is not the cause; main's
CI is green on the same spec at cec4603. Environment, not this diff, and not this
branch's to fix.

Verified: 61 redline node checks, 4 engine-pin checks, 14 browser assertions.
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.

2 participants