Add REDLINE THEATER: an MCP-driven redline demo that proves itself - #615
Add REDLINE THEATER: an MCP-driven redline demo that proves itself#615JSv4 wants to merge 50 commits into
Conversation
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.
#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.
…cp-lcvm85 # Conflicts: # CHANGELOG.md
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.
…cp-lcvm85 # Conflicts: # CHANGELOG.md
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.
…cp-lcvm85 # Conflicts: # npm/package.json
|
CI note: the The failing test is 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 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 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.
A third demo page at
docs/demo/redline.html, with two modes. The Arcade painted frames into one paragraph throughraw.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
Three counsel negotiate a Master Services Agreement. Every edit that lands is dispatched from a real JSON-RPC 2.0
tools/callframe — the shapedocxodus-mcpaccepts 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_inlinemode, so each call writes nativew:ins/w:delinto 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 oflistRevisions(), 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.
proveRedlineReversibilityrebuilds two packages from the redline;docxDiffGetRevisionsindependently 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.xmland 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 writingw: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;
docxDiffComparewas 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.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:
revisionsdocxDiffGetRevisionsredlinedocxDiffCompareProducts→ package + revisionsfull + HTMLconvertDocxToHtmlA 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
redlinedepth usesdocxDiffCompareProductsrather thandocxDiffComparefollowed bydocxDiffGetRevisions, 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
mainrepeatedly 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:docxDiffCompare278 → 153 ms.revisionsheld inside the noise band — a saving that grows with pipeline depth, which is what a load-path change looks like.WmlToHtmlConverterspeedup every row read 5–15% slower, which nothing in that merge could do —revisionsnever calls the converter. Decomposing said the opposite: the conversion stage alone went 154 → 143 ms, cheaper, exactly as Run the actual Doom engine as a fourth cartridge, keeping the E1M1 raycaster #620 intends. That reading was the machine on a different day, and it is labelled as such rather than pinned on the nearest PR.revisions143 → 52 ms,redline170 → 74,full + HTML324 → 141;docxDiffCompareProductson fixed inputs 125 → 51, and the conversion stage in isolation 144 → 60. Uniform across depths again, but the shared thing is the .NET execution underneath them rather than a code path — which is why the ratio against the recording path moved (68–157× → 33–78×) instead of holding: recording got faster too. A claimed 2× got more evidence than a single session, both methods agreeing and the controlled figures pooled across two.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.mdasks 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.jsand 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-serveris a .NET process on stdio, and a browser cannot subprocess it. Sodocs/demo/mcp-wire.jsreimplements the server's front half — envelope parsing, the (tool, action) routing table, thecontent[].text+isErrorresult shape, and the convention that a business-level failure is a tool result rather than a JSON-RPC error — overDocxSessionvia 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.mjsparses the shippedToolCatalog.csand fails if any (tool, action) pair, searchmode,set_modevalue, or argument the script puts on the wire is absent from the real catalog. It earned its keep immediately, catching two mismatches:atomic, which the catalog callsmodewith an enum;set_modewire names (accept,render_inline) passed straight tosetTrackedChanges, which takes theTrackedChangeModeenum — 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_openwith 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/insertEndnoteunderrender_inlinerecording 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. UnlikeApplyListFormatRangeunder 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:inscitation 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. Thetracked_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.InsertFootnoterefuses that outright, which is the right answer. The script therefore searches the clause's untouched tail and cites past it, using abindOffsetstep 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
resultMutated()now decides, and it is 13 repaints for 31 calls.docs/demo/tools/but runs fromnpm/, so a bareimport '@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 atdocxodus_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
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 carryingw: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).docs/demo/tools/redline-theater.test.mjs(45: frame shapes, bindings, script validation, telemetry, verdict logic, the catalog contract) anddiff-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).mainmerge against a rebuilt WASM engine — including the one that removed theWmlComparerengine 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.CHANGELOG.mdfour times (both sides adding under[Unreleased], both kept),npm/package.jsononce (both branches extendingpretestandtest:demo-logic, taken as a union and verified file by file), anddocs/demo/README.mdonce (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-boundaryconfirms none of this ships in the npm tarball.A note for anyone editing the script:
## 8. Governing Lawloses its8.to the markdown ordered-list parser once the##is stripped, so section headings are written asSection 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=stressfor the second) andframes-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