cytoscape.js v4 HEAD · 47a5c66d · built 2026-09-16 14:48Z

WebGPU model + renderer prototype (#3486)

This file is the development record, not an introduction. It is written in rounds: a plan section per round, a landed section beside it, and — the part worth reading if you read nothing else — what measurement said when the round was wrong about itself. Two shorter documents exist for the two other jobs: src/README.md is the maintained scope and design-decisions doc, and EXECUTIVE_SUMMARY.md is the five-minute version of this file for someone who will never open it.

Where the rounds are (round 108.2): each ## section of this record is its own file under plan/rounds/, named YYYY-MM-DD-NN-rndRRRR-kind-description.md — when it was written, a counter among the sections sharing that date, the round it is about (rnd0000 for the sections that are not rounds) and its kind (plan, landed or note) — and indexed in plan/INDEX.md (generated — npm run plan:index). The name carries the bookkeeping so the ## heading can be a title (round 108.9): headings read ## Background images, not ## Round 15 plan — background images (planned 2026-08-01), and the index reads the round, the date and the kind off the filename rather than parsing them back out of prose. This file keeps only the parts that are maintained rather than appended to, which is what makes it readable in one sitting; the record itself had reached 1.5 MB, past what any reader — and any coding agent — can open. A new round is a new file in plan/rounds/, then npm run plan:index. The status site still publishes the whole thing as one page, assembled from this file plus every section in order.

Two sections here are kept current rather than appended to, and they are where to start: "Process" (the rules every round is held to) and "Open calls for the maintainer" (every question waiting on a decision, plus the ideas logged for later).

Status: implemented and evolving on the v4 branch (started as feature/webgpu); the base pass was 11 commits, e30542cf4..9b177c193.

What happened, and where to read it (round 108.8). This file used to open here with a round-by-round prose summary of the work. It is gone, and its removal is the point rather than a tidy-up: it was a third copy of a history two better-kept documents already hold, and the only copy nothing obliged anyone to update. It had stopped at round 64 while 44 further rounds landed behind it — so the record opened by summarising itself up to a point it no longer reached, which is worse than not summarising itself at all.

  • The five-minute version is EXECUTIVE_SUMMARY.md, which AGENTS.md requires be rewritten from the record whenever a round closes. That ritual is why it is current and this was not.
  • What a particular round did is in that round's own file, found through plan/INDEX.md — generated, so it cannot go stale the way prose does.
  • What v4 is, as opposed to how it got here, is src/README.md — the maintained scope, deviations and design-decisions doc.

Nothing was lost: every sentence the summary carried about a round is in that round's own file, verbatim since 108.2.

v4 is not close to a release, and this record is not a route to one. The rounds on file are the currently documented set, not a plan for everything v4 needs before 4.0: several rounds are known to be needed and are not logged yet, and four of the rounds that have shipped — 43, 46.5, 55 and 56 — were each inserted after the sequence they interrupt was already planned. Rounds 55 and 56 are the pattern to expect rather than the exception: both began with a maintainer opening a page and seeing something no test in this repository could, and both found more than they set out to fix.

Read "what remains" as an inventory of what has been written down; it is not an estimate, and a round's absence from it is not evidence that the work is absent too.

The reason this needs saying at all is that a per-round record reads optimistically by construction. Every round closes green, so a file made of closed rounds reads like a finished thing — and the phrases that accumulate in it ("the autonomous shelf is clear", "what remains is calls rather than effort", "v3's arrow vocabulary is complete") were each true about their own scope and say nothing about the distance to 4.0. They are kept as written, because each is accurate history; this paragraph is here so the sum of them is not mistaken for a claim.

src/README.md is the maintained scope / deviations doc; the files in plan/rounds/ record each round's plan and outcome.

Process (applies to all work under this plan)

  • Isolated commits as you go. Every item lands as its own commit(s) with a detailed message — never batch unrelated changes, and never leave a round's work sitting uncommitted.

  • Docs travel with the code, every commit. Each commit updates src/README.md (scope / deviations / design decisions) and the record — including the logbook: the round records ("Landed (round N)" sections and their verification notes) are written or amended in the same commit as the work they describe, not batched at the end. Since round 108.2 a round's plan and its landed record are one file in plan/rounds/, so amending it is an edit to a small file rather than a blind append to a 1.5 MB one; when a planned round lands, rename its file's plan field to landed. Run npm run plan:index after adding or renaming a section, and test/modules/plan-record.mjs fails the build if the index has drifted — or if a heading has gone back to labelling itself with the round, the date or the kind that the filename already carries.

  • The kind field is the round's state, for every round (rule made enforceable 2026-08-26, round 111). Round 111 renamed the 33 rounds that had shipped inside a file still named plan, merged the five pre-108.2 rounds that had a separate plan and landed section into the one-file shape, and re-kinded three planning sweeps to note. So the rule has no exceptions left, and three gates hold it: a plan file may not record its own landing (a self-declaration, or a checklist with nothing left open), no round may be filed as both a plan and a landed record, and plan/INDEX.md publishes the derived state — a round is landed once a section names it so, its own or one of its sub-rounds'. A round may land with an item held for a call; its file says which, and the round is still landed.

  • The closing sweep checks git worktree list (rule added 2026-08-24, round 108.4). An agent worktree left behind after a round is not harmless: one abandoned tree under .claude/worktrees/ put 141 of 278 files into npm pack and turned test:modules red, because it holds a whole second copy of the repo. Remove a landed round's worktree when the round closes.

  • EXECUTIVE_SUMMARY.md is rewritten from the record when a round closes (rule added 2026-08-05, round 46.5). That file is the five-minute version for a reader who will never open the record — organised by calendar week, in outcomes and decisions rather than rounds and file names. It is derived: the round files stay the source of truth, and nothing is recorded there that is not recorded in them first. Since round 108.8 it is also the record's only cross-round narrative, so this rule is the single thing keeping that history current — a missed rewrite is how the summary this file used to carry came to stop at round 64. Three things make the rewrite honest rather than mechanical. Restate, do not append — a new week is not the only thing that changes, because later rounds routinely reveal what an earlier decision actually meant, and the z-index and animation-queue entries were both re-described after the fact. Re-measure every number rather than copying one forward; test tallies, member counts and benchmark figures are the first things to go stale, and this file has a documented history of sentences that were true when written. And an open question leaves the summary's table when it is decided, not when it is scheduled — the distinction this file's "Open calls for the maintainer" section exists to keep.

  • A closing docs sweep ends every round (rule added 2026-08-01, after the post-round-19 sweep caught drift the per-commit rule had missed). Per-commit doc updates track the sections a change obviously owns; the long-lived overview sections — the directory layout, the follow-up/open-hooks lists, the README header and "Follow-up hooks", cross-references like "still open"/"remains" — belong to no single commit and drift silently.

    So once a round's last item lands, sweep both docs end to end before calling the round complete: grep for the round's own vocabulary and for staleness markers ("open", "remains", "planned", "not yet", stale counts and file lists), verify every section the round touched reads true, and land the fixes as the round's closing docs commit. Sweep this file too, not just the README (amended 2026-08-02, after round 27's sweep did the README end to end and left PLAN.md's own gap ledger asserting that shape keywords, compound arrows and numeric text-rotation were still unbuilt — the round had just built all three).

    The blocks that drifted worst were the standing summaries of what remained — a gap ledger whose per-item "Still open:" lines outlived the work that closed them, and a "Gaps with direction already set" list that went seventeen rounds without a sweep, seven of its eight entries still describing landed work as pending and its z-index entry still promising to restore zDepth/sortByZIndex two days after z-index was dropped outright. Those blocks are gone (round 108.8 took the last of them), which removes the drift by removing the duplicate: what remains now lives once, in "Open calls for the maintainer" below and in EXECUTIVE_SUMMARY.md. Sweep those two. A round record is not the whole record.

  • Anything needing the maintainer's decision goes in "Open calls for the maintainer" (section added 2026-08-03), not only into the round record that found it. That covers scope calls and contradictions — code that disagrees with this file's decided-design ledger, which autonomous rounds accumulate. Log a contradiction; never silently patch it: removing or keeping public API is the maintainer's call even when a ledger entry looks like authorization to delete. Sweep that section with the rest of the file.

Open calls for the maintainer (kept current)

Rounds 10–29 ran largely autonomously, and some decisions got absorbed into parity work without a human in the loop. This section is the one place those surface: every open question that needs the maintainer's call, with the evidence and what changes once it is answered. It is swept with the rest of this file at the end of each round — an item leaves only when the call is made, and a round that discovers a new one adds it here rather than burying it in its own record.

Entries are of three kinds: contradictions — places where the code and this file's own decided-design ledger disagree, usually because an autonomous round kept or added something the ledger says was dropped — new open calls raised by a round, and logged ideas, raised but not scoped. Contradictions are logged, never silently patched: removing public API is the maintainer's call even when a ledger entry appears to authorize it.

Scope calls — work deliberately not done pending a decision — were a fourth kind, and the subsection is gone because all eight of its entries had been taken and executed. Item numbers are stable identifiers and are never reused, so the gaps below are deliberate: a round record citing "item 12" must keep resolving to item 12.

As last swept (2026-09-15, round 125 planned), the genuinely open questions are still items 18, 23 and 27 — the three the ninth design sitting (2026-08-10) left open, none of which a round has taken since. The 2026-09-01 sweep added item 54, the benchmark rows the performance review could not screen; round 116 took item 55's three calls, left only its round-102 measurement note on the item, and added item 56, the convergence price of size-aware repulsion — taken by round 117 the next day (the sim half of avoidOverlap is opt-in, on the measurement the item asked for), which added item 57: the settle's separation hands back more overlap than it finds on the 25k scene — taken by round 118.1 on the per-stage measurement the item asked for (an expansion stage for the crammed case, and a best-state guard; the measurement is on the round). Round 119 (2026-09-08) took no call and raised none: the maintainer's first sitting in front of 118's page found the GPU force executor's iteration cost, and the round measured and fixed it (item 54(e)'s rows are re-dated below); round 120 (the same day) gave the smallest components canonical shapes and the page its EM combo, and raised none either; round 121 (2026-09-09) took the five follow-ups 120 left — the grouping and the order on the library, the orientation, the mixed component, the labelled row, the priced batch — and raised none. Round 122 (2026-09-09) took item 49, the layout portfolio audit: the demand read from the two apps' repositories, the matrix, the flagships named, the item closed — and logged item 58, component packing on the discrete layouts, the one gap the audit found — taken by round 123 the same day, on four calls the maintainer made at planning. Round 124 (2026-09-10) took item 59, taxi tracks, on the four calls decided at planning. The 2026-09-15 sweep added item 60, the layout quality audit the maintainer marked Partial in the feature inventory on 2026-09-14 — planned as round 125, nine sub-rounds each gated on a maintainer review sitting — carried out the same day, every fix on the record then the first sitting pass the same day: one sub-round signed off, radial's roots decided and shipped, the rest deferred to the page — which raised item 61 (one consistent option surface: spacing, compacting, the bounding box as hint or constraint) and item 62 (AVSDF, reconsidered). What the 2026-08-26 sweep changed: item 22's decided action was finally done (the comment, sixteen rounds after the code), item 32's first measurement was taken and is recorded on the item, and two entries were added for calls that had been living only in round records — item 52 (round 109's residual flake) and item 53 (the round-90 worktree's unmerged branch, raised by round 108 and carried in its record alone). Item 51 had already been lifted out of round 86's record on 2026-08-26.

The sitting-by-sitting history — the fifth through ninth design sittings, which round executed what, and the running count of open questions after each — is not repeated here. It is in the design-sitting records under plan/rounds/, and it was the clearest example of what this file kept badly: the log that stood here stopped at round 64, and no later round updated the count it kept restating. A taken call now leaves this section, which is what the rule above always said.

Contradictions between the code and the decided-design ledger

Each was found by reading the code against this file (rounds 28–29's docs checks), and each is left in place pending the call.

  1. arrow-scale is quantized to 1/16, and it is not only readback (round 56, 2026-08-07). edge.arrowShapes stores the scale as an integer x16 in its top byte (round 13 B7). The ledger has described that as "quantized readback — recorded" since; measurement says the scope is wider. The drawn head takes its size from the quantized value, and since round 56 so do v3's gap and spacing, so an arrow-scale that is not a multiple of 1/16 is wrong in geometry as well as in what style() reports.

    Measured at arrow-scale: 1.4, which quantizes to 22/16 = 1.375: the triangle gap is 19.25 against v3's 19.6, the circle spacing 12.260282 against 12.483196, and the straight midpoint 120.187500 against 120.100000. 1.8% small on every arrow quantity. Deliberately not dodged by giving the routing scenes a representable scale: the seven residual fields are pinned in routing-ledger.mjs with two-sided bands, so re-quantizing the field fails them and forces a re-measurement. The call, and why it is one: the contract's own note says the escape hatch is that "the scale byte can be re-quantized", and there are six reserved bits (18..23) sitting next to it.

    Spending them takes the scale to 14 bits at x128 — 0.11% error instead of 1.8% — but forecloses a 17th arrow shape, which the same paragraph names as the other claim on that span. Which of the two the reserve is for is the maintainer's call, not a round's. (Note the third option: leave it. v3's own arrow-scale is a presentation knob and 1.8% of an arrowhead is sub-pixel at most zoom levels — the reason this was invisible until a close-up scene existed.)

    Deferred by the maintainer (2026-08-07): logged, not decided. No round may spend the reserved span until this is answered — which is why round 56's own need for two flag bits was met from edge.width's mirror lane instead, leaving all six bits of the real column intact.

  2. v4's edge underlay/overlay band is width + 2 × padding wide; v3's is 2 × padding (round 58, 2026-08-09). Found by a parity scene draft: underlay + small heads on a short edge read 7.105% mismatch, and the diff was band-width along the whole span, not the trim the scene was named for. Measured against v3/src/extensions/renderer/canvas/drawing-edges.mtsdrawEdgeOverlayUnderlay sets context.lineWidth = 2 * padding, nothing else — so at v3's defaults a padding smaller than half the line width draws a halo narrower than the line, i.e. invisible. v4's round-13 A2 formula (the edge geometry stroked at width + 2 × padding, recorded in src/README.md) matches the node overlay's semantics (the node's own extent plus padding) and always shows the halo. The casing (line-outline) is unaffected — both libraries agree on width + outlineWidth. The call: keep v4's formula as a deliberate deviation (arguably the saner semantics, but a visible difference in any app that styles edge overlays), or match v3's. Logged rather than patched: either direction changes rendered output, and the edge-layers golden and the round-13 A2 zoom-1 parity scene (which passes today because its padding values happen to dominate the width term) both move if the formula does. Related recorded deviations, unchanged by this: v3 rounds these strokes' caps where v4 butt-cuts, and v3's erase-only compositing where heads overlap.

New open calls (sixth sitting, 2026-08-06)

Questions that had been living only in round records — against the standing rule that every open question surfaces here — plus the conditional halves of calls the sitting took. Item 17's condition was resolved by measurement and its entry has left.

  1. Warm the tween pipelines at init? (round 53.1's left-open judgement.) A user's first animate() on a software adapter stalls up to ~1.8 s because Dawn compiles the tween compute pipelines on first use; warming them at init moves that cost into startup, which round 53 just spent effort reducing, and real hardware shows the same shape an order of magnitude smaller. Call taken (2026-08-06): logged, revisit with data — the case for warming rests on software-adapter users, so measure who those are before paying startup for them.

    The maintainer also noted a larger future direction to keep beside it: a WebGL fallback renderer may be worth considering for users whose platforms cannot support WebGPU at all — logged as a direction, not scoped.

  2. v3's derived parent box is 1 px larger per side than v4's (round 55, 2026-08-06). Measured on a parent with two 30x30 ellipse children and padding 10: v4's box is the children's union plus padding exactly (120.00 wide), v3's is that plus 1 px on each side (122.00), and the gap does not move with border-width — 0, the default, and 4 all give 1.0 px per side. So this is not the border/miter difference src/README.md records, and that note is incomplete as an explanation of parent-box differences.

    Everything on an ancestry edge follows from it (v3 builds compound control points from min(pos − outerW/2)), and it is what the round-55 routing harness reports as a flat 1.000000 on every field of p-child, p-grandchild and child-p. Call taken (2026-08-06): keep v4's tighter box; the deviation is recorded, and so is the reason v3 has the pixel. The maintainer supplied what the measurement could not: v3's extra pixel exists because v3 caches elements as textures and composites them through canvas2d, where antialiasing makes the true extent uncertain by about a pixel — the margin is a rendering allowance, not geometry.

    v4 has no per-element textures and rasterizes the whole scene on the GPU, so it has nothing to allow for. That also resolves why the gap was invariant to border-width: it was never a border term. This settles the question src/README.md had been describing incompletely, and it agrees with the direction already set — round 54 tightens compound bounds further, and item 16 ratified the tighter compound fit(). Not yet isolated, and deliberately not claimed: the larger divergences in the same scene (parent-parent 16.04 px, leaf-parent 8.07 px) are consistent with the same 1 px amplified where an endpoint clips a box at a shallow angle, but that has not been demonstrated.

  3. sourceEndpoint()/targetEndpoint() answer the node centre on a straight edge (round 55, 2026-08-06). v3 answers the node boundary (its rs.arrowStartX/Y, the spacing-shortened arrow point).

    v4's Collection._endpointPoint falls through to the raw node positions whenever there is no curve eval and no route, which is every straight edge — so the answer is off by a whole node radius, measured as 13.207 px on a 30x30 node at the harness's generic slope, and by exactly the boundary offset in every straight row of every scene. renderedTargetEndpoint() is how applications place custom overlays, so this is visible, not theoretical. Call taken (2026-08-06): match v3. Landed the same day. GraphStore.straightEndpointAt resolves the boundary along the chord — the CPU twin of the straight arrow shader's own tip placement, so the accessor reports the point the renderer draws to. The one v3 term deliberately left out is the arrow shape's spacing, non-zero only for tee and the circle heads: it arrives with the gap/trim port, so the accessor never describes a point the renderer does not draw. Effect on the routing harness's arrows scene: 14 diverged fields to 4, and the residual is exactly that spacing term — circle by 9.880383 (getArrowWidth(5, 1.5) x 0.15) and tee by its constant 1.0, with the other five heads clean. The base, families and bundles scenes went fully green and lost their test.fail() markers. One prediction in that plan was wrong and is corrected here: the Playwright probe at renderer.spec.js:4516-4546 was expected to break and did not, because it exercises a manual endpoint (12c), which takes the route path rather than the straight fall-through this changed. Only test/collection-dimensions.mjs needed rewriting.

  4. Hollow and translucent mid arrows keep showing the line (round 55, 2026-08-06; still open after round 56, which landed the endpoint trim and left mid arrows exactly as this entry describes). The fix for endpoint heads is to stop the line short of the head, which reproduces v3's destination-out erase at zero runtime cost. A mid arrow sits mid-line, where a trim cannot reach, so it is not covered. Call taken (2026-08-06): punt and log. Record it as a deviation, and note the maintainer's lean — v4 may simply not support arrow-fill: hollow on mid arrows, in which case the deviation becomes a documented drop rather than a defect.

    Revisit with a scene that measures how visible it is; the round's own cautionary case is that the filled-head gap looked dramatic and measured 0.495%. The options priced, if it is ever taken up: collapse the strip quads inside the mid arrow's arc-length window (no extra draw, but the edge vertex shader then needs the mid shape ids, which pushes toward the heavier trim carrier), or build the erase pass for that case alone.

  5. edgeHitsBox keeps its straight-edge approximation (round 55, 2026-08-06). Round 56 shortened the drawn line, so the comment claiming containment and box selection "agree about where the edge is" is now inexact, as this entry predicted. Call taken (2026-08-06): keep the approximation, amend the comment. It backs box selection, where a stub of an edge near a node is not a distinction a user is making, so the cheap and simple answer is the right one on UX grounds. The comment is the thing to fix, not the code — this entry exists so that a later reader finds a decision rather than an inconsistency. Done 2026-08-26, and the delay is the lesson: the call was taken on 2026-08-06 and the one-line edit it authorized sat undone through sixteen rounds, because a ledger entry that reads as decided is read as finished. The comment at graph-store.mts now states the agreement the two modes do have, the round-56 trim neither accounts for, and why the approximation is kept.

Logged round ideas (raised, not scoped)

Directions the maintainer has raised that are neither open questions nor scheduled work. They live here rather than in a round record for the standing reason the ledger exists — an idea recorded only where it was raised is an idea nobody finds — and each carries the first thing that would have to be measured, so picking one up starts from a question rather than from a blank page.

  1. Split the big implementation files, v4's way (raised 2026-08-07). style.mts is 7.9k lines, collection.mts 5.8k, store/graph-store.mts 5.0k, render/shaders.mts 4.3k, core.mts 3.1k. v3 split its collection into v3/src/collection/* (traversing, data, dimensions, events, …); v4 has already done the same thing once, for src/algorithms/ — one file per algorithm over a shared algo-shared.mts, slot-native, no prototype patching. The constraints are what make this a round rather than a refactor anyone can do in an afternoon, and all three are things this file has already been bitten by:

    • The audits read files as text. PUBLIC_API is a file list, and auditFile walks class bodies; a split done v3's way — assigning onto a prototype from several modules — would make every moved member invisible to coverage, @param, @returns and @throws at once, and the gates would keep reading 100%. That is round 57.2's lesson before the fact. Whatever shape is chosen has to keep the members inside the class, or the audits have to learn the new shape first.
    • The // -- section -- banners are the docs generator's section grouping (rounds 26 and 45). A split has to decide whether a file boundary is a section boundary; if it is, the generator gets simpler, and docs-generate has a spec that will say so.
    • git blame and this file's own round records point into these files. A move is cheap to make and expensive to have made carelessly; round 42's method (compare every moved file against its pre-move blob, and filter the diff to the changes the round is allowed to make) is the one that proved a 1100-file move behaviour-neutral, and it applies here unchanged.
  2. A worker-pool executor for the per-source-parallel algorithms (raised 2026-08-10, round 65.10 — the maintainer asked what the GPU-modest algorithms could gain from wasm/SIMD/threads, and this is the piece that survived the analysis). Weighted betweennessCentrality is contracted CPU-only (Brandes over weights needs a priority queue) and is n independent Dijkstras — embarrassingly parallel across sources. A pool of plain workers (Node worker_threads / browser Worker) with transferable typed arrays needs no SharedArrayBuffer and therefore no COOP/COEP isolation — our algorithms are snapshot-in/result-out — and it can keep the CPU executor's bit-reproducibility: partition sources into contiguous ranges and combine per-node sums in range order, and the f64 summation order matches the sequential reference exactly, a property no GPU executor offers. It slots behind the round-65 executor contract ('auto' picking workers where no adapter exists), with the pool cached like the GPU device. Deliberately not wasm: SIMD reorders float sums (a third numerics tier), wasm threads inherit the SAB isolation constraint plain workers avoid, and a new toolchain fights code standard 7. First measurement before building: weighted betweenness at n=2048 through a 4- and 8-worker pool against the sequential reference on the same box — proceed only if the speedup clears ~3× at 8 workers after pool-startup amortization, and re-check the per-worker copy cost of the CSR against SharedArrayBuffer before concluding SAB is unnecessary.

Items 30–50 entered 2026-08-19 — a brainstorm sitting swept against the scheduled rounds 71–97, so nothing below duplicates a scheduled round. Four asks raised the same day were already covered and are pointed at rather than re-logged: annotations are round 81, the table/filter affordances are round 84, the WebGL2 fallback is round 73 (whose plan now carries the sequencing note raised today), and the create-react-app-style extension scaffold is round 71's cyext init. Two of the sitting's other candidates already live in this file and stay where they are: the file split is item 26, and per-component discrete layouts / layout-lifecycle unification / the bulk discrete-animate tween are round 87's "suggested further directions".

  1. Golden coverage, enumerated — what no golden sees (raised 2026-08-19). The visual suite is ~45 goldens plus the parity scenes, and this repo's most repeated visual-testing lesson is that a golden only measures what is not painted over. Nothing enumerates the inverse: which style properties have no pixels riding on them at all. The round builds the enumerator the audits already model (bench-coverage.mjs's shape): instrument the style engine while the golden scenes render and record which (property, non-default value) pairs are exercised, then diff against the full compiled-property list. Second tier, for the exercised-but-occluded class: a scripted degrade control — reset one property to its default, re-render, count pixels moved — run over a chosen subset, since a property that moves zero pixels when deleted is decoration in that scene. First measurement: the count of style properties for which no golden scene sets a non-default value. Expect the number to be embarrassing; rounds 27/55/56 each found scenes measuring nothing, one property at a time.

  2. Scripted gesture traces — the interactions get parity scenes (raised 2026-08-19). Goldens cover static frames; gestures are verified by Node specs plus a person driving debug/. The round adds a Playwright tier that replays recorded pointer traces — drag, box select, wheel zoom, pinch, cxt press, grab-and-throw — against both renderers on parity.html and diffs end states numerically (routing.spec.js's method, not pixels): positions, the selection set, pan/zoom. Round 89.3 already builds real-browser gesture driving for cursors; this generalizes its machinery. The known traps are already written down: mid-flight assertions poll (never sleep-to-offset), and the frame driver is load-bearing under SwiftShader. First measurement: the inventory — which gestures have any browser-level assertion today, and which have only headless synthetic-event coverage.

  3. The benchmark coverage audit graduates (raised 2026-08-19). bench-coverage.mjs stays report-only deliberately — it is heuristic where the gated three are not — so graduation is not "flip it to gate": it is round 30's triage applied to benches. Every public member ends in one of three states: a row that discriminates, a keyed exemption with a reason (audited, so a stale entry fails — the throw gate's rule), or a recorded not-perf-relevant call. Only after the triage does the audit gate at zero, and only if the heuristic's false positives were fixed rather than exempted around. First measurement: the current uncovered count and its composition — how much is one-line trivia versus real hot surface — which decides whether this is a round or an afternoon. Taken 2026-08-26 (node scripts/bench-coverage.mjs --verbose): 331/394 callable members, 84.0%, so 63 uncovered — and the composition says the triage is an afternoon, because most of the 63 are not what the item assumed. Fifty-six sit in three files that no benchmark calls by name because applications do not either: animation.mts (29 — Animation and AnimationManager internals: gpuEligible, settleGpu, demoteGpuAll, onCompacted), style.mts (22 — StyleEngine internals: applyBulk, refreshMapped, paintInputs, arrowBase), and event.mts (3, constructor included). Every one of those is exercised by benchmarks through the public call that drives it, which is precisely the under-detection the script's own header warns about — so they are exemptions with reasons, not missing rows. What is left is small and real: five Collection members with no benchmark at all (breadthFirstSearch, depthFirstSearch, randomWalkWithRestart, heatDiffusion, plus the constructor), buildColumnar, LayoutContext.eles, and three constructors. So the round is: four algorithm rows, then an exemption list with a reason each, then gate at zero — and the gate's real content is the exemption list, which is the part that rots.

  4. Mutation testing as the automated control (raised 2026-08-19). The repo's most productive habit is the hand-made control: break the behaviour deliberately, watch the spec fail. The round automates the cheap subset — swap comparison operators, negate guards, delete throw branches — over one subsystem at a time, runs the narrowest suite that claims to cover it, and reports mutants no spec killed. Constraint that makes it a round: runtime. Whole-tree mutation over npm test is days; per-file scoping with narrowed test selection is the design problem, and the tool must respect the .mts-via-.mjs import convention. First measurement: mutation-survival rate on src/style.mts (the largest file, 7.9k lines) under test:js alone — the survivor list is the finding, whatever the tooling verdict.

  5. A renderer soak tier (raised 2026-08-19). test:soak churns the model; nothing churns the GPU side for hours. The round adds a browser soak: repeated add/remove/restyle/zoom cycles on a real adapter, tracking the renderer's own allocation ledger (buffer bytes, atlas pages, realloc counts — Dawn exposes no VRAM meter, so the instrumentation is ours), plus repeated device-loss/recovery cycles where 48.5 does one. Growth that trends is the failure, exactly as in the Node tier — and the first spec must be the probe's own control (a deliberately-leaked buffer must show), or every spec after it passes by doing nothing. First measurement: tracked allocation totals over 10k add/remove/restyle cycles at fixed graph size — flat or not.

  6. The scale ceiling (raised 2026-08-19). The largest thing v4 has ever rendered is the 465k-edge ndex-x-large. Nothing records where it actually breaks — 1M? 5M? — or which subsystem fails first: ingest, style apply, the curve blob, the atlas, cull, pick, or a device limit at buffer creation. The round builds a synthetic-growth harness (the debug/ generators are the seed) that bisects to first failure per subsystem, documents the ceiling in src/README.md as a measured number rather than a hope, and raises the cheapest limiter. Companion to item 36; likely the same harness. First measurement: the bisect at 1M/2M/5M edges on the RX 580 — failure mode and owning subsystem for each.

  7. The VRAM budget, and failing gracefully (raised 2026-08-19). Device loss is tested; allocation failure is not — nothing defines what a createBuffer failure mid-session does today (probably an unhandled error or a lost device, neither chosen). The round prices bytes-per-node/edge/label across the pipelines (measured, not derived from the contract), publishes the figure, and designs the degradation order — plausibly labels first, then charts/images, then curve subdivision — so an out-of-memory frame degrades instead of dying. v4 fails loudly by design; this is the one place "loudly" should mean "visibly worse", not "gone". First measurement: force an allocation failure on a real adapter and record what actually happens now; then the per-element byte price at three graph sizes.

  8. Accessibility (raised 2026-08-19). A canvas renderer is invisible to assistive tech, and no scheduled round touches it. Scope, in priority order: keyboard navigation (a focus model — tab into the graph, arrows traverse neighbours, keys pan/zoom); a generated DOM/ARIA mirror describing the focused element and its neighbourhood (scoped — mirroring 465k elements is not a plan); a renderer-drawn focus indicator (the pick-ring look is the obvious carrier); and prefers-reduced-motion honoured by the tween layer (collapse animated transitions to their end states). The ecosystem apps have had to solve none of this because the library never gave them a substrate — which is the tenth sitting's demand-signal argument in reverse. First measurement: a screen-reader transcript over debug/index.html (predictably: silence), and an inventory of what Cytoscape Web built or skipped for a11y.

  9. Label internationalization — CJK first (raised 2026-08-19; the maintainer's priority order: CJK is highest after Latin). The label pipeline shapes Latin-simple text: no CJK line-breaking, no bidi/RTL, unknown emoji behaviour, and a glyph atlas sized for alphabets rather than for the thousands of distinct glyphs a CJK graph carries. The CJK tier is tractable precisely because CJK needs no shaping: the work is (a) atlas capacity — growth and eviction policy under thousand-glyph label sets; (b) line-breaking without spaces — break-anywhere plus the kinsoku prohibition classes; (c) a font fallback chain, since the vendored Open Sans has no CJK and today's fallback behaviour is whatever canvas-2D rasterizes. RTL/bidi is a separate later tier — it does need shaping, which means a real dependency decision against code standard 7 — and emoji/color fonts later still. First measurement: render a CJK-labelled fixture beside v3 in the harness and catalogue what is wrong; count atlas pages consumed by ~500 distinct CJK glyphs.

  10. Lasso selection + public spatial queries (raised 2026-08-19). v3.30 added lasso; v4 has box selection only (no lasso anywhere in src/). The columns are CPU-canonical and the cull/pick tier already answers box and point questions, so a polygon-containment query is an extension of what exists, not a new index. Pair it with the API it implies: public spatial queries — nearest node, k-nearest, elements-in-polygon — beside round 75.4's public sync pick, since every app that wants lasso wants those next. First measurement: what cull.mts/cpu-pick.mts already provide toward polygon containment, and the cost of point-in-polygon over 100k nodes at a realistic vertex count.

  11. Compound drag-and-drop reparenting (raised 2026-08-19). The compound-drag-and-drop extension is a standing ecosystem bolt-on, and v4 owns everything it needs: the hierarchy lives in store/hierarchy.mts, move({ parent }) exists, and the drag gesture is ours. Scope question to settle first — primitives or gesture: drop-target resolution during drag + a preview affordance (primitives an extension composes), versus the full gesture in core. The v3 extension is the requirements document: enumerate what it reimplements versus what it would call. First measurement: that enumeration, plus what drop-target resolve costs per pointermove at depth (it is a point query against parent boxes the hierarchy already maintains).

  12. Undo, on the columnar store (raised 2026-08-19). Every editor app builds an undo stack over this library; cy.batch() exists but no history does. The columnar store is uniquely placed: a snapshot is typed-array copies (the wire format already serializes columns), so the design fork is snapshots (simple, memory-priced) versus an inverse-operation log (cheap per-op, but every mutating API — data, bypasses, hierarchy moves, element add/remove, viewport? — must emit its inverse, which is a completeness obligation the audits would have to learn). Batch boundaries are the natural transaction marks. First measurement: snapshot cost — bytes and milliseconds — at 100k elements, and restore cost; that number decides the fork before any API is designed.

  13. Viewport constraints (raised 2026-08-19). Min/max zoom exist; pan is unbounded, and "keep the graph on screen" is a perennial app-level reimplementation (the #1905 family's other half — round 75.5 takes the wheel toggles, not bounds). Scope: an optional pan/zoom constraint — clamp the viewport to the graph extent plus margin — with the semantics question being where the clamp lives (every viewport writer funnels through the camera; gestures, fit, animations and the constraint must agree, and the gesture feel — hard clamp versus rubber-band — is a design call). First measurement: the closed-issue inventory of what was actually asked for, then the writer inventory — every code path that sets pan/zoom, to confirm one funnel exists.

  14. The wire format goes public (raised 2026-08-19). Round 46.5's binary format is 2.7× smaller at rest than JSON and fuzz-hardened since 48.3, but it is an internal fixture vehicle: undocumented, unversioned, no public API. Promoting it means: a version/feature-flag header and written evolution rules (the format becomes contract — columns must be stable across releases or negotiated), a documented layout, a schema home beside round 79's, and public save/load on the core. The cost is the commitment, not the code. First measurement: what the current encoding lacks for evolution — is there any version field at all, and which column ids would break if the contract reordered.

  15. A v3→v4 codemod (raised 2026-08-19). MIGRATING.md is prose, but its property table is machine-checked (test/modules/migration-guide.mjs) — a codemod can be driven from the same data, so guide and tool cannot drift apart. Scope: the mechanical 80% (renames, option moves, event-name changes, style() → bypasses spellings), with the non-mechanical remainder flagged, not transformed — selector strings to the mapper DSL and style functions are design decisions, and a codemod that guesses them is worse than one that points. First measurement: run the candidate transform over v3's own documentation snippets and one real app's source, and count clean conversions versus flags versus misses.

  16. Typed element data (raised 2026-08-19). The shipped d.ts types data() as any-shaped; the ask is generics — cytoscape<NodeData, EdgeData>( … ) — flowing through data(), the mapper DSL's field references, and event payloads. Two risks make it measure-first: whether the build:types pipeline (rolldown-dts) preserves generic parameters end-to-end at all, and whether the sheet DSL's string-keyed field references can be typed without wrecking inference for the untyped default case (they must degrade to today's types when no generic is given). First measurement: a two-member prototype through npm run build:types — if generics survive to dist/cytoscape.d.ts with hover docs intact, the round is real; if not, the round is first a build-pipeline round.

  17. Framework bindings, React first (raised 2026-08-19). react-cytoscapejs is stale and every consumer rebuilds the same lifecycle glue. An official wrapper owns: mount/destroy (StrictMode's double-mount included), container resize, and prop-diffing that batches element/style updates instead of re-init. Build it with round 71's toolchain — which doubles as cyext validation on a non-layout extension, the round-71 plan's own stated risk. Vue/Svelte follow the same skeleton if React proves the shape. First measurement: what the stale wrapper gets wrong against v4's lifecycle, enumerated — destroy timing, resize, double-mount, update batching — as the requirements list.

  18. A devtools panel (raised 2026-08-19). debug/ already has the instruments — the stats overlay, frame timings (gpu-timer.mts), store counts — but they are welded to the harness. The round packages an opt-in inspector for app developers: frame-graph timings, per-pipeline draw stats, store/dirty-tracking introspection, pick debugging. Form factor is the design call: an in-page overlay an app enables, versus a browser-extension panel; the overlay is v1 (no extension-store dependency). First measurement: the dependency inventory — which of the harness's instruments read public API versus reach into internals, since that boundary decides what the panel can be without growing the public surface.

  19. PDF export over the SVG serializer (raised 2026-08-19). Round 77 builds the SVG serializer; EnrichmentMap web ships a pdf-export extension today, so the first consumer exists before the feature does. Publication figures are the driver (it is why #639 is the most-demanded export ever filed). The hard part is text: fonts must embed or outline, and the fidelity question is whether an svg-to-pdf library preserves round 77's output or a direct PDF backend is needed. First measurement: run 77's output through the svg2pdf-class candidates, rasterize both, and diff — the parity harness round 77.5 builds is reusable verbatim.

  20. The layout portfolio — one excellent layout per use case, layered/hierarchical first (maintainer, 2026-08-19: v4 should ship a better set of built-ins than v3 — one really good layout per main use case, and "comparable to dagre" is the named bar for hierarchical). Today v4 has grid, preset, circle, concentric, breadthfirst, random and the GPU force; round 85 adds radial tree, force constraints, edgeLength and per-side padding. The audit: enumerate the use cases — layered/DAG, tree, force/organic, circular/attribute-grouped, component packing — and name the flagship for each, against what the ecosystem apps actually ship (Cytoscape Web carries three layout engines because the built-ins weren't enough; that is the demand signal). The known hole is layered/hierarchical: nothing in v4 or the plan is Sugiyama-class (ranking, crossing minimization, coordinate assignment; compound-aware; port/edge-routing aware at the elk end). Build-vs-port is the first call — dagre is unmaintained, elkjs is huge, and round 59's pattern (the CPU reference is the spec; GPU assist only where a phase is parallel) applies if built. First measurement: the use-case × current-coverage matrix, then a quality harness — crossings, edge-length variance, area, runtime at N — run over dagre and elkjs on real DAG fixtures, so "comparable to dagre" is a measured bar before anything is designed. *The layered/hierarchical half is taken up by round 112 (the flow layout — built, not ported); the harness is its pass 112.1. Round 114 cleaned up the portfolio that exists — one reading of node dimensions, overlap avoidance in every layout, locked nodes held everywhere, force's animate made to mean what it means elsewhere, and a quality suite over every layout — so the audit for the remaining use cases starts from layouts that are correct. Taken by round 122 (2026-09-09): the demand read from Cytoscape Web's and EnrichmentMap's repositories at pinned commits, the use-case by coverage matrix, and the flagships named — force, flow, radial, circle / concentric, grid, preset, one per use case. Every use case the apps ship has one; the single gap is component packing on the discrete layouts, logged as item 58. A tidy tree, a crossing-minimised or clustered circle and any second force layout are declined until an app asks; scale is not a use case. Closed.*

  21. The v3 extension ports (maintainer, 2026-08-19: the most important v3 extensions need v4 ports). Round 71 builds the toolchain and one example; this is the campaign that uses it. The list is not "all of them": several are absorbed by scheduled rounds — expand-collapse and bubblesets by 82, layers largely by 81, pdf-export by 77/item 48, dagre by item 49 — and eh's look returns via round 96's bypasses while the edge-drawing gesture itself still needs a port. The likely port tier: fcose (both flagship apps), edgehandles, automove, popper, cxtmenu/context-menus, panzoom, navigator. Each port is also cyext validation on a different extension shape (gesture, layout, UI overlay), which is exactly the coverage round 71's example layout does not give. First measurement: rank v3 extensions by npm downloads and flagship-app usage, map each against rounds 71–97 for absorption, and let the remainder be the port list — with the first port chosen for shape-coverage, not popularity.

  22. Round 86's worker-host deferrals (logged 2026-08-26, from the round-86 landed record — surfaced here per the standing rule that a deferral recorded only where it was made is a deferral nobody finds). Three capabilities the worker host (renderer: { worker: true }) ships without, each correct today by falling back rather than failing silently:

    • Background images in the worker. The proxy zeroes the image count and emits one loud error event; nothing is drawn. The build-out is createImageBitmap decodes in the worker with rasters as transferables — the plan's own design — plus mirroring the ImageRegistry's entry lifecycle across the boundary.
    • GPU tweens and startForce across the boundary. The animation manager keeps its own main-side rAF clock (no sink attaches) so every tween takes the CPU path, and the proxy has no startForce, so the force layout uses its CPU executor. Both are correct; the cost is exactly the per-frame span traffic 86.1 priced (0.086 ms at harness scale). The build-out is a register/unregister sink proxy and a force-inputs message.
    • Page @font-face labels in the worker. A worker's FontFaceSet does not inherit the document's registrations, so worker-rasterized labels fall back to system faces (why the 86.3 exact-parity scene excludes labels). Options: load faces into the worker's own FontFaceSet from URLs the app provides, or raster glyphs main-side and transfer. First measurement: for images, entry-lifecycle traffic on an image-heavy fixture (bytes and messages per second); for tweens/force, the span traffic of a real animated session under the worker host against the 86.1 numbers; for fonts, a worker FontFaceSet.load spike proving the face actually applies to OffscreenCanvas 2D rasterization in each engine.
  23. The chain spec's intermittent failure, still unexplained (logged 2026-08-26, round 109). test/force-layout.mjs's "uncurls a chain" fails in full-tier runs at a rate around one in four (round 108: 2 of 4; round 109: 1 of 3), always at 346.45575009003477, which round 109 identified as exactly the init: 'scatter' result on that fixture — so in a failing run the spectral seed contributes nothing. What that rules out is most of the search space: spectralSeed is pure and deterministic, reads no global state, and neither it nor the sim has a wall-clock or load-dependent term; the two hypotheses on file (round 86's invocation shape, round 108's load-dependent iteration count) are both falsified by the code. It survives instrumentation-shyly: 0 failures in 20 probed runs, including 2 under 16 concurrent CPU hogs, with every probed run recording byte-identical layout inputs. The spec now prints the diagnosis when it fires (it re-measures the scatter path in the failing process and names which of the two failures happened), so the next occurrence reports rather than puzzles. First measurement: catch one failing run with a probe cheap enough not to move it — the seeded positions before and after spectralSeed, held in memory and flushed at exit — since the inputs are already known to be constant and it is the effect that goes missing.

  24. The merged round branches (logged 2026-08-26; raised by round 108, which carried it only in its own record). Round 108 left open "whether .claude/worktrees/round-90 and its branch are kept — 8 unmerged commits, the maintainer's call". Measured at the sweep: the question has answered itself. No worktree remains (git worktree list is one line), and all four round branches are fully merged into v4round-90-api-cleanup (8b992283, 0 ahead / 30 behind), feature/round-86-worker-renderer (ac2d7d8f), worktree-plan-round-numbering (b87f9812) and worktree-round-108-agent-ergonomics (c4398416). Nothing is at risk in deleting them and nothing is lost by keeping them, so this stays a call rather than a cleanup: git branch -d on those four names is the whole action, and the tips are written here so it is reversible from this file alone.

  25. The rows the performance review could not screen (logged 2026-09-01, round 113). The review found no library regression across the 43-commit span since 13 Aug, but four movers carry no evidence either way and were logged rather than acted on; each is a measurement question first. (a) The renderer's init: create + ready (labels) on the wrapped-labels scene, a one-shot row: 190 ms on 13 Aug, 307 ms and then 224 ms on two readings an hour apart — 94.1's first-raster atlas work is the candidate, and the row needs repeats before it can say whether it moved. (b) data · query: predicate function, +19% against its own 17% band, traced through the bundle and per commit to noise (45.8–52.5 µs over 43 commits, docs commits at both ends); a ~40 ns-per-element row needs the (x32) amplification to carry evidence. (c) The algorithms-gpu profile's CPU one-shots (affinity propagation n=1024 +27%, neighbourhood similarity +22%) with no commit under src/algorithms/ — a --repeat for the CPU side alone would screen them. (d) The same profile's GPU side reading 20–52% faster with no source change: a driver-version line in meta.adapter would give the next such step a suspect. (e) Added 2026-09-04 (round 117): every render-bench --layout row recorded before round 116 — round 36's 759–952 ms and round 59's 1,308 ms on ndex, the 87.2 rows — was a nine-iteration run (116.1's readback defect), so the first converged figures are round 116's and 117's, and the review's "harness break at 87.2" on those rows is a run-length break as well. Re-dated 2026-09-08 (round 119): the GPU executor's per-iteration cost fell 3–7× with the parallel cell scan, so rounds 116–118's converged rows are the pre-scan figures and the next --layout run is the first on the new kernel. Not a decision so much as a queue; it leaves this list when a round gives those rows bands.

  26. Round 114's layout follow-ups (logged 2026-09-02; the three calls taken by round 116, 2026-09-03). (a) Size-aware repulsion inside the force sim, CPU and WGSL — landed: under avoidOverlap the sim keeps the settle's own boxes apart through a short-range contact term, so a live run streams separated bodies once the transient clears; the settle's exact separation stays. (b) boundingBox on force — decided: flow's rule (scale down, never up, bodies held, the drawing centred; held back when a node is pinned or constraints are set, as the re-pack is), not v3 cose's size-blind stretch. (c) A locked child now stays when its compound parent is positioned, shifted or dragged, its subtree with it, and the parent re-derives (v3's rule). What the round found on the way: the GPU force displacement readback had never landed, so every default GPU run stopped at nine iterations — fixed with 116.1, and every browser force number measured before it is a nine-iteration figure. What remains on this item is a measurement the round produced for round 102: the debug page's hover panel is the "app spelling today" (a per-element opacity bypass or hide() over everything outside the closed neighbourhood), timed in the console per hover change — the numbers on em-web and ndex-large belong in 102's first measurement when that round opens.

  27. Size-aware repulsion's convergence price (logged 2026-09-03, round 116; taken by round 117, 2026-09-04: the sim half is opt-in, avoidOverlapInSim). The contact term kept every pile apart in the sim at 1.7–3× the iterations (25k render-bench scene: GPU live 15.4 s → 25.5 s, sync CPU settle 44 s → 130 s), and the calls were to keep it on, make the sim half opt-in, or change the convergence test when boxes are on. The first measurement decided it: the boxed field is never done early (the per-tick max step sits at the step cap until alpha ≈ 0.005, so a raised floor freezes a bounce — 78 overlaps of up to 10 px on the 200-clique at alpha 0.02), and on a dense random graph the term does not clear the overlap at all (the 1 px gap clamp bounds its push, the spring pressure beats it, and the fully annealed 25k × 50k sim holds 36,042 overlapping pairs that the settle's separation then clears exactly as it clears the point sim's). So the term buys a watchable animateLive run on a small or clique-heavy graph and nothing on the graphs that pay for it; the settle's separation stays on by default. The tables are on the round.

  28. The settle's separation gives out on the 25k scene, and makes it worse (logged 2026-09-04, round 117; taken by round 118.1, 2026-09-07: an expansion stage for the crammed case, and a best-state guard). The calls were whether the dense pass should refuse a field it cannot open, whether a global expansion belongs above some overlap fraction, and what the budget should scale with; the first measurement — the count after each stage — decided the first two and made the third moot. On the sim's 25k random field (12 px bodies in 22 px padded boxes, 93% of nodes inside a neighbour's box) the sweeps deepened the worst pair from 15.8 px to the full box, forty stress rounds took 75,668 pairs to 100,952 while the bounding box never moved — a stress round is a local Jacobi step, and a uniformly dense field's neighbours ask for nothing net — and the closing sweeps handed back 69,127 pairs at 5.68 px mean against 79,373 at 5.82 in. The local passes alone clear crammed random fields of 2k nodes and give out at 3k. So a component of ≥ 1,000 nodes with ≥ 60% touching is scaled about its centroid by its median overlapping pair's requirement (≤ 1.25 per round) while it stays crammed, the local passes take the residue, and the summed overlap depth is measured after every stage with the shallowest state restored at the end. 5k, 10k and 25k come out with zero overlapping bodies, in 0.2–3 s at 25k, grown 1.4–1.8× linearly; the 60-clique of labels keeps its 0.45 fill. The tables are on the round; the quality suite's crammed 3k row and test/modules/force-separation.mjs are the gates that were missing.

  29. Component packing on the discrete layouts, and a standalone re-pack (logged 2026-09-09, round 122's audit; round 87 had logged the same as a "suggested further direction"). The packing machinery of rounds 120–121 — shelf pack, the small-component shapes, the orientation pass, componentGroup, componentOrder — is reachable only through force's settle and flow's own pass; breadthfirst, radial, circle, concentric and grid place a disconnected graph as one body, and the quality suite's component rows exclude them by design. EnrichmentMap vendored a packer and lays the singletons out with grid for want of this. The shape: packComponents: true on the five discrete layouts (run per component, shelf-pack the boxes, group and order as force does), and a pack layout that regroups existing positions with no sim. First measurement: the quality suite's component fixture under each discrete layout with the option on — boxes disjoint, the largest component's centre held — and em-web's singleton rows from pack alone against 121's force-run rows. Taken by round 123 (2026-09-09), on the maintainer's four calls: the option is packComponents, on all five discrete layouts, with the four shared spellings; the pack layout; breadthfirst's trees as blocks on by default (bands, shelves, the singleton block); concentric binned per component; locked nodes left out and the field kept off them. The quality suite's packed rows and test/layout-pack.mjs are the gates; the em-web pictures are on the round. Closed.

  30. Taxi tracks — automatic turn distances per bundle (raised by the maintainer 2026-09-10; planned as round 124). Flow's open legibility problem is which edge goes to which node: with curve-style: taxi every edge out of a rank turns at the same distance, so different sources' runs lie on one line. The reference is Abrate's tangled-tree picture (GeneaQuilts with curved links), whose universal device is a different turn distance per group — in general form, Sander's hyperedge routing slots that ELK's orthogonal router assigns per layer gap. The plan: taxi-turn: auto takes its turn from a track the curve subsystem assigns from live positions (style-side, so edges keep routing themselves after a drag and every layout benefits), tracks keyed on the source by default (taxi-track: source | target | family, spelled track because bundle is the bezier pair), the casing drawn per edge in v3's order so a crossing is gapped, and flow contributing target-anchored merged corridors (112.5's first lever) plus gap growth by track count (edgeSep). First measurement: a run-overlap column on the quality harness and a tracked flow-taxi row against the 50 % and 20 px rows, on deps, workflow-1k, reactome and the Greek-gods genealogy. The four calls — style-side with a lazy epoch-keyed refresh, the taxi-track spelling, source as the default, the casing as parity without a switch — are decided on the round file. Taken by round 124 (2026-09-10): the pass as a pure function (obstacle-cut bands, ELK's pairwise crossing order — the measurement chose it over the staircase on all four fixtures); the props with the auto turn delivered through the params header's n lane (a new column had no binding in the curved vertex stage) and refreshed once per geo epoch from flushDerived; the casing per edge in v3's order (the parity scene 72 → 10 px); flow's merged, target-anchored chains (a corridor probe over 400 seeded DAGs: 187 violations → 7) and edgeSep; the run-overlap count 0 / 1 / 0 / 0 on deps, workflow-1k, reactome and the gods. The Greek-gods scene on the page reproduces the reference picture. 124.7 measured the sweep the plan had estimated at "well under a millisecond" — 1.1 s on an 8.7k-edge layered DAG — and cut it to 8 ms on workflow-1k and 160 ms there (a bitset closure per conflict component, capped at 128 bundles; an obstacle index; numeric keys). Closed.

  31. The layout quality audit and iteration — one sub-round per layout, each signed off by the maintainer (raised by the maintainer 2026-09-14 as the Partial row in docs/features.csv; planned as round 125, 2026-09-15). Round 122 audited the portfolio and round 123 filled its gap; neither looked at the pictures. The maintainer's clarification: built-in coverage does not establish finished quality, and the review is of representative app graphs for overlaps, crossings, spacing, hierarchy readability, component packing and stability, iterated on algorithms and defaults with runtime measured alongside. The plan: nine sub-rounds — 125.1 force, 125.2 flow, 125.3 radial, 125.4 breadthfirst, 125.5 circle, 125.6 concentric, 125.7 grid, 125.8 preset and random, 125.9 packing, 125.10 the debug page as the instrument (a live spacing slider, source-coloured edges, an airiness readout) — each with a pinned fixture set from the app graphs, a measured baseline from the quality suite's probes, the pictures from the debug page, the fixes the pictures justify, and a maintainer review sitting recorded on the round file before the sub-round counts as landed. No new layout; no default changed without the sitting; every algorithmic change carries its runtime row. The maintainer's first sitting (2026-09-14/15) opened the round with findings on the file: label-inclusive avoidOverlap broken on force and fighting the N = 4 tidy diamond; the shelf packer's row-height waste (EM web's limitation, reproduced); flow too spread on reactome and worse with labels, plus a degenerate placement; breadthfirst too airy on reactome. Two levers fall out: a measured "too airy" (nearest-box distance over a test-side quadtree; distance-to-DAG-parent for flow) and an explicit node separation option on the geometric layouts. First measurement: the baseline — overlap pairs, crossings, edge-length variance, area, the airiness columns, run time — on each sub-round's fixtures before anything changes. Open on the round file: where the sittings are recorded, whether 125.8 folds into 125.7, whether an accepted default change ships before the round lands whole, and the node-separation spelling. Carried out 2026-09-15 (every sub-round on the round file, every sitting pending): 125.10 first (the live spacing slider, the airiness readout, source-coloured edges, benchmark:layout-audit, the quality suite's airiness rows), then 125.1 / 125.2 / 125.9 / 125.4 in parallel worktrees and the rest. Defects fixed: labels measured with a canvas at style time so a layout before the first frame separates the boxes the frame draws (the "labels broken" finding was the load path); force's tidy shapes sized by the exact separation and the sweeps kept within a component; flow's extents per side and per direction and a placement pass for free singletons (IRAK1 1,632 → 68 px); breadthfirst sized by the pixel viewport (nine times airier at zoom 0.11 was the "too airy" finding) and a compound parent never a root; the shelf stacks short components under earlier columns (em-web 0.66 → 0.83 efficiency); preset throws on a half position; random takes a seed; condense on circle and radial. Defaults measured and recommended, none changed: flow's nodeSep 30 / rankSep 40 and a smaller label gap; breadthfirst's need-plus-gap spacing; radial's roots by indegree on a directed component, and the rim; condense as a default; force's crammed expansion traced per round. The first sitting pass (2026-09-15, from the desk): 125.8 signed off; 125.3's roots decided and shipped (a component's true roots, the maximum-degree rule as the fallback for a component with none); everything else deferred to the page sittings, which wait on the page exposing each layout's options (sub-round 125.11); the calls on spacing, compacting and the bounding box gathered into item 61, AVSDF reconsidered as item 62. Round-level: sittings are recorded on the round file under each sub-round, and an accepted default ships as accepted. 125.11 done the same day: every layout option on the page, generated from a table the module suite holds to the declaration, with the cy.layout() call as a readout. Open on the page sittings.

  32. One consistent option surface across the layouts: spacing, compacting and the bounding box (raised by the maintainer at round 125's first sitting pass, 2026-09-15). The audit found the geometric layouts sizing themselves three ways (grid's condense, concentric's minNodeSpacing rings, circle's and radial's box-filling rings grown by avoidOverlap), breadthfirst stretching every rank to the box, flow spacing by nodeSep / rankSep, and the gap spelled avoidOverlapPadding, minNodeSpacing, nodeSep and componentSpacing by turns. The maintainer's framing: in v3 the bounding box was a constraint — the result must lie inside it unless another option conflicts; in v4 the default might read it as a hint of the available space that helps a layout work better, with an explicit constrainWithinBounds-style option saying when and how it binds; and options, spacing options included, should be generally consistent between the layouts — one spelling for the gap, one meaning for compacting, one default. Absorbs 125.4's, 125.5's, 125.6's and 125.7's deferred calls and round 125's open node-separation spelling. First measurement: the option matrix — every layout's spacing, compacting and box options with their current semantics in one table — then the proposed surface applied to the audit's fixtures through benchmark:layout-audit, area and gap columns before and after, so the sitting decides on pictures and numbers.

  33. AVSDF, reconsidered (raised at round 125's first sitting pass, 2026-09-15; declined by round 122 "until an app asks"). The crossing-minimised ring order. 125.5 measured the clustered fixture: the sort mapping by cluster cuts crossings 4.3× (1,375 → 318) on a 40-node four-cluster graph; AVSDF's adjacent- vertex-smallest-degree-first order is the next step on the same fixture. First measurement: crossings on that fixture and on em-web's giant component under the id order, the sort order and an AVSDF order computed offline, before any spelling is designed (the natural one is a value of sort).

  34. Round 127's bundle price, and whether to inline the tables (logged 2026-09-15, round 127). Spelling every column id, style property name and reserved data key once cost 1.0% of the minified bundle and 1.2% gzipped (+8.8 KB / +2.7 KB): the minifier mangles PROP and COL but not .BACKGROUND_COLOR, so each reference ships longer than the literal it replaced, and the 173-entry table ships once besides. Two ways to take the cost back, both build changes the round did not make: a build-time inline of as const members (a rolldown/oxc transform, if one exists that is not a regex), or keeping the tables and accepting the 2.7 KB. The call: accept, or schedule the inline as part of round 126's minification work, where a shader-string inliner is already on the table.

  35. Group names as constants? (logged 2026-09-15, round 127.) 'nodes' / 'edges' is the same shape as the three vocabularies round 127 made constants, at 744 sites — and it was left alone: a two-member typed discriminant reads best as the literal, the values are public API (group: 'nodes'), and the directive's examples did not name them. Logged so the omission is a decision on the record rather than an oversight; the gate can take a fourth rule in an afternoon if the call goes the other way.

Context

Issue #3486 specs a v4 performance redesign: columnar/GPU-native model, persistent GPU buffers, WebGPU rendering. This first pass (originally on feature/webgpu, branched from the TS refactor PR #3477; the work now lives on v4) builds a separate v4-style prototype — not a mode of the canvas renderer like WebGL. It ships a new GPU-oriented data layer with the familiar synchronous core/element API on top, plus a WebGPU render pipeline. The existing v3 core, collection, and renderers are not modified.

Agreed constraints (from user) — the pass-1 agreement, kept as the historical baseline: nearly every "No" below has since landed in rounds 6–19 (animations, the sheet + mapper styles, layouts, algorithms, compound nodes, data(), arrows, curved edges, ...); the sections and round records below track what actually shipped.

  • CPU-canonical columnar model, write-through to persistent GPU buffers via dirty-range uploads. Sync API reads always hit CPU typed-array columns. Model works headless (Node-testable, no GPU). ✅
  • Parallel core in a new directory with its own entry point; familiar API shapes. ✅
  • API scope: core — viewport fns, events, graph manip, grid layout only. Collections — events, graph manip, position/dims, iteration, comparison, building/filtering, basic traversal (outgoers etc.), select/unselect. No: animations, stylesheets (constrained compiled-style blocks instead, constants only, no mappers), other layouts, algorithms, compound nodes, data() (deferred; ids/source/target are first-class). ✅ — with one deliberate scope addition: the label style prop accepts the single mapper data(id), since ids are first-class.
  • Rendering scope: SDF node shapes, straight edges (endpoints read from node position buffer on-GPU), GPU picking, basic culling/LOD. Originally no labels or arrows; SDF labels were added in the follow-up commits (see below). Arrows remain out. ✅
  • Hard error when WebGPU unavailable (only when a container is given; headless never throws). ✅

Directory layout, as built

src/
  index.mts              # default factory cytoscape(options); hard-error gate; wires model↔renderer↔pointer
  public-types.mts          # public option/type surface (RendererOptions LOD knobs, RendererStats, ...)
  core.mts               # Core facade: graph manipulation, queries, events, style(), layout(), pick(),
                         #   batching, compact() (round 19), json()/serialize(), destroy(), width/height
  collection.mts         # Collection ("element is a length-1 collection", v3-style; interned handles;
                         #   epoch-guarded _refs with post-compaction lazy repair, round 19.3)
  viewport.mts           # zoom/pan/panBy/fit/center/extent state + math (core-owned; core emits the events)
  event.mts              # v4's Event object (41.1): typed target, originalEvent, no namespaces
  emitter.mts            # v4's emitter (41.2), replacing the v3 import it had reused
  events.mts             # the one core emitter's wiring: ref/predicate-qualified listeners, the 14.5 phase rules
  matcher.mts            # query objects compiled to per-group (mask, want) flag tests + data conditions
  style.mts              # StyleEngine: sheet blocks compiled into channel columns + label sidecar
  style-scales.mts       # mapper DSL: object specs compiled to a closure-free IR + CPU evaluator
  style-schemes.mts      # named color schemes (viridis, ColorBrewer, ...) + sRGB↔OKLab
  easing.mts             # compileEasing: one curve layer shared by the CPU tick and the GPU kernels
  curve-geometry.mts     # CPU twin of the curve WGSL (rounds 12a-c): frames, routes, corners, bounds
  columnar.mts           # the columnar elements form: validation + toColumnarElements converter
  wire.mts               # the binary wire format: serializeElements/deserializeElements + cy.serialize()
  element-defs.mts       # classic definition-form parsing shared by the factory and cy.add()
  image-registry.mts     # round 15: the unique-image pool (url dedup, tiers, async decode)
  label-wrap.mts         # round 16: multiline breaker/justify/ellipsis + the headless estimator
  animation.mts          # Animation + AnimationManager: CPU tween, concurrent per-channel runs (round 21 — no queue); routes position/paint to the GPU sink
  layout/                # grid, preset, circle, concentric, breadthfirst, random
    contract.mts         #   round 17: the extension contract (CustomLayout + the columnar LayoutContext)
    force-sim.mts        #   round 18/59: the CPU reference force simulation (the kernels' spec)
    force-init.mts       #   round 59: components, anchors, the spectral seed, the settle re-pack
    force.mts            #   round 18/59: the built-in force layout (contract consumer; picks the executor)
  algorithms/            # round 10: the full v3 algorithm surface, slot-native over CSR
  shape-points.mts       # unit polygon + arrowhead point tables shared by WGSL gen + CPU pick (round 10;
                         #   round 27 added the round-corner indirection, the compound-arrow parts and
                         #   the computed ARROW_MAX_BACK quad bound)
  store/
    graph-store.mts      # GraphStore: tables + indexes + sidecars; mutation API; compact() (round 19)
    table.mts            # ColumnTable: typed-array columns, x2 growth, free-list, generations, compact()
    id-map.mts           # string id ⇄ slot dictionary, blob-native (UTF-8 blob + probe table; round 11 reclaim)
    adjacency.mts        # CSR adjacency (two counting passes) + per-node overlay for incremental adds
    hierarchy.mts        # round 14: compound parent links, depth, child lists, the parent draw permutation
    curve-index.mts      # rounds 12a-c: bundle/loop membership + curve-param derivation
    curve-blob.mts       # variable-length record pools (curve params, polygons, images) + waste reclaim
    data-store.mts       # the data() sidecar: per-(group, key) adaptive columns, dict reclaim
    dirty.mts            # DirtyTracker: per-column coalesced [min,end) span, resized flag, touch() for sidecars
  math.mts               # round 42: the seven generic helpers v4 uses (copied from v3, not shared)
  types.mts              # round 42: Position/BoundingBox — structural types, no runtime exports
  util/                  # round 42: colors.mts, regex.mts, position.mts, sort.mts (v4's own copies)
  contract.mts           # model↔renderer contract: ColumnId specs, flag bits, ModelView, StoreDelta, LabelEntry;
                         #   also the shared field packings (round 27.1: arrow ids, the node shape byte)
  gpu-context.mts        # adapter/device/canvas configure, device-lost handling
  render/
    renderer.mts         # frame graph: rAF render-on-dirty loop, pass ordering, stats(), pick/export driving
    column-mirror.mts    # GPU storage-buffer mirror; dirty-span writeBuffer; realloc+full re-upload on resized
    cull.mts             # compute cull pre-pass: three-dispatch stream compaction + indirect args per group
    node-pipeline.mts    # node render + depth-prepass pipelines (SDF shapes, vertex pulling)
    edge-pipeline.mts    # straight-edge pipeline (endpoints fetched from the node position buffer)
    curved-edge-pipeline.mts   # rounds 12a/b: the curved stream (24-quad strips off the params + blob)
    arrow-pipeline.mts   # straight-end arrowheads (SDF point tables, boundary tips)
    curved-arrow-pipeline.mts  # curved-end arrowheads (end tangents off the route)
    label-pipeline.mts   # SDF label pipeline (glyph instances; draws after nodes; not pickable)
    label-layer.mts      # consumes the label-dirty channel; shaping memo; lays glyphs into the GlyphBuffers
    label-layout.mts     # pure glyph layout (Node-testable)
    glyph-atlas.mts      # runtime SDF atlas: canvas-2D raster → exact EDT → shelf-packed r8 texture
    glyph-buffer.mts     # persistent glyph-instance buffers: per-owner ranges, tombstones, compaction
    mapper-runtime.mts   # GPU mapper eval: program/stop/data packing + the per-frame runtime
    mapper-shaders.mts   # the eval kernel WGSL (scale math mirrors style-scales.mts)
    gpu-tween.mts        # GPU tween runtime + kernels (position/scalar/color; per-slot from/to)
    gpu-force.mts        # round 18/59: the on-device force integrator (grid/pyramid/gather/apply + lease)
    image-arrays.mts     # round 15: tiered rgba arrays + mips + the r8 icon array + image table
    image-pipeline.mts   # round 15: the image compositing draw (own pass off the node streams)
    chart-pipeline.mts   # round 23: the pie/stripe chart draw (own pass, after images)
    node-layer-pipeline.mts    # round 13 A2: overlay/underlay layer quads
    image-decoder.mts    # round 15: the browser rasterizer (fetch/createImageBitmap/svg canvas)
    cpu-pick.mts         # synchronous CPU node pick: shader-semantics replica over the columns
    picking.mts          # r32uint pick tile, 3-buffer staging ring, latest-wins + full-ring deferral
    gpu-timer.mts        # timestamp-query wrapper behind stats().gpuFrameMs
    scale-controller.mts # adaptive render scale: GPU-time-driven band controller
    upscale.mts          # Catmull-Rom bicubic upscale pass for scaled frames
    quad-index.mts       # shared indexed-quad index buffer
    shaders.mts          # all WGSL as template-literal strings
    webgpu-constants.mts # numeric usage/stage flags so render modules stay Node-importable
  interact/pointer.mts   # pointer/wheel/touch: pan, zoom, hover, taps, box select, drag, pinch, cxt
  README.md              # scope + accepted deviations (the maintained doc)
debug/                   # the manual harness, rebuilt in round 43:
                         #   networks.js   the fourteen networks: six from real exports (four fixtures
                         #                 shared with v3's WebGL harness under v3/debug/webgl/, the
                         #                 465k-edge ndex-x-large local here, and a clustered variant
                         #                 derived from em-web in-page) + three built in-page
                         #   styles.js     a hand-authored v4 sheet per network + a 'plain' one
                         #   fixtures.js   fixture conversion + the generators (shared with the spec)
                         #   init.js       params, loading, the instance, the stats overlay
                         #   view/layout/toggles/query/events/add-remove.js   the control sections
                         #   slim-ndex.mjs how the 34 MB ndex-x-large fixture was derived
                         #   index.html / style.css / livereload-setup.js   the page itself
                         #   network-ndex-x-large.json   the one fixture that lives here, not in v3/
playwright-page/index.html (+ parity.html for the live v3-vs-v4 diffs)
playwright-tests/renderer.spec.js (+ visual.spec.js + goldens/)
test/*.mjs               # 126 suites picked up by the test:js glob (128 files, less the
                         #   two the glob excludes: types-* and node-test-setup), incl.
                         #   style-readback-all.mjs — round 35.1's characterization of all 153
                         #   readable style props on a node and an edge, the guard the readback
                         #   dispatch table was refactored behind
test/soak/*.mjs          # round 48: the robustness tier, run by `npm run test:soak` under
                         #   --expose-gc (its own script: a leak spec that cannot force a
                         #   collection is a flake generator).  lifecycle.mjs (reachability,
                         #   listeners, the heap backstop), churn.mjs (round 11's sliding
                         #   window, promoted from measurement to gate), wire-fuzz.mjs
                         #   (seeded byte mutations; found three defects on its first run)
                         #   and isolation.mjs (multi-instance; found the fourth)
benchmark/               # 25 suites + the renderer/report/executor-sweep runners (see the Benchmarks section of the README).
                         #   Round 36.5 added style-bundle.mjs — the style getters measured through the
                         #   *built bundle*, giving rounds 34-35's headline figures a re-runnable source.
                         #   Round 33 added layouts, style, load, spatial, data, events, store and
                         #   surface (the breadth pass) to the round-1..29 set, and report.mjs grew
                         #   an --all profile that runs every one of them (closing open call 7).
                         #   Round 60.1 added report-compare.mjs — the cross-run join behind the
                         #   status site's comparison pages (per-row p50 across published runs,
                         #   movers with the frozen-v3 twin as control, whole-run drift).
scripts/bench-coverage.mjs   # round 33.12: which public members a benchmark calls (reports, never gates)
test/modules/bench-coverage.mjs  # round 33.12: that script's matcher, and the limits it errs within
scripts/jsdoc-coverage.mjs   # round 26: the two-tier JSDoc audit (--verbose lists every miss);
                                 #   also @throws accuracy (31.2), @param completeness (32, widened in
                                 #   36.2 to exported functions and again in 37.3 to `export default
                                 #   function`), @returns (36.1, gated since 37.1) and stranded doc
                                 #   blocks (36.6) — the last of which reports, never gates
test/jsdoc-coverage.mjs      # round 26: the coverage gate (no file may regress), + the 31.2/32 rules
test/modules/jsdoc-returns.mjs   # round 36.1: the @returns audit's parser, against a fixture
test/modules/jsdoc-stranded.mjs  # round 36.6: the stranded-block check, and the limits it errs within
scripts/throw-coverage.mjs   # round 30.4: which src throws the Node suite runs; a zero-tolerance
                                 #   gate since 37.1 (npm run test:throws, in the npm test chain), with
                                 #   UNREACHABLE/MISATTRIBUTED as validated allowlists
test/modules/throw-coverage.mjs  # round 30.4: that script's lcov parser, against a fixture; + the 37.1 gate
test/modules/picking.mjs         # the pick tile's packing/unpacking, Node-side
test/modules/wgsl-identifiers.mjs # the shader sources parse as WGSL identifiers (no stale bindings)
test/modules/benchmark-report.mjs # round 33: the HTML report's renderer + stat shaping
test/modules/debug-harness.mjs   # round 43: debug/'s only coverage — every fixture exists at the
                                 #   path the page fetches, and every sheet compiles against it
test/modules/import-graph.mjs    # round 41.3: what src imports from outside itself.  Round 42
                                 #   emptied the allowlist: the rule is now absolute (nothing under
                                 #   src/ may import outside it, nothing may reach into v3/)
scripts/docs-generate.mjs    # round 45: the docs generator — source doc blocks + the
                                 #   `// -- section --` banners -> docmaker's fns shape
                                 #   (`npm run docs:api`; 363 members over 48 sections)
test/docs-generate.mjs           # round 45: the gate — the model cross-checked against the
                                 #   *shipped* dist/cytoscape.d.ts, both directions, plus the
                                 #   stranded-block precondition (gated at zero for published files)
test/modules/docs-generate.mjs   # round 45: that generator's doc-comment parser, against fixtures
test/modules/packaging.mjs   # round 44: the packaging chain — rolldown outputs -> dist:copy ->
                                 #   the manifest -> `npm pack`'s real file list, plus the
                                 #   exports map's conditions (types first, import/require pairing,
                                 #   the legacy fields agreeing, ./gpu resolving to identical files)
test/modules/migration-guide.mjs # round 47: MIGRATING.md's property table checked against the
                                 #   running library — every prop it calls dropped must be
                                 #   rejected, every replacement it offers must compile
MIGRATING.md                 # round 47: the v3 -> v4 porting guide (ships in the package)
CHANGELOG.md                 # round 47: the 4.0 changelog (ships in the package)
rolldown.dts.config.mjs      # round 26.5: rolls src declarations up (build/dts/)
scripts/build-dts.mjs            #   finalizeDts -> dist/cytoscape.d.ts
dist/cytoscape.d.ts          # round 26.5: the shipped declarations behind the package types export
test/types-surface.mjs       # round 26.5: shape audit (exports, statics, surviving doc blocks)
typescript/tests/api.test-d.ts   # round 26.5: compile-only consumer test in the test:types project
v3/                              # round 42: the whole v3 project, self-contained and still buildable —
                                 #   v3/src, v3/test, v3/benchmark, v3/debug, v3/documentation,
                                 #   v3/playwright-{tests,page} (port 3334), v3/package.json and its own
                                 #   build/tsconfig/rolldown configs.  `cd v3 && npm run build`.

Round 42 (2026-08-04) moved all of this. The tree above is the post-move layout: v4's source promoted from src/gpu/ to src/, the gpu-/webgpu- prefixes dropped from the test, benchmark, script, debug and Playwright names, and v3 moved wholesale into v3/. src/math.mts, src/types.mts and src/util/ are new — v4's own lean copies of the five utility modules 41.3 measured it still importing from v3, so nothing under src/ imports outside it.

Where the gpu- prefix survives inside src/ (gpu-context.mts and the render/gpu-*.mts trio) it names the device half against a CPU counterpart, which is a live distinction rather than a v3-era prefix. gpu-types.mts was not such a case — it holds the public option surface — and became public-types.mts in round 42.6.

The model half, implemented as planned

Columns, flag bits and shape ids are exactly as originally specced; contract.mts is the co-signed source of truth and was implemented first. Key decisions that held up:

  • Stable slots: free-list + tombstones (cleared flags) + per-slot generation counters; renderer draws highWater instances, dead ones collapse to degenerate quads in the VS. No compaction in pass 1 (since landed: the slot-stable tier round 11, slot-moving round 19).
  • Dirty tracking: one coalesced [min,end) span per column per frame + resized flag; takeDelta() returns-and-clears; onInvalidate(cb) fires ≤ once per microtask. Extended with touch() so non-column sidecars (labels) join the same scheduling.
  • Adjacency: incremental per-node outEdges[]/inEdges[] (O(1) degree, cascade removal). CSR deferred.
  • Element handles: interned singleton length-1 collections per live slot; {group, slot, gen} refs validated on access; cached id()/group() stay readable after removal (needed for remove events).
  • Events: single core emitter (v3's src/emitter.mts unmodified at pass 1; v4's own since round 41.2) with per-ref listeners for collections and selector qualifiers for the core. Emitted: add, remove, position (skipped when no listeners), select, unselect, zoom, pan, viewport, fit, layoutstart/ready/stop, style, render, destroy, error, tap, mouseover/mouseout.
    • No event namespaces: v4 drops namespaced events ('tap.foo') — they are unused and cost a per-emit parse on the hot path. This line long claimed the shared emitter's namespace parsing was "retained for v3 and simply not exercised by v4"; round 37.4 measured that and found it fully exercised, in v3 semantics, because v4 imported that emitter. Round 41.2 gave v4 its own, which matches a type whole — so 'tap.ns' is one literal name now. Listeners/emits should use plain type names.
  • Style: constant blocks on node|edge|*|#id + :selected/:unselected; node channels background-color/width/height/shape/opacity/border-*, edge line-color/width/opacity; plus label/font-size/color (label sidecar, data(id) allowed). Applied on setBlocks, add, and select/unselect. Equal-radii ellipses compile to the exact circle SDF.
  • Grid layout: cell-packing math ported verbatim; bulk store.setPositions (one dirty span) + layout events; cy.layout({name}) errors on anything but grid.
  • Positions: Float32 canonical; headless dims via headlessWidth/headlessHeight (800×600 defaults).

The render half, implemented as planned, plus labels

  • Init/ready/device-lost: as specced (sync throw without navigator.gpu; .ready rejects on null adapter; premultiplied canvas; dead instance + error event on loss).

  • Frame uniform: 48-byte struct at pass 1 — viewportPx, panPx, zoomDpr, edgeWidthFloor, nodeLodPx, hidePx, edgeDim, labelFadePx. Not a mat3x3, as planned. (Since grown to 17 fields / 72 bytes: labelMinPx, the curve/haystack/outline slack bounds, arrowScaleMax, imageMinPx, and the round-20.2 pickMode.)

  • Node/edge pipelines: pure vertex pulling from the mirrored columns; colors bound as array<u32> + unpack4x8unorm (byte-identical uploads); border band + selected accent ring (#0169d9) + hover/grab brighten in the node FS; edges extrude in screen space and fetch endpoints from the node position buffer (drags follow on-GPU).

  • Z-order: single pass — edges, then nodes, then labels; slot order within a group. Early-z (added): a depth buffer + node depth prepass (opaque interiors only, conservative cheap SD tests, no Newton solver) kills edge fragments under opaque nodes before blending; depth = per-element z-rank (edges far / nodes near), designed to generalize to z-index/compound ordering as more ranks + batches. Pixel-identical output (verified by screenshot diff); ndex-x-large fit-all at dpr 2: 37.7 → 31.4 ms.

  • ColumnMirror: per-column storage buffers, span uploads at start × bps, realloc + full re-upload on resized with destroy() deferred behind onSubmittedWorkDone(), version-bumped lazy bind-group rebuild. Unit-tested against a mock GPUQueue.

  • Picking: r32uint id target, same draw order, ids 0/slot+1/high-bit-edge; latest-wins requests through a ring of 3 staging buffers (a full ring defers the pending request to the next frame with a free slot — drop-to-null was removed by the 2026-08-01 pick-ring look at the end of this file). Exposed as cy.pick(x, y). Reworked after the initial pass (the original full-scene pick pass + unbounded frame queueing made hover picks take ~1 s on GPU-bound graphs): the pick pass now draws a fixed 64×64 cursor-centered tile — a pick-specific Frame uniform whose viewport is the tile turns the shaders' own conservative culling into cursor-region culling, O(region) not O(scene) — submits in its own command buffer ahead of scene work, reads back a single center texel, and pick-only frames skip the scene pass entirely. Scene submissions are capped at 2 in flight (backpressure; a behind GPU coalesces state into the next frame instead of queueing deeper). Result at 100k×300k: hover-while-panning pick latency ~956 ms → ~70 ms median, idle picks ~58 ms → ~13 ms.

  • Culling/LOD: originally VS conservative collapse; now a compute cull pre-pass + drawIndexedIndirect per group (nodes, edges, glyphs) — a deterministic three-dispatch stream compaction (count / serial scan / scatter with a workgroup Hillis-Steele scan) that preserves slot order, with an exact Liang-Barsky segment-vs-rect test for edges; the pick pass reuses the kernels with the pick-tile uniform (O(region) picks). LOD: edge width floor with alpha compensation; far-zoom edge decimation (below half alpha, a hash-stable 1-in-N subset at N× alpha, N ≤ 64); plain-disc nodes below ~3 px; sub-pixel size flooring with alpha compensation; optional zoom-based edge dimming. Indexed instance quads (4 VS invocations per quad via vertex reuse). Node decoration columns moved to the fragment stage (flat-instance fetch) to stay within per-stage storage-buffer limits. ndex-x-large pan benchmarks (GPU ms/frame): far zoom 33 → 3.5; zoomed-in 20× at dpr 1 12.4 → 8.8; fit-all at dpr 1 18.5 → 10.2; labels at 117k glyphs now ~free (38.6 vs 37.7 ms at dpr 2 fit-all).

  • Labels (added): runtime SDF glyph atlas (TinySDF-style canvas raster → exact Euclidean distance transform → one shelf-packed 1024² r8 texture, glyphs added lazily; edge encoded at sample 0.5, fwidth-AA in the FS). Persistent glyph-instance buffer (40 B/glyph) with per-node ranges, tombstones + compaction, coalesced span uploads and ColumnMirror realloc rules. Glyph instances reference the node slot, so labels follow drags/layouts on-GPU with zero rebuild (a node move uploads 8 bytes). Labels fade out below labelFadePx; single-line, centered below the node, not pickable.

  • Interaction: wheel zoom-about-cursor, drag pan, throttled latest-wins hover picking (HOVERED bit + mouseover/mouseout), pan-vs-grab via an exact synchronous CPU node pick (no staleness), node drag through the core position API, tap-toggle selection (shift additive, background clears). Hover picking pauses during viewport-only gestures (pan drags never pick; wheel zooms suppress picks and re-pick once settled). Pinch deferred.

  • Pick fast paths (added): nodes pick synchronously on the CPU (columnar scan replicating shader semantics — flooring, plain-disc LOD, shape tests, topmost wins; unit-tested); the GPU tile (now edges-only) reads back whole and doubles as a cursor-region pick cache invalidated on viewport/geometry changes. ndex-x-large at dpr 2: node hover ~0 ms, cold edge/background ~7 ms, cached ~0.2 ms, hover-while-panning median ~0 ms (was ~70 ms), with zero GPU pick passes for node hovers and cache hits.

  • Frame timing: stats() reports cpuFrameMs (encode/submit cost, ~0.1 ms by design) separately from gpuFrameMs (real frame GPU time via the optional timestamp-query feature — the span across the cull/render/upscale passes, which is robust to backends that emulate pass-boundary timestamps at command-buffer granularity) — CPU-side timers cannot see GPU execution, which is what bounds fps on large graphs.

  • Adaptive render scale (added): renderScaleMin/renderScaleMax band (defaults 0.5/1), quarter steps driven by median gpuFrameMs over ~400 ms windows (drop > 14 ms; raise only when the projected cost at the higher step fits under 10 ms — no pumping; backpressure stalls as the no-timestamp fallback; pure ScaleController, unit-tested). Idle settles back to max after ~250 ms so stills are always native — chosen over a static scale because far zoom is maximally resolution-sensitive (floors are render-px-defined, sub-pixel statistics change, decimation engages earlier) yet nearly free at native after decimation+culling. Scaled frames render offscreen + Catmull-Rom bicubic upscale (9 bilinear taps). Verified: fit-all pan at dpr 2 steps 1 → 0.75 → 0.5 within ~0.8 s (25 → 76 fps, 8.3 ms GPU); idle returns to 1; far-zoom pan holds 1. Picking stays native; labelMinPx option hard-culls unreadably small labels in the glyph cull predicate.

  • Whole-graph fit fast path (added): no-arg fit()/center() compute bounds via GraphStore.boundingBox() — a direct columnar scan (nodes: position ± size/2 + border/2; edges as a first-class extent term, today the endpoint centers) instead of materializing ~500k element handles through cy.elements(). ndex-x-large: 235 → 15 ms, identical zoom/pan. Future edge geometry (bezier, arrows) extends the edge term in the store scan and Collection.boundingBox together. (Since superseded: round 12a extended the store scan's edge term with the conservative curve-hull bound and gave Collection.boundingBox the exact lazy curve tier.)

Integration

  • devDep @webgpu/types; tsconfig "types": ["@webgpu/types"].
  • rolldown: build/cytoscape.umd.js (global cytoscape) + build/cytoscape.esm.mjs; the FILE=umd watch filter picks the gpu UMD up automatically (verified).
  • package.json: exports["./gpu"], gpu bundles in dist:copy, debug in watch:sync.
  • debug/: network/bg/LOD/labels URL params, ?gen=NxM random-graph generator, best-effort constant-prop conversion of the v3 fixture styles, FPS/counts/upload-bytes/glyphs/pick-latency overlay.
  • playwright: the renderer project (named webgpu until round 42) — channel: 'chromium' new headless + --enable-unsafe-webgpu --enable-unsafe-swiftshader, loading via http://127.0.0.1:3333; soft-skips without an adapter; the default chromium project ignores it.

Verification — the pass-1 record, all green

  • Node tests (npm run test:js): 16 gpu suites / ~240 gpu assertions within the 918-test suite — store, dirty contract, core graph manip, collection iteration/comparison/building-filtering/traversing, selectors, selection, events, viewport, style, grid layout, ColumnMirror (mock GPUQueue), labels model channel, label layout/EDT/GlyphBuffer.
  • Playwright (renderer.spec.js, 10 specs on a real Metal adapter): ready; hard error with navigator.gpu removed; headless never requires GPU; red-node-on-white composited pixels (pins premultiplied compositing); pick() node vs background; mouse-drag moves node in model + pixels; tap select/clear; label renders below node; label follows a move with ≤64 B upload; label LOD fade-out.
  • Manual/scripted: ?gen= harness runs verified via scripted Chromium (render-on-dirty confirmed: 1 frame while idle); typecheck and lint green.

Benchmark (Apple Silicon Metal, 1280×800, continuous-pan steady state)

25k nodes / 50k edges 100k nodes / 300k edges
Glyph instances 139k 589k
FPS fit-all, labels off → on 73 → 73 41 → 37
FPS zoomed-in, labels off → on 74 → 74 38 → 31
One-time glyph build ~0.8 s ~4.1 s
Extra GPU upload for labels +5.2 MiB +22.5 MiB

CPU stays ~0.1 ms/frame throughout — the renderer is GPU-bound (instance count in the VS). Steady-state labels are near-free at fit-all zoom (LOD collapse) and cost ≤~18% zoomed in at the 100k scale.

Known deviations, accepted

  • Element/core listener firing order is registration order within a bubbling phase (compound bubbling landed round 14.5 with v3's cross-phase order).
  • No z-index; compound parent bodies (round 14.9, depth order) under edges under leaf nodes under labels; within a stream, slot order (reused slots draw at the recycled position).
  • Float32 position precision (~7 significant digits).
  • Pan-vs-grab uses the ≤2-frame-stale resolved pick.
  • cy.elements() returns nodes then edges, not mixed insertion order.
  • Labels: nodes only, single-line, fixed below-node placement, not pickable, fixed-size atlas, color/text baked per glyph run. (Since superseded: edge labels + label visuals landed in round 10; edge-label autorotate 2026-07-29.)
  • data(), arrows, compounds, bezier, non-grid layouts: all since landed (animations round 9; circle/concentric/breadthfirst/random layouts round 10; the full curved-edge families rounds 12a–12c; compound nodes round 14; GPU layouts stay logged).

Follow-ups informed by the benchmark

  1. Compute-shader culling + drawIndirect — done (see the culling/LOD section above).

  2. Batch the one-time glyph builddead on re-measurement: 100k labels (588,890 glyphs) build in ~160 ms (~1.6 µs/label; init-time delta 110 ms wall), not the ~4.1 s / ~40 µs per label originally recorded. The build path is unchanged since labels landed, so the original figure did not survive a controlled re-measurement (runtime style() apply → stable frame, CPU-profiled). SDF raster/EDT is per unique glyph and cached in the atlas; per-label work is layout + instance emission only.

  3. Bulk element loaddone (the actual init bottleneck). Profiling the ndex-x-large load (28.6 MB JSON, 19.6k nodes / 465k edges, ~960 ms end to end) showed cytoscape init at 662 ms — dominated not by the columnar model but by eager per-element handle materialization (Collection interning for 484k elements the loader never touches), a per-element add emit with no listener early-out, def-clone churn and the ~110 ms GC echo.

    Landed as two pieces: (a) a bulk add path — no handles or emits on the factory load, clone-free def partitioning, one up-front table reservation, and applyBulk (the mini selector language resolves per (group, selected), not per element) — init 662 → 236 ms; (b) a columnar elements form ({ columnar: true, ... }, typed-array columns, integer-indexed edge endpoints, contiguous-slot memcpy ingest) with the compat converter cytoscape.toColumnarElements(json) — init 236 → 80 ms, and ~76 ms with a prebuilt payload (what fetching a binary format would enable; JSON.parse itself is 90–113 ms on this fixture).

    The serialized wire layout for the columnar form is also done: one little-endian ArrayBuffer (header + columns; ids as a UTF-8 blob + prefix offsets with an ASCII fast path) via cytoscape.serializeElements / deserializeElements, accepted directly by options.elements/cy.add(). Numeric columns deserialize as zero-copy views; deserialize is ~5 ms on this fixture (replacing the 90–113 ms parse + 27–48 ms convert of the JSON path) and the payload is 9.2 MB vs 30 MB JSON.

  4. data() sidecardone, columnar like everything else: per-(group, key) adaptive columns (f64 + presence for numbers, dictionary-encoded strings, plain-array fallback), ele.data() with v3 semantics (immutable id/source/target, data events), ingest from defs, columnar data: columns and the wire (v2 data blocks — f64 and dictionary indices deserialize zero-copy). Labels now take any data(key) mapper and refresh on data writes.

  5. Perf round 2 (post-load-path)done: (a) grid layout got a slot path (no handles; bulk setPositions; 200k nodes 270 → 24 ms) plus a new preset layout; (b) the id index went blob-native — UTF-8 blob + open-addressing probe table, no stored JS strings, lazy per-slot decode; packed wire ids ingest with zero string materialization (484k ids: ~69 ms Map inserts / ~50 MB → ~10 ms / ~9 MB); (c) adjacency is CSR built in two counting passes from the endpoints column, with a per-node overlay for incremental adds (~15.5 MB of per-node arrays → ~4 MB). Wire-payload init on ndex-x-large: ~106 → 68 ms median (deserialize itself ~0 ms).

  6. Cheap wins remaining: arrows, pinch zoomdone. Triangle source/target arrowheads render as one quad per visible edge per enabled end off the edge cull stream, tips on the endpoint node's boundary computed on-GPU (drags/layouts need no rebuild); the vertex stage stays within WebGPU's base 8-storage-buffer limit (per-end color column binding; edge opacity folded into stored arrow alpha). Two-finger pinch zooms about the touch midpoint with grab cancellation and an inert leftover finger. Playwright also runs on WebKit now (classic renderer specs green; WebGPU specs soft-skip until Playwright's WebKit build ships navigator.gpu).

All follow-ups are done. The open hooks this list once tracked have all since closed: slot compaction (slot-stable tier round 11; slot-moving round 19), z-index ranks (z-index dropped by decided design 2026-08-01), compound nodes (round 14), curved edges (rounds 12a/12b/12c), a binary export of live graphs (cy.serialize(), round 10), and mappers landed as the round-7 object DSL below. "More layouts" remains demand-gated (the round-17 extension contract is the vehicle).

API gaps vs v3

Pass-1 scope held, but a lot of the familiar v3 core/collection surface was missing. The LHF (buildable on the existing columnar/flag/ adjacency model with no new architecture) and small-touch (one localized store/renderer/pointer change, no new subsystem) tiers are now done — see "Landed" below. What remains is split into needs a call (a new selector type, storage, lifecycle, or readback path) and deferred (already-declared out-of-scope blocks, for completeness).

Landed (LHF + small-touch)

Done across 11 isolated commits, each with Node tests (interaction-gated behaviour also covered by Playwright).

Core:

  • Viewport math/setters: reset, viewport({zoom,pan}), minZoom(v)/ maxZoom(v)/zoomRange setters, getFitViewport/getCenterPan (compute without committing), renderedExtent, size, centre. (getZoomedViewport skipped — internal in v3.)
  • Introspection/aliases: instanceString, isReady (via a _readyResolved flag), headless, styleEnabled, hasCompoundNodes, hasElementWithId, $id, mutableElements, window, options.
  • Events: once, listen/bind, unlisten/unbind, pon; onRender/offRender.
  • renderer(), forceRender() (renderer got a public requestRender), resize()/invalidateSize, makeLayout/createLayout.
  • Graph-level data/removeData/scratch/removeScratch (+attr/ removeAttr), plain objects on the core.
  • Interaction gating: autolock/autoungrabify/autounselectify (+*Nodes aliases) and panningEnabled/userPanningEnabled/ zoomingEnabled/userZoomingEnabled/boxSelectionEnabled; all ctor options too. pan/panBy/zoom gate on the programmatic flags; the pointer gates drag-pan/wheel/pinch on the user* flags and drag on grabbable+unlocked; autounselectify suppresses tap selection.

Collection:

  • Reference/identity: cy() (was absent), renderer(), element(), collection(), instanceString, hasElementWithId, indexOf/ indexOfId.
  • Traversal (existing CSR adjacency): roots, leaves, successors, predecessors, edgesWith, edgesTo, parallelEdges, codirectedEdges, components/component/componentsOf, allAreNeighbors.
  • Set/iter/degree: byGroup, absoluteComplement (+complement/ abscomp), diff, reduce, max/min ({value,ele}), sort, merge/unmerge/relativeComplement aliases, isLoop/isSimple, equal/equals, min/maxDegree/min/max{In,Out}degree/ totalDegree. degree/indegree/outdegree are singular first-element accessors (undefined when the first element isn't a live node), as in v3 — the whole-collection sum is totalDegree.
  • Dimensions: renderedBoundingBox, renderedWidth/renderedHeight (+outer), renderedPosition setter, shift/silentShift, silentPosition(s), midpoint/renderedMidpoint, source/ targetEndpoint (+rendered; node-center approx), relativePosition, point/modelPosition aliases.
  • Data/scratch/json: removeData (+attr/removeAttr), per-element scratch/removeScratch (plain JS on the interned handle), json/jsons; once/pon/listen/bind/unlisten/unbind.
  • Flags: selectify/unselectify, grabbable/grabify/ungrabify, locked/lock/unlock, grabbed getter, show/hide/visible/ hidden. FLAG_GRABBABLE/FLAG_LOCKED added; grabbable defaults on; def/ctor-level grabbable/locked. show/hide turned out to be pure LHF — the cull kernels and CPU pick already mask on SHOWN = ALIVE|VISIBLE, so toggling FLAG_VISIBLE needed no shader change.
  • move() for edges (re-endpoint in place via store.moveEdge).

Not yet ported from the small list: active/activate, pannable/panify, inactive — landed in round 6 (below).

Collection/core API performance

Benchmarked against the v3 analogue in v3/src/ via Mitata (npm run benchmark, BENCH_N scales the graph; suites in benchmark/). The harness rotates over a pool of distinct operands so V8 can't hoist pure loop-invariant calls out of the measured region — without that, allocation-free ops (e.g. same()) mis-report by 5 orders of magnitude. On a 2k-node/4k-edge graph:

  • Where v4 wins big: degree/totalDegree/maxDegree ~100–230× (O(1) off the adjacency index vs v3 rebuilding connectedEdges); add+remove ~32×; components ~30×; intersection/difference ~24×; collection() ~14×; mutations (data/position set) ~10–12×; map ~2.6×; traversal 1.5–4×.

  • Optimizations applied (each its own commit, all revealed by the benchmark): pure #id selectors resolve through the O(1) id index instead of materializing + scanning the graph ($('#id') went ~420× slower → ~3× faster than v3); set membership keys on a packed {group, slot, gen} integer instead of a group:slot:gen string; each collection lazily caches its membership Set (sound — _refs is immutable), so same/contains/set ops are O(other) once a collection is reused (contains ~13× slower → ~4× faster); subset results (filter/nodes/edges/slice/difference/intersection) spawn via a dedupe-skipping _spawnUnique; map/forEach/filter preallocate and hoist the thisArg branch; position() reads its column once.

  • Columnar flag-selector scan (perf round 3) — closed the residual whole-graph losses without copying v3's maintained-set approach. The mini-selector language minus #id (which keeps its id-index path) is entirely (group, flag-mask) predicates, so compileFlagPlan compiles any flag-only selector to per-group (mask, want) tests and GraphStore.scanRefsInto answers them with one preallocated pass over the flags column — no handles, no per-element term matching; today's pseudos always collapse to one test per group (a multi-flag language would generalize to a test list).

    cy.elements/nodes/edges/filter/$ route through it (_select: id index → flag scan → materialize+match fallback for mixed id+flag comma lists), collection filter(selector) tests refs against the plan directly, the interned-handle pool went Map → dense slot-indexed array, and scan-built collections skip _eleFromRef (refs known current). Callback iteration (forEach/map/filter(fn)/some/every/min/max) now plain-calls when no thisArg is given, matching v3's semantics (this is undefined, not the element) — rebinding the receiver per element via fn.call() cost ~2× at 20k.

    Verified at N = 2k/20k/200k (the focused benchmark/materializers.mjs sweep runs where the full suite can't): $(':selected') ~2× slower → 16–59× faster, $('node') → 9–14×, $('node:selected') → 46–166×, nodes(':selected') → 70–198×, nodes()/edges() → 3–9×, elements() ~2.6× slower → ~parity-to-2× faster, filter(fn) flipped to a win, forEach ~3.3× slower → ~1.8×.

  • Columnar bulk writes (perf round 4) — the write-side counterpart of round 3, driven by a new benchmark/mutators.mjs sweep (whole-graph mutation round-trips vs v3 at 2k/20k/200k; BENCH_OP runs one group per process at 200k, where eight v3 instances exceed the heap). The sweep exposed eles.select() as the one outright loss: per-element _applyStyle (a defaults-spread + full block match per element) and an unconditional per-element emit made 200k-node select+unselect 178 ms — behind v3 at 2k and only ~1.4× ahead at 200k.

    Fixes, each revealed by a benchmark line: (a) GraphStore.flagRefs — one bulk flag pass over a collection's refs with the flags/gen columns hoisted out of the loop, a requireBit filter (selectable-only for selection), changed-index collection and one coalesced dirty span per group — now backs select/unselect and all _setBit mutators (show/hide, lock, grabify, selectify); (b) select/unselect skips restyle outright unless some case condition reads the state (StyleEngine.dependsOnState, generalised in round 57.1 from a selection-only check to every state), else restyles only the changed slots; emits are gated on registered listeners; (c) shift() and constant/partial positions() write the position column directly (GraphStore.shiftPositions/setPositionsConst — no per-element handles, callbacks or Position allocations) and the positions(fn) path reads previous coords off the column instead of allocating via position(). At 200k vs v3: select+unselect 178 → 6.2 ms (1.4× → 38×), hide+show 2.4 ms (~1400×; v3 pays a style bypass per element), lock 96×, positions(obj) 71×, positions(fn) 44×, shift 18.8 → 2.6 ms (106×), remove+re-add of a 256-node band ~1000×. The gpu side improved 3–54× per op at 2k (select 54×, shift 5×, lock 8×, hide 6×); data set is dominated by per-(group,key) column resolution and stayed ~1× (16–22× over v3; its 200k timing is GC-noisy on both sides).

  • Slot-native traversal (round 4b) — traversal walks built results by pushing refs per adjacency hit and re-deduping in _spawn (a packRef-keyed Set over ref objects), iterated CSR runs through the iterator protocol, and successors/predecessors spawned a full collection per hop — ~10.5 s for one 20k-node closure on the benchmark ring (diameter ~N/3; v3 ~40 s). Now every walk (connectedEdges/ connectedNodes, outgoers/incomers, neighborhood, roots/leaves, sources/targets, successors/predecessors) collects current refs straight off CSR with an int-packed (group, slot) seen-set and index loops, and spawns through _spawnLive — a trusted {unique, live} constructor path that skips per-element _eleFromRef re-validation. neighborhood pre-seeds the seen-set with its own elements instead of a difference() post-pass, and successors/predecessors is a raw slot BFS (no per-hop collections at all): 2k-node closure 92.7 ms → 352 µs (2.9× → ~725× vs v3). Verified by benchmark/traversal.mjs at 2k/20k: the two residual v3 wins flipped (100-node-band connectedEdges 1.2–1.5× loss → 1.3–1.5× win, band sources 1.1–1.3× loss → 1.3× win), and the rest widened — neighborhood 2× → ~4×, outgoers/incomers ~3.4× → ~4.5×, band neighborhood 2.5× → ~5.4×, band roots ~64× → ~110×. The ~2–5× ceiling on single-hop traversal is structural, not unfinished work: v3 is already O(degree) there (each element object holds its incident edges as a direct array), and a traversal must return a v3-shaped collection — per output element the gpu side allocates a ref, dedupes and interns a handle, a floor comparable to v3 assembling its result from already-materialized objects. Bulk writes have no such floor (they touch columns and return nothing), which is why shift can be ~106× while outgoers is ~4.7×; the big traversal multipliers only appear where an algorithmic layer was removed (the per-hop collection machinery in successors).

  • Scenario sweep (round 5) — with the micro surface swept, the open question was whether the wins survive composition and the listener-gated emit paths the micro suites deliberately exclude (their emits never fire — no listeners are registered).

    benchmark/scenarios.mjs replays five composed traces with core listeners attached, at 2k/20k/200k (BENCH_OP one-group-per-process at 200k; v3 instances styleEnabled + preset layout — the realistic app config, and required for meaningful v3 bounds headless). Results (× vs v3): explore (2-hop expand + select + fit) 8.4/5.3/34×; select-all + whole-graph fit with 2N emits per iter 18/10/12.6×; 100-band drag with a position listener (800 emits/iter) 8.5/7.3/10.6×; remove + re-add 256 + cascade with add/remove listeners 20/162/529×; dashboard refresh (bulk data write + mapped labels + filter(fn) + fit, data listener) 3.8/4.0/4.2× before the fix below, 9.5/6.4× at 20k/200k after. Emit cost itself is ~85 ns/listener call (~17 ms for 200k emits) — no batching policy is urgently needed. Two fixes fell out: (a) pan() get returned a fresh {x,y} per call — now returns the live internal object (v3 parity; setters always swap in a new object), ~4× slower → ~2.3× faster; (b) the refresh trace exposed the data-write label path: _onDataChanged ran a full per-element style apply (defaults spread, every block matched, all six node channels + dirty spans rewritten) per element per write whenever any label mapped any data key — 64 ms of an 85 ms 200k bulk write.

    Now the StyleEngine tracks which keys labels map (labelDependsOn(keys), decided once per _setData call), and refreshLabels(slots) recomputes only the label sidecar, resolving the stylesheet once per selectedness like applyBulk (per-element fallback only under #id blocks); writes of unmapped keys skip the pass outright. 200k bulk write with mapper+listener: 85 → 37 ms.

  • Residual v3 wins (micro-ops at 20k, accepted): forEach (~1.8×), getElementById (~1.4×), data()/position() get (~1.1×, noise-level).

Landed (round 6 — the needs-a-call tier)

Five isolated commits (2026-07-24), each with Node tests; box selection also has a Playwright spec (18 renderer specs total, all green on a real adapter). src/README.md records the policies.

  • active/pannable states: FLAG_ACTIVE/FLAG_PANNABLE bits; activate/unactivate/active/inactive, panify/unpanify/ pannable through the bulk flag path. v3 defaults (edges pannable, nodes not; per-def override); pannable overrides grabbable() and drag eligibility, so dragging a pannable element pans.
  • Batching (startBatch/endBatch/batch/batchData/ batching): v3 semantics — defers style application (first apply of added elements, sheet re-application, mapped-label refresh) to one bulk flush at the outermost endBatch, filtered to live refs; events keep firing; a sheet set mid-batch flushes as one applyAll. Renderer scheduling needed no deferral (the dirty tracker already coalesces per microtask), so notify/noNotifications have no v4 counterpart.
  • Read-only style getters: style/css, renderedStyle (length props × zoom), numericStyle, effectiveOpacity/ transparent/takesUpSpace/interactive. Values read back from the stored channels (columns + label sidecar); label channels of unlabelled nodes resolve through the sheet. Setter forms throw — no per-element bypass in v4 (mappers are the per-element mechanism).
  • Core json() export: elements (grouped, or flat via json(true)), sheet, graph data, viewport, gating flags; element json gained locked/raw grabbable/pannable (v3 parity). The import/restore form throws (needs stored defs); exported elements round-trip through the definition form.
  • selectionType + box selection: validated 'single'/'additive' (ctor option; additive taps toggle without clearing, mult-sel keys shift/ctrl/cmd match v3); GraphStore.refsInBox answers the box query in one columnar scan over shown elements (v3 'contain': node bb incl. border fully inside, straight edges by both endpoint centers), public as cy.elementsInBox; the pointer boxes on mult-sel-key drags (or when panning is disabled) with a DOM overlay box and the v3 event flow (boxstart/boxend, box/boxselect per element). Mouse/pen only.

Needs a call — note only, don't build yet

  • Classes (classes/addClass/removeClass/toggleClass/ hasClass/flashClass): new per-element class storage + class selectors in the mini-selector + restyle on change. Couples to the constrained (constants-only) style engine. Call made: not in v4 — see "Selector removal + stylesheet reshape" below.

  • Style getters — the read-only surface landed in round 6 (shape call: stored-channel truth, numbers + rgb() strings); bypass/per-element style setters remain out by design (the fn mapper is the per-element mechanism; pstyle stays internal-only in v3 and has no v4 counterpart).

  • Batching — landed in round 6 with the v3 policy (defer style apply, keep events); notify/noNotifications deliberately have no v4 counterpart (the renderer is dirty-driven).

  • Core json() import and element clone/copy/restorecall made (round 10 planning, 2026-07-27): not in v4. Removed elements are terminally dead (see the design decision in src/README.md): their column bytes are tombstoned and the slot free-listed, so nothing keeps a removed element readable or restorable. restore()/clone() and the import form of cy.json() are permanently closed; re-adding from kept definitions is the app's job (exported element json round-trips through cy.add()).

  • Image export (png/jpg/jpeg/renderTo) — landed in round 9.6 (below) as the offscreen render + buffer readback path; renderTo remains out.

  • mount/unmount: the container is fixed at construction today; re-mounting means renderer teardown/re-init.

  • Lazy / slot-backed collections: the only way past traversal's ~2–5× handle-materialization floor (see round 4b) is returning collections that hold slot lists and intern handles on demand — an API-shape change (it moves the cost of eles[i]/forEach from build time to access time, and complicates the "handles are interned singletons" invariant).

    Call made (round 5): not warranted. The scenario sweep measured the floor in composed traces: in the worst one (dashboard refresh, the narrowest win) the per-element handle reads in filter(fn) cost 5.2 ms of a ~90 ms iteration at 200k (vs 1.9 ms for a direct columnar scan) — ~4–6% of the trace — and the traversal-heavy explore trace runs a 200k click-interaction in ~45 µs median, 34× v3. Revisit only if a real profile ever disagrees.

  • Odds and ends that each need a small feature, not just wiring (selectionType + box selection, active/activate, pannable/ panify — landed in round 6): multiClickDebounceTime (multi-click), eles.layout()/layoutPositions/layoutDimensions, boundingBoxAt (bbox at a hypothetical position), sortByZIndex/zDepth (closed 2026-08-01: z-index is dropped by decided design — see the design-sitting section below), padding/paddedWidth/paddedHeight.

Selector removal and stylesheet reshape

Decided in design discussion (2026-07-24) and implemented in one pass; src/README.md ("Design decisions") is the maintained record. The decisions, explicitly:

  • v4 has no classes. The class system (addClass/class selectors) is not coming to v4; user-defined state lives in the columnar data() sidecar, with mappers and predicates supplying the styling/filtering behaviour classes provided in v3.

  • v4 has no selector strings at all. Rather than porting a dialect of the v3 selector language, the language is gone: selector.mts was deleted and replaced by matcher.mts — a matcher IR of structured queries ({ group, selected } today) compiled to the round-3 columnar flag scans. Query objects answer whole-graph queries (cy.nodes({ selected: true }), throwing on unknown keys), predicate functions cover everything richer (lodash-style), including event delegation (cy.on('tap', ele => ele.isNode(), cb), identity-compared in off()), and ids go through $id/getElementById. cy.$() and string arguments to set ops/edgesWith/components/remove/fit were removed.

    Future richer matching (data predicates, structural terms) extends the IR; any frontend (chained builder, serialized query) compiles to it.

  • Style is { nodes, edges } (keys renamed from { node, edge } 2026-07-24 to match the group names) — each key a props object (constants, camelCase or kebab-case, and mapper objects). Selector blocks and #id blocks are gone; state is a case condition (round 57.1: { when: { selected: true } } and the rest of v3's state selectors), and v3's own :selected / :parent:selected / :active blocks are entries in v4's default stylesheet that a user block replaces. The (ele) => props function form was removed in round 8 (below): all per-element styling is declarative (case conditionals, data(key) scales), so every value is analyzable, serializable, and GPU-evaluable. Refresh: a data write re-derives the affected mapped channels, key-gated.

  • Mapper DSL direction — landed in round 7 (below), as a plain object spec rather than strings/builder; round 8 added conditionals and removed the fn form.

Verification: typecheck, lint, test:js (1221 passing, incl. the new test/query.mjs matcher suite and rewritten style/events/flag-scan suites), and all 17 Playwright renderer specs on a real adapter. Benchmarks compare idiomatic forms per side now (cmp(name, v3Op, gpuOp) where they differ); pointer.mts tap-clear uses elements({ selected: true }).

The mapper DSL

Ten isolated commits (after a { nodes, edges } sheet-key rename to match the group names): OKLab + scheme tables → mapper compile/IR → engine integration → data-write plumbing → program packing → GPU eval (scalars, then colors) → ordinal dict path + mixed demotion → benchmark → docs. All green throughout: typecheck, lint, test:js (1360 tests; three new mapper suites), test:modules, 20 Playwright renderer specs on a real adapter. src/README.md ("Design decisions") is the maintained record; the shape, briefly:

  • Spec: plain serializable objects as style prop values — { data, scale?, domain?, range?, clamp?, fallback?, ... }. Scales: linear/log/sqrt/pow/symlog, diverging ([min, mid, max]), ordinal, threshold, quantize. Colors interpolate in OKLab (opt-out interpolate: 'srgb') with named schemes (viridis family, ColorBrewer ramps, category10/dark2) and multi-stop ranges. Missing/unmappable data → fallback else the channel default. domain omitted/'auto' is a live extent (Vega-Lite semantics): re-checked on writes of the mapped key, whole-channel re-derive when moved.

    Compiles to a closure-free IR (style-scales.mts): everything continuous lowers to one piecewise program over transformed stops; refresh is gated per (group, key); edge data writes now refresh edge channels; fn-sheet returns may not contain mappers; label takes the passthrough only.

  • GPU eval — the paint/geometry split: paint channels (fill/border/ line colors, opacities, arrow colors) evaluate in a per-group compute kernel that interprets a packed program array (64 B uniform structs + vec4 stop/LUT tables + f32 data-region shadows with present masks) and writes the existing channel buffers — render pipelines untouched, zero permutations, fits base device limits. Data writes upload only the touched bytes and dispatch once (200k color write: 78.5 → 15.9 ms; the getter answers by evaluating the shared IR lazily, within ±1/byte of pixels — Playwright-pinned).

    Geometry (size, border-width, shape, edge width) + labels stay eagerly CPU-evaluated: anything read by culling, CPU picking, or columnar scans stays CPU-canonical. Arrow alpha folds in-kernel; mapped arrow shapes and mixed-promoted columns demote to CPU; string ordinals run as dict-index LUTs (dict growth repacks); headless stays fully CPU-correct with no renderer.

Conditionals and fn removal

Direction set in discussion: maximize GPU offload / minimize CPU resolve by making the analyzable mapper IR the only way to style, and removing the one construct that can never be offloaded — the opaque style function. Isolated commits; all green (typecheck, lint, test:js, test:modules, 20 Playwright renderer specs).

  • CPU-evaluable invariant (established). Every mapper must be cheaply CPU-evaluable. That is what keeps ele.style() synchronous, keeps headless mode and Node tests working (one IR runs on CPU, GPU, and in tests), and keeps determinism. Reads stay sync — async reads were considered and rejected (viral, reentrancy windows, breaks headless/testability, and unnecessary while the IR is CPU-evaluable). GPU eval is an optimization over the IR, never a value source the CPU can't reproduce. Async is reserved for genuinely GPU-only reads (rendered pixels, image export).
  • case conditional mapper. { case: [{ when: { data, gt/lt/eq/ne/in/... }, then }], else } — ordered clauses, conditions AND-ed within a clause, first match wins; when reads any data key or the first-class id. The declarative replacement for (ele) => cond ? a : b and the form for typed edges. Compiles to a closure-free program; CPU-evaluated (multi-key), so the GPU eval kernel is untouched. Dependency tracking generalized to CompiledMapper.keys.
  • The (ele) => props fn form removed. GpuStyleFn is gone; the sheet is props-only. The engine collapsed to one path (no def.fn branches in applyBulk/refreshMapped/labelChannels/setSheet, no fn-return throw, eleFor dropped). Selection-dependent recolouring is intentionally gone (the accent ring is shader-drawn); id-based styling migrates to case on data: 'id'. Tests/docs migrated.
  • Deferred: derived-data expression mappers (arithmetic over keys — no current use needs them); and geometry channels → GPU eval (the direct ~48 ms/200k offload, but it inverts the store→style layering since boundingBox/refsInBox/CPU-pick read resolved size — a later round).

Animation

Direction (discussion): animation is a v4 priority and should scale. API first, on the CPU-canonical path (complete + correct + Node-testable); a GPU tween fast path is the planned optimization underneath, transparent to the API.

  • Animation API + CPU tweening (src/animation.mts). Tween element style/position (and the viewport) from captured start values to explicit targets over a duration, easing normalized time. Collection: animate/animation/animated/stop/delay/delayAnimation + promise() + a per-element queue (the queue since removed — round 21 runs animations concurrently by channel); core: animate (viewport pan/zoom), animated, stop. Each tick writes the store columns (works headless; a rAF-or-timeout auto-driver, plus a deterministic tick(now) for tests). Standard easings.

    Animatable: position, node opacity, border-width, background/border/line-color — the coupling-free set; size (width/height circle-collapse) and arrow-folded channels are a follow-up (both since landed — the arrow fold in round 9.4, the geometry channels in round 25).

  • Ownership: transient lease (design set this round). A tween is CPU-reproducible (pure fn of time), so the CPU columns stay authoritative on the CPU path. The lease model — default CPU-authoritative, GPU-authoritative during a position episode with readback-on-settle — is the shared substrate for the GPU tween path and (later) GPU layouts. Grabbing is forbidden while an element animates (pointer.canDrag consults isAnimating), removing the two-way drag-feedback boundary.

  • GPU position fast path (render/gpu-tween.mts, landed). Position animations offload to a compute pass: per-slot from/to uploaded once, a now uniform bumped per frame, node.position = mix(from, to, ease(t)) on-device in its own pre-cull pass (barrier → cull + edges read the tweened positions). node.position is GPU-owned during the tween (the mirror skips its uploads), CPU reads stale, settle-on-complete re-derives the exact final on the CPU (no readback — tween is CPU-reproducible). The renderer drives the frame clock while active; the manager routes position-only animations to the sink and cedes its auto-loop.

    Playwright proves the lease on a real adapter (CPU position() stays at start mid-flight while the node moves; settles after). Paint/size GPU tweens are a follow-up.

  • Deferred: GPU tween for paint/size channels; and GPU layouts (stateful, not CPU-reproducible → GPU-authoritative-with-readback + a CPU reference for headless) — reuse the lease machinery; per-algorithm kernels are a future round.

GPU geometry and the read-staleness contract

Direction set in discussion after round 9, ahead of building the paint/size GPU tween extension. No code yet; these are the locked calls that scope that work and the expensive-geometry cases (multiline labels, bundled bezier) that sit behind it. src/README.md ("Design decisions") is the maintained record.

  • Paint tween is the clean next extension; size is a geometry-tier project. The gpu-tween.mts runtime generalizes to paint channels (node.opacity, fill/border/line color, edge.opacity) with low risk — paint has no CPU consumer (cull, CPU pick and columnar scans never read it, which is why it went GPU-evaluable in the mapper split), so a paint tween owns its column with no staleness hazard.

    Work: widen fromTo for color (two vec4f per slot; sRGB per-channel to match the current CPU tween unless we deliberately unify on OKLab), fold edge.opacity into arrow alpha in-kernel, and an ownership-precedence rule so an active tween wins over the mapper eval kernel writing the same channel. Size (width/height/border-width, edge.width) is not a peer: it is geometry read by cull, CPU pick, and every columnar scan, so a GPU-owned size tween reopens the store→style layering seam R8.5 flagged and belongs with that geometry work. Recommendation: ship paint-only (an R9.4), bundle size with the R8.5 geometry-seam work.

  • The read-staleness contract. A frame-stale sync-read contract (GPU owns expensive geometry, CPU reads a frame behind) was floated and rejected as a default, for three reasons: (1) read-after-write is pervasive and load-bearing — data()/position() then width()/bb() in one synchronous tick must reflect the write (layouts, extensions, user code all rely on it); (2) headless has no frame and no readback, so it would still need the complete CPU implementation plus a weaker contract — strictly worse than CPU-canonical; (3) "a frame stale" is undefined in synchronous code (a build-graph → query-bbs loop never yields to a frame, so staleness is unbounded, not one frame; real GPU→CPU latency is 1–3 frames regardless).

    Staleness is admitted only for values already in frame-driven motion — the position tween lease is exactly that, and edge.bb() mid-tween inheriting it is consistent, not a new rule. A discrete user write is never stale. Escape hatch for GPU-exact geometry after a write batch: an explicit await on a settle/flush, not a relaxed sync default.

  • Expensive GPU geometry → dual implementations, not readback (multiline labels, bundled bezier — v4-but-not-yet; since superseded for bundled bezier + self-loops, which landed round 12a under exactly this model). These are expensive and read by .bb(), so the position lease's no-readback trick doesn't apply directly (they aren't cheaply CPU-reproducible). The model: two deterministic implementations that agree by construction — WGSL for render, CPU for reads, run on the same inputs, neither reading back the other — the OKLab-LUT/mapper-table discipline generalized to expensive computations.

    The standing cost is keeping the two impls bit-agreeable (divergence = bb-doesn't-match-pixels), which is the actual gate on whether GPU is worth it per case. Two consumer tiers keep it affordable: cull/ fit read a cheap conservative CPU over-approximation (guaranteed to contain the true box), public .bb() triggers the exact lazy CPU compute, memoized per element. For bezier: control points are f(positions, membership) — stale via the position lease mid-tween (consistent), settle when positions are reclaimed; bundle membership is a cheap CPU structural index rebuilt on add/remove edge, not per frame.

  • Labels are model-space only (no viewport-fixed mode). font-size and the wrap width are both model coordinates (v3 parity). Load-bearing three ways: (1) line breaking is zoom-invariant (font-size and wrap width share a space), so shaping — the expensive part — memoizes and the GPU metrics pass runs on text/font/wrap writes, not per frame (a mixed space reflows on zoom and defeats both memo and offload); (2) image export is WYSIWYG — a full/high-scale export is the screen arrangement over identical shaping, so scientific figures don't reflow between screen and export and the export reuses the screen memo; (3) v3 parity, so existing figures reproduce.

    Screen-space labels were rejected: they break export WYSIWYG (reflow at a scale ≠ current zoom) and their apparent legibility win on dense graphs is overlap that makes a worse figure (a data-density limit, answered editorially, not by a coordinate system). The visibility sub-decision was taken in round 9.6: label LOD thresholds evaluate at export scale (self-consistent figure), as leaned.

Deferred by design (out of scope for the prototype)

  • Compounds: parent/parents/children/descendants/ commonAncestors/siblings/orphans/nonorphans/isParent/ isChild/isChildless/isOrphan, and compound-relative relativePosition/padding/bounds — landed in round 14.
  • Animations — landed in round 9 (CPU-canonical path; below).
  • Graph algorithms (v3/src/collection/algorithms/*): bfs/dfs, dijkstra, aStar, kruskal, bellmanFord, floydWarshall, pageRank, all centralities (degree/closeness/betweenness), all clustering (markov/k-means/k-medoids/fuzzy-c-means/hierarchical/affinity), tarjan & hopcroft-tarjan, hierholzer, kargerStein.
  • Bezier/segment geometry: controlPoints/segmentPoints/ isBundledBezier and curved edge rendering — a v4 direction, in the expensive-geometry tier (see the design discussion above): dual CPU/WGSL impls, conservative CPU bound for cull/fit, exact lazy CPU .bb(), membership as a structural index. (Since superseded: bundled bezier + self-loops landed round 12a exactly in this tier, incl. controlPoints/isBundledBezier; segmentPoints and the unbundled/segments/taxi families landed in pass 12b, same tier.)
  • Full stylesheet + mappers beyond the constant blocks and the label data(key) mapper; layouts beyond grid/preset. (Since superseded: mappers landed round 7–8; circle/concentric/breadthfirst/random layouts landed round 10.)

GPU paint tweens

Executes the paint half of the round-9 follow-up under the design calls above. The scope correction made while planning it: border-width was listed with the paint channels in round 9, but it is geometryboundingBox() reads position ± size/2 + border/2 — so it stays CPU-canonical and moves to the R8.5 geometry-seam work.

  • Paint/geometry tiers (animation.mts). Channels carry a tier: paint (opacity both groups, background-color, border-color, line-color) may offload, geometry (border-width, and later size / edge.width) may not. Paint has no CPU consumer — nothing in cull, CPU pick, or a columnar scan reads it, which is why it went GPU-evaluable in the mapper split — so a tween can own the column outright. Eligibility is all-or-nothing per animation, so a column is never half-owned.
  • One capture, two executors. capture() snapshots start values into per-channel ChannelWrites (column, kind, slots, packed from/to) once; the CPU tick and the GPU kernels consume the same numbers, so they agree by construction rather than by parallel implementations.
  • Three kernels (render/gpu-tween.mts): position (vec2), scalar (f32), color (packed rgba8). Dispatch counts come from WGSL arrayLength(&slots), not a uniform — queue.writeBuffer is ordered against submitted command buffers, not against dispatches inside one, so a per-dispatch value cannot live in a shared uniform (a bug caught while authoring; pinned by a test).
  • Tween-wins precedence, free mapper reclaim. Paint dispatches are encoded inside the cull pass after mapperRuntime.encode(); dispatches in one pass observe prior dispatches' writes (the guarantee the cull kernels already rely on), so a live tween beats the eval kernel for the same channel. On settle, the CPU write dirties the column — already the mapper's re-evaluation trigger — so the mapped value returns with no new machinery.
  • Colors tween in OKLab, matching color mappers' default: one perceptual model across the library instead of a mapper/animation split. Endpoints are converted on the CPU and packed as two vec4f (L, a, b, alpha), so the kernel needs only the OKLab→sRGB direction it shares with the mapper kernel. Deliberate v3 divergence (v3 tweened per-channel in sRGB) and a change to round 9's shipped CPU behaviour.
  • Arrow-alpha fold rides along. The arrow VS is at WebGPU's base 8-storage-buffer limit, so edge opacity is pre-folded into stored arrow alpha (stored.a = base.a × opacity). The fold is linear in opacity, so animating edge.opacity also emits a color tween per arrow end to base × toOpacity — identical math on both executors. The base comes from StyleEngine.arrowBase(), not the stored bytes, which cannot recover it when the folded opacity was 0.
  • Bugs fixed on the way in (all pre-existing, all now covered): eles.animate({style: {opacity}}) was a silent no-op on edges (the channel map was node-only); stop() on a GPU-driven animation left the CPU at the start value while the device buffers held the last frame drawn, with nothing to reconcile them (it now settles, matching v3's leave-it-where-it-got -to); a custom easing function was silently downgraded to 'ease' on the GPU (made ineligible here, then dropped from the API in R9.5); and the GPU path captured start values before the delay elapsed, unlike the CPU path.
  • A reserved-word trap, and the guard for it. target is a WGSL reserved keyword: all three tween pipelines failed to compile, the dispatches became silent no-ops, and the specs still passed on stale buffer contents. Two guards now close that hole — the webgpu Playwright project fails any test whose console reports a WGSL/validation error, and a Node test (test/modules/wgsl-identifiers.mjs) checks every shader's declared identifiers against the reserved list, so a GPU-less CI catches it too.
  • Verification: 1411 Node tests + 47 module tests, typecheck and lint clean, and 24/24 webgpu Playwright specs on a real (SwiftShader) adapter — including a paint-lease spec (pixels fade through the OKLab path mid-flight while CPU style() reads the start value; settles exactly on the target) and a precedence spec (a tween outranks a mapped opacity, which reclaims the channel on stop).
  • Still deferred: the size tween (width/height, border-width, edge.width) with the R8.5 geometry seam, and GPU layouts.

The easing layer

Round 9 shipped eight ad-hoc easings, with the four names shared with v3 drawn as different curves (max deviation 0.33 for ease) and unknown names falling back to ease silently. This round replaces that with one curve layer (src/easing.mts) that both executors run.

  • v3's enum, verbatim. linear plus the 25 named cubic-beziers, using v3's own control points, so every named curve is now identical to v3's (pinned by a test that samples both implementations across t). One exact Newton solve covers the whole enum and cubic-bezier(x1, y1, x2, y2), so there is no per-name code — the 8 hand-written curves and their WGSL twins are gone.
  • linear(...) progression arrays, in the full CSS form: bare values, explicit % stops, two stops on one entry for a flat segment, and the CSS fill rules (first stop 0, last 1, runs spread evenly, every stop pulled up to the largest one before it, so a decreasing stop reads as a jump).
  • spring(bounce) replaces v3's spring(tension, friction) with Apple's perceptual parameterization (via kvin.me): mass 1, stiffness (2π/D)², damping 4π(1 − bounce)/D — which reduces to a damping ratio of exactly 1 − bounce. So one number sets the shape: 0 is critically damped, positive rings, negative is overdamped. A spring compiles to a progression array on the CPU — the closed-form step response sampled over the whole settling window, densely enough that the chord error stays under the residual that counts as settled — so the kernel needs no physics and a spring costs exactly what linear() costs.
  • duration is perceptual for springs (the article's model, and SwiftUI's): it sets the pace of the key movement and is held constant as bounce changes, so the animation runs on past it while the ringing decays — durationMs = duration × durationScale, where the scale is the settling window measured in perceptual units.
  • One program, two evaluators. compileEasing returns {kind, bezier, points, durationScale, fn}; the CPU calls fn, and the kernel reads kind/bezier out of its params (now 48 bytes) with progression arrays on a storage buffer at binding 4 (a shared 8-byte dummy when the curve needs none). The WGSL mirrors the CPU step for step — same 11-sample bracket and Newton refinement, same binary-search lerp — so they agree to float precision; the ends are exact on both sides and a settle re-derives anyway.
  • No custom easing functions (a v3 feature, and an API break here). A closure cannot cross to the device, so keeping it would mean a curve that silently depends on whether the animation was offloaded; with cubic-bezier() and linear() covering any drawable curve, parity is worth more than the escape hatch. Unknown names now throw with the list, rather than animating on the wrong curve.
  • Overshoot handling. Bouncy curves pass their endpoints: position is let through (that is the point), while scalar channels clamp to per-property bounds on both executors (opacity [0,1], border-width ≥ 0), mirroring v3's type.min/type.max; color bytes clamp on pack, with alpha clamped explicitly (a Uint8Array write would wrap).
  • Verification: 1448 Node tests (28 new easing specs + 5 overshoot specs) + 47 module tests, typecheck and lint clean, 26/26 webgpu Playwright on SwiftShader — including a spring spec (the node visibly passes the target, still animating past its perceptual duration, then settles exactly on it) and a steep-bezier spec (ease-in-expo has barely moved at 40% of the time), which together prove both device evaluators.

Image export and the visual regression harness

Direction (discussion): ship image export next as the small design-clean round, and build a pixel-diff harness on top of it — v3 output as a tolerance-based parity check, v4-vs-v4 golden diffs as the standing regression backbone (v3 can't be a strict baseline: SDF vs canvas-2D AA, label raster/placement, the shader-drawn accent ring all differ by design).

Two calls made explicitly: goldens are checked into the repo (that is what makes them a regression tool), and v3 parity renders live in the same Playwright run rather than from checked-in v3 snapshots — same-machine images sidestep cross-platform font/AA determinism entirely and can't go stale against the v3 code actually in the repo.

  • cy.png()/cy.jpg() (Renderer.exportImage): offscreen render at the requested viewport (current view, or store.boundingBox() with full) into a transient texture + depth target, culled by a dedicated export Frame uniform and export CulledGroups through the same drawScene sequence as the screen; copyTextureToBuffer readback (256-byte row alignment stripped), BGRA swizzle + unpremultiply to straight-alpha RGBA, canvas-2D encode in the core.

    v3's options (bg, full, scale, maxWidth/maxHeight override scale, quality, output); every form resolves through one promise (sync readback is impossible on WebGPU); jpg defaults bg white; headless rejects; dimensions beyond the device texture limit throw (no tiling in pass 1).

  • Frame-coherent by construction: exports are encoded in the frame loop after that frame's scene work (deferred while backpressure keeps needsRedraw set), so they see exactly what the screen shows — a Playwright spec exports mid-position-tween and finds the node at its GPU-tweened position while CPU position() is lease-stale. Exports always render native (adaptive render scale never applies); label LOD thresholds evaluate at export scale, taking the sub-decision parked with the label design (self-consistent figures).

  • Latent bug fixed on the way in: the label pipeline cached one bind group keyed only on mirror/glyph versions — sound only while labels drew exclusively with the scene uniform; it now caches per uniform buffer like the other pipelines.

  • Pixel-diff harness (playwright-tests/lib/image-diff.mjs; pixelmatch + pngjs as devDeps): decode, rect masking, tolerance diffs, failure artifacts (actual/expected/diff PNGs), and compareToGolden with an UPDATE_GOLDENS=1 regen flow.

  • WYSIWYG self-diff (no golden needed): a viewport export at scale 1 pixel-matches a screenshot of the live canvas (≤ 0.1% of pixels) over a scene exercising all four pipelines — pins the export path to the screen path both ways.

  • v4 goldens (new visual Playwright project, pinned to SwiftShader via --use-webgpu-adapter=swiftshader so rasterization is machine-independent): four checked-in scenes — shapes/borders/opacity/ arrows, the selection accent ring, GPU-evaluated color mappers, and far-zoom LOD (floors, decimation, plain discs). Goldens stayed label-free in this round — SDF glyphs raster via OS fonts, which is not cross-platform stable — superseded in round 9.7, where a fixed web font made a label golden possible.

  • v3 parity (playwright-page/parity.html loads both UMD bundles): the same fixture rendered by both renderers in the same run, exports diffed in memory — nodes/borders/opacity/straight edges, and a zoom+pan transform case; one look in two dialects (v3 selector blocks vs v4 case mappers). Interiors agree exactly; AA differs by design, so the specs bound the mismatch ratio (measured 0.5–0.8%, asserted ≤ 2%). Two v3 gotchas guarded: v3's default layout is 'grid' (parity passes an explicit preset layout, fit: false), and v3 adopts position objects by reference (each side deep-copies the defs).

  • Verification: 1452 Node tests + 47 module tests, typecheck and lint clean, 32/32 webgpu + 6/6 visual Playwright specs; goldens byte-stable across repeat runs.

Label testability and font-family

Direction set in discussion (amendment to round 9.6: "it's important to test labels"). The 9.6 goldens excluded labels because the atlas hardcoded 32px sans-serif — the browser's generic sans-serif, which resolves to a different font per OS, making label pixels unpinnable even in principle. The package, with the load-bearing piece being a missing API, not harness design:

  • font-family as a constant, effectively global node style prop (default sans-serif) — the atlas is keyed by character, one font per atlas by design, so per-element fonts (atlas re-keyed by (font, char)) are out of scope; mappers for the prop and the edges-group form throw. A change routes store.labelFont → atlas reset (cache/pen/full + re-measured ascent, same texture object so bind groups survive) → all labelled slots marked label-dirty → one LabelLayer.process() pass rebuilds every glyph run against the new metrics.
  • A vendored OFL web font for the specs (@fontsource/open-sans as a devDependency; @font-face in the test pages; specs await document.fonts.load before instance creation). The pre-load matters because the atlas rasters lazily and caches forever: a glyph built before the font loads is cached from the fallback with no invalidation. A document.fonts.ready re-raster hook for the library is logged as a follow-up, not built here.
  • Label goldens as their own tolerance tier in visual: the fixed font pins glyph shapes/metrics and SwiftShader pins the GPU, but Chrome's atlas raster still goes through CoreText (macOS) vs FreeType (Linux), so label goldens get a looser bound (threshold ~0.25, ratio ~2%) than geometry goldens (0.5%). Escape hatch if CI disagrees: per-platform golden suffixes. A font-swap Playwright spec proves the atlas rebuild path (pixels change when the sheet's font changes).
  • Already covering labels and unchanged: the WYSIWYG self-diff (same-machine export-vs-screen, includes glyphs) and the behavioural label specs (placement, follow-on-drag, LOD fade). v3 parity keeps excluding labels — raster and placement differ by design.
  • Verification: 1461 Node tests (9 new font-family specs) + 47 module tests, typecheck and lint clean; 33/33 webgpu (incl. the font-swap spec) and 7/7 visual specs (incl. the labels-open-sans golden), the visual project stable across three consecutive runs.

Edge labels, as logged and then built

Needed regardless (discussion, 2026-07-27). A generalization, not new architecture: a second glyph stream parallel to the node one (own instance buffer + cull group + draw); edge glyphs anchor at the edge midpoint computed in the VS from the two endpoint positions, so edge labels follow drags/layouts/position tweens on-GPU with zero rebuild — the node-label trick extended to labels whose endpoints move. Cull predicate mirrors the edge cull (edge SHOWN + both endpoints SHOWN); the atlas is shared (keyed by char, so the 9.7 font work is owner-agnostic); the model side group-keys the label sidecar, label-dirty channel and StyleEngine label channels.

Pass-1 scope: horizontal at the midpoint (v3's default); autorotate — cheap in the VS via the endpoint delta, but with flip-when-upside-down readability rules — is a separate follow-up call (since landed 2026-07-29). Sequencing: after 9.7, so the label goldens/WYSIWYG harness exists to verify it; the edge-label round then just adds a golden scene.

The autonomous parity sprint

Scope criteria set with the user: this round is composed only of items whose design is already decided (or is a mechanical v3 port) and that are easily verifiable in the existing harnesses — Node test/*.mjs, the webgpu/visual Playwright projects, benchmark/ — so the round can run autonomously as far as possible. Anything needing iterative design discussion is deferred and logged (see the compaction section below and the deferred list at the end). Two design calls were made during planning:

  • Removed elements are terminally dead in v4 (recorded in src/README.md, "Design decisions"): only the handle's cached id()/group() survive removal. This permanently closes restore()/clone()/cy.json() import — the needs-a-call entry above is closed.
  • Compaction is out of this round — the motivation analysis is logged below with all policy calls left explicitly open.

Process (user-set):

  • Per-item cadence, full verify. Each item lands as its own isolated commit(s) on v4, gated on typecheck + lint + test:js (+ test:modules where relevant) + the relevant Playwright projects. Goldens are regenerated/added autonomously when a visual change is intended (UPDATE_GOLDENS=1), noted in the commit message.
  • Docs land in the same commit as the code they describe: src/README.md (scope / deviations / design decisions) and this file's round record are updated per commit, not batched at the end.
  • Escalation rule: if an item turns out to need a real design call mid-implementation, stop that item, log the question under "Needs a call", and move on to the next item — API semantics are never improvised autonomously.
  • Perf-relevant items run the matching benchmark/ sweep and record numbers here.

Round complete (2026-07-27): all 17 items landed, each as isolated commits with docs in-commit and the full verification gate per item. Net across the round: 1461 → 1629 Node tests, 33 → 44 webgpu + 7 → 14 visual Playwright specs (51 total), 7 new golden scenes, and the full v3 algorithm surface, four more layouts, viewport animation targets, data query predicates, ten node shapes, line styles, label visuals, arrow shapes, edge labels, the gesture set, mount/unmount and device-loss recovery in v4.

Items, in execution order — CPU-first (banks autonomous wins with zero renderer risk), then shader/golden work, then interaction/lifecycle. Each entry converts into a "Landed" record as it ships:

Phase A — pure CPU, Node-testable

  • A1 Algorithms: search + paths — landed 2026-07-27. bfs/dfs (+ breadthFirstSearch/depthFirstSearch), dijkstra, aStar, bellmanFord, floydWarshall, kruskal in src/algorithms/ (a shared SubgraphView — dense node index + edge membership over the calling collection — plus an indexed binary min-heap in algo-shared.mts; one file per algorithm), all slot-native over CSR with dense typed-array state, no per-node string ids.

    v3 option/result shapes preserved, including the positional bfs/dijkstra forms, bfs's exact multi-root queue mechanics, bellmanFord's same-edge relax guard and canonical negative-cycle rotation, and pathTo edge cases (unreachable dijkstra target → [target], unreachable bellmanFord target → empty). v4 deltas: node args are collections (strings throw), missing required roots/goals throw, and cycle collections dedupe the closing node (v4 collections are sets). 39 specs in test/algorithms.mjs ported from the v3 fixtures (1500 Node tests total green).

  • A2 Algorithms: structure — landed 2026-07-27. tarjanStronglyConnected (+tsc/tscc/long alias; converted to an iterative DFS so deep graphs can't overflow the JS stack — component sets identical to v3's recursive form, verified against the v3 fixtures including exact component order), hopcroftTarjanBiconnected (+htbc/htb/long alias; recursive like v3, quirks preserved: parent edges skipped incl. parallels, non-cut vertices' edges absorbed), hierholzer (slot-keyed literal port; trail dedupes to first-traversal order as v3's does), kargerStein (index-based port; throws on <2 nodes as v3's error() does).

    Tests assert order-independent graph-theoretic results (blocks, cut vertices, Eulerian properties) where v3 pinned traversal-order sequences; 12 specs in test/algorithms-structure.mjs (1512 Node tests green).

  • A3 Algorithms: pageRank + centralities — landed 2026-07-27.

    pageRank (dense power method on Float64Arrays), degreeCentrality /degreeCentralityNormalized (+dc/dcn/...Normalised; Opsahl's alpha, loops counted on both directed sides as v3), closeness Centrality/closenessCentralityNormalized (+cc/ccn; harmonic default; dijkstra per root, floydWarshall for normalized), betweennessCentrality (+bc; Brandes over deduped neighbor lists with first-edge weight pick as v3, but a proper decrease-key heap so S is truly distance-ordered). 19 specs pin v3's exact numeric expectations (all matched, incl. the multiple-shortest-paths case); test/algorithms-centralities.mjs (1531 Node tests green).

  • A4 Algorithms: clustering — landed 2026-07-27. kMeans, kMedoids, fuzzyCMeans/fcm, hierarchicalClustering/hca (threshold + dendrogram modes, addDendrogram), markovClustering/ mcl (Float64Array matrices), affinityPropagation/ap, plus the shared clustering-distances metric module. The attribute-space algorithms stay handle-level like v3 (they're feature-space, not adjacency walks); markov builds its matrix off the slot view.

    v3 quirks preserved: raw-option validation for affinity (damping and preference effectively required), the 2-arg custom distance form when no attributes are given, kMedoids' k>n throw. 25 specs pin the v3 fixtures' numeric expectations (k-means/k-medoids/fcm/markov cluster memberships in exact order, dendrogram levels 0–10); affinity gets a compact deterministic fixture instead of v3's 700-line one. test/algorithms-clustering.mjs (1556 Node tests green).

  • A5 Algorithm benchmark — landed 2026-07-27. benchmark/algorithms.mjs (standalone Mitata sweep; superlinear ops gate on BENCH_N). At N=2000 (4k edges) the slot-native walks win every op vs v3: bfs 34×, dfs 39×, dijkstra+pathTo 33×, bellmanFord 22×, kruskal 14×, tarjan SCC 19×, hopcroft-tarjan 20×, betweenness 13×, degreeCentralityNormalized 22×, closenessCentrality 31×, aStar 2.1×, hierholzer 2–3×. The dense-matrix ops are parity, as expected (identical math dominates): pageRank/floydWarshall/markov/ hierarchical/kMeans all within ±1.2× at N=500.

  • A6 Layouts — landed 2026-07-27. circle, concentric, breadthfirst, random (handle-level ports of the v3 math — these layouts are per-node-callback-shaped, unlike grid's slot path), plus the v3 plumbing on the collection: layoutDimensions, layoutPositions (spacingFactor scaling, transform, fit/zoom/pan, the layoutstart/ready/stop event flow, and animate: true via the existing animation system — handle-memoized, animateFilter honored; the fit applies at layoutstop until A7's animated fit), and eles.layout()/makeLayout/createLayout (grid and preset honor eles scoping too, incl. fit-to-eles).

    Two corrections vs the repo's v3 files, both noted in code: circle calls layoutPositions on the sorted collection (upstream v3 behavior — the repo's TS port calls it on the unsorted one, so sort does nothing there), and breadthfirst compacts the nulls left by maximal shifts before sorting a depth (v3 passes them into its comparator). 28 specs in test/layouts.mjs (1584 Node tests green).

  • A7 Viewport animation targets — landed 2026-07-27. cy.animate/cy.animation (the handle form is new, mirroring eles.animation) take fit: { eles | boundingBox, padding } and center: { eles }, resolved to concrete pan/zoom at creation time (v3 semantics — pinned by a spec that moves a node after creating the animation); fit/center bypass the pan/zoom gating flags, like fit() itself.

    eles.boundingBoxAt(posOrFn) landed with it (side-effect-free direct computation, edges spanning out-of- collection endpoints at current positions) — pulled forward from A9 because the animated layout fit needs it: layoutPositions with animate: true now animates the viewport to the final arrangement's box concurrently with the node tweens, exactly v3's shape (the A6 fit-at-layoutstop compromise is gone). Note: v3's animated fit()/center() options don't exist in v3 either — the target form is the parity surface. 9 specs in test/viewport-animation.mjs (1593 Node tests green).

  • A8 Data query predicates — landed 2026-07-27. Query gains data: { key: value | { eq/ne/lt/lte/gt/gte/in } } (bare value = eq; keys AND together), compiled to CompiledCondition[] on the plan and evaluated with the same testCondition the case mapper uses (missing value fails every op, ne included; exactly one op per condition; in non-empty; ordinal ops numeric — all throwing as the mapper does). The whole-graph scan (scanRefsInto) takes the tests with per-key column readers hoisted out of the loop (DataStore.reader); the collection-filter and planMatchesRef paths apply them too. 10 specs in test/query-data.mjs (1603 Node tests green).

  • A9 Small items — landed 2026-07-27. (boundingBoxAt landed with A7.) padding()/paddedWidth/paddedHeight: accessor-only — v4 has no padding style prop (compounds-era), so padding reads 0 and padded dims equal plain dims; kept so v3 call sites work. cy.serialize(): live-graph export to the wire ArrayBuffer (ids, positions, selected/selectable flags, and the data() sidecar via DataStore.exportColumns — numbers as f64+NaN holes, strings as dictionary columns, mixed as arrays), round-tripping through options.elements/cy.add(); 7 Node specs incl. selection state, post-load mutations and empty graphs.

    Web-font re-raster hook: the renderer listens for document.fonts's loadingdone and re-rasters the atlas + rebuilds all glyph runs (GlyphAtlas. reraster, store.markAllLabelsDirty), closing 9.7's cached-fallback-glyph footgun; removed on destroy. Playwright spec registers a FontFace after the label renders and pins the pixel change (an @font-face family can't test this — the atlas's own canvas use starts its load). Verified: 1610 Node + 47 module tests, 34 webgpu + 7 visual Playwright specs on a fresh bundle (note: a stale http-server on :3333 silently serves an old bundle to Playwright — kill it before trusting a run).

Phase B — renderer/shader work, golden-verified

  • B1 Node shape parity — landed 2026-07-27. Ten polygon shapes (triangle, pentagon, hexagon, heptagon, octagon, diamond, rhomboid, vee, star, tag, + square alias) from one point-table source of truth (shape-points.mts, built with the same shared math generators v3's node-shapes registration uses — identical geometry).

    Shape ids 4–13 in contract.mts; WGSL per-shape SDF functions are generated from the tables (iq's sdPolygon, vertices scaled by half-size so the device-space distance is exact — first cut evaluated in normalized space and showed smeared borders on stretched nodes in the golden; exact-space fixed it); CPU pick uses exact point-in-polygon in normalized space (inside-ness is affine-invariant); the depth prepass treats polygon interiors via their SDF (conservative rect/roundrect/ellipse fast paths kept).

    round-* polygon variants deliberately not ported (no clean closed form under anisotropic scale) along with cut-rectangle/barrel/etc — README records the list. Verified: 5 polygon CPU-pick specs (incl. concave star/vee and an anisotropic hexagon), keyword parse+readback specs, and a polygon-shapes golden (11 nodes incl. a selected star's accent ring and a stretched hexagon), stable across repeat runs; 1617 Node + 47 module tests, 42 Playwright specs green.

  • B2 line-style: solid | dashed | dotted — landed 2026-07-27.

    New edge.lineStyle column (contract LINE_* ids) with the full style plumbing (keyword parse, case mappers, stored-truth readback); the edge VS emits a model-px longitudinal varying and a flat style id, and the FS applies an AA'd dash mask (v3's patterns: dashed [6, 3], dotted [1, 1], model units so dashes zoom with content). Picking ignores gaps as v3 does; the pick FS is untouched. border-style skipped per the plan's stretch clause (dashing an SDF boundary needs perimeter parameterization) — README records it. line-styles golden (three styles + a wide diagonal dashed edge proving the pattern runs along the edge); 1618 Node + 47 module tests, 43 Playwright specs green.

  • B3 Label visuals — landed 2026-07-27. text-outline-width/ -color/-opacity (second SDF threshold in the label FS; width precomputed CPU-side into SDF sample units), text-background-color /-opacity/-padding (a solid quad instance preceding the run's glyphs — a negative-u0 sentinel skips the atlas sample; it carries the glyph block's height so LOD fade/cull match the text exactly), text-margin-x/y (margin-y folds into the anchor; both kept in the entry for readback). All eight props are mapper-capable (added to the MAPPABLE table; applyMapped writes whole elements so the label sidecar rebuilds through the existing path).

    Glyph instances grew 40 → 48 bytes (outline color + width). Two WGSL uniform-control-flow traps hit and fixed: textureSample and fwidth both hoisted above the solid-quad branch (caught by the validation-error guard). label-visuals golden (outline, boxed, margin-shifted) at the label tolerance tier; 1619 Node + 47 module tests, 44 Playwright specs green.

  • B4 Arrow shape parity — landed 2026-07-27.

    vee, chevron, circle, square, diamond, tee (+ the arrow alias), with WGSL SDFs generated from v3's arrow point tables (shared ARROW_POINTS in shape-points.mts; tip-at-origin frame, uniform scale — v4's arrow sizing turns out to be exactly uniform: halfBase/0.15 == arrowLen/0.3). The arrow FS now evaluates a shape SDF in the arrow-local frame instead of the triangle's lateral-taper mask (the triangle's geometry is unchanged, only its AA method — nodes-edges-arrows golden regenerated); shape ids pack source|target<<8 into a new edge.arrowShapes column bound fragment-only, keeping the arrow VS at its 8-storage-buffer budget. Readback keeps the stored-truth rule (transparent arrow → shape 'none'), now returning the real keyword otherwise. Compound shapes not ported (README lists them). arrow-shapes golden (7 target shapes + a source-end chevron pinning the byte order); 1621 Node + 47 module tests, 45 Playwright specs green.

  • B5 Edge labels pass 1 — landed 2026-07-27, exactly the logged shape. Model: the label sidecar, label-dirty channel and setLabel/labelAt/takeLabelDirty are group-keyed (trailing group param defaulting to 'nodes', so node call sites read unchanged); StyleEngine's label channels — the passthrough label, font-size, color and all the B3 text visuals — now compile for edges too (the edge write path calls the shared writeLabel, edges centering on the midpoint by font size).

    Renderer: a second GlyphBuffer in the LabelLayer, an edgeGlyph cull kind (predicate = edge SHOWN + both endpoints SHOWN + fade/min-height + viewport at the midpoint), and the label shader generated for both streams from one template — the edge variant binds edge.endpoints and computes the midpoint anchor in the VS, so edge labels follow drags/ layouts/position tweens on-GPU with zero rebuild (spec-pinned: an endpoint move re-uploads ≤64 B and the label lands at the new midpoint).

    Also fixed en route: a hardcoded 40-byte glyph stride in the renderer's cull-capacity math (stale since B3's 48-byte instances; benign over-allocation) now uses GLYPH_BYTES. Horizontal only — autorotate stayed the separate follow-up (since landed 2026-07-29; see the autorotate entry below). 7 model specs (test/edge-labels.mjs), the follows-drag webgpu spec, and an edge-labels golden (midpoint + background box on a diagonal edge); 1628 Node + 47 module tests, 47 Playwright specs green (twice).

Phase C — interaction & lifecycle, Playwright-verified

  • C1 Gesture parity — landed 2026-07-27. Right button: cxttapstart/cxtdrag/cxttapend + cxttap (no-move), with the canvas context menu suppressed; taphold after a 500 ms unmoved press; dbltap on a same-target second tap within cy.multiClickDebounceTime() (default 250 ms; new ctor option + validated getter/setter) plus the debounced onetap; and drag-all-selected — grabbing a selected node collects every draggable selected node into a drag set moved by one bulk shift per pointermove (all flagged grabbed, unflagged on release/cancel).

    Verified by two Playwright specs (the event-order cxttap/dbltap/ taphold sweep and a three-node drag-set spec) + a Node accessor spec; 1629 Node + 47 module tests, 49 Playwright specs green (serial run; parallel runs on this loaded machine flake one arbitrary visual spec — an env issue, not a code one).

  • C2 mount/unmount — landed 2026-07-27.

    The factory's renderer+pointer wiring moved into a reusable _attachFn on the core (with the WebGPU-availability check at attach time); unmount() destroys pointer + renderer and the instance reads headless with a resolved ready; mount(container) re-attaches a fresh renderer — the new ColumnMirror's from-zero realloc re-uploads every column, and markAllLabelsDirty() requeues every glyph run (the old LabelLayer had consumed the dirty channel). Same-container re-mount no-ops; a different container unmounts first; cy.destroy() now also tears the pointer down. Playwright spec: render → unmount (headless, canvas removed, png rejects) → move/ relabel/add while headless → mount → the moved node, its rebuilt label, and the headless-added node all render. 1629 Node + 47 module tests, 50 Playwright specs green (serial).

  • C3 Device-loss recovery — landed 2026-07-27, with the proposed policy recorded as the decision: an external loss emits devicelost and auto-recovers once per loss by re-mounting a fresh renderer against the same container via C2's machinery (the model is CPU-canonical, so mirrors/pipelines/glyph runs all rebuild), then emits devicerestored; a loss during recovery or a failed re-acquisition goes headless-dead + error (the previous behavior).

    Plumbing: gpu-context now surfaces every loss and the renderer distinguishes its own teardown by its destroyed flag (so renderer.destroy() stays silent); a _debugLoseDevice() test hook destroys the device externally. Playwright spec: lose the device → devicelostdevicerestored → post-loss writes render. 1629 Node + 47 module tests, 51 Playwright specs green (serial).

Deferred out of this round (logged, not built): compaction (below; the slot-stable tier since landed as round 11, the slot-moving tier as round 19); autorotated edge labels (since landed 2026-07-29); multiline labels (since landed, round 16); bezier edges (round-12 plan written; passes 12a/12b/12c since landed — round 12 is complete); compounds (since landed, round 14); z-index (since dropped by decided design, 2026-08-01); GPU layouts (since landed, round 18); size tweens (the R8.5 geometry seam); renderTo; restore/clone/json-import (closed — not in v4); the three-finger touch box gesture (since landed, round 20.5).

Renderer benchmarks

The renderer's recorded numbers (fps tables, pan ms/frame, pick latency, init/export costs) were manual debug-harness measurements; this makes them a repeatable command.

npm run benchmark:renderer (or benchmark:report -- --renderer to fold into the combined report) runs benchmark/render-bench.mjs: a Playwright-library driver (not a test project — no assertions, not in CI's sweep) that serves the repo on an ephemeral port (no stale-:3333 dependence; bundle-vs-src mtimes are checked and warned), launches Chromium channel: 'chromium' with --enable-unsafe-webgpu, aborts without a real adapter (software adapters warn — different machine class), and drives render-bench.html: one instance at a time on a shared stage, seeded 25k×50k / 100k×300k generators + stripped ndex-x-large, v3 canvas vs v4 WebGPU on identical defs and constant styles.

Scenarios: continuous-pan steady state (fit-all / zoomed-in 20× / far-zoom ÷8, labels off/on) — programmatic panBy per rAF, warm-up then sampling until window + a minimum frame count; wall ms per rendered frame (v4: stats().frames delta, since backpressure skips ticks; v3: the tick delta, since the canvas draw runs inside it) as the comparison metric, with stats().gpuFrameMs (timestamp-query) as gpu (device) rows — the vsync-unbounded cost; hover-while-panning pick() latency percentiles; one-shot init / columnar init / full-png export (≤2048 px — full-graph exports would exceed the device texture cap). dpr 2, 1280×800, render scale pinned to 1.

Results emit the same mitata-shaped stats (render-stats.mjs, unit-tested) so report-html.mjs renders renderer sections unchanged; jobs carry a note (new, rendered once per section) stating the vsync bound and pinned config. First full run (M2, Metal, dpr 2), fit-all pan p50 v3-vs-gpu wall: 336 ms vs 10.6 ms at 25k×50k (device 7.8 ms; far-zoom device 2.1 ms — decimation), 2.05 s vs 15.2 ms at 100k×300k, 1.86 s vs 32.8 ms on ndex-x-large (~30 fps native, matching the round-recorded "25 fps before adaptive scale"); init 7.7 s vs 457 ms at 100k; ndex pick p50 0.1 ms (the CPU fast path).

The benchmark HTML report

npm run benchmark:report runs the Mitata suites and renders one self-contained HTML page (plus a timestamped results JSON) into the gitignored benchmark/results/.

Pieces: bench-run.mjs — a shared finishRun() tail that, under BENCH_JSON, runs quietly and captures per-group/per-bench stats (mitata's run() returns them; sample arrays stripped) with terminal behaviour otherwise unchanged; report.mjs — the job-table orchestrator (quick profile at default scales; --full adds the 2k/20k/200k matrix with one process per group at 200k via BENCH_OP, per the suite headers; failures logged and reported, partial reports still render; --suite filter, --render-only re-render); and report-html.mjs — a pure results→HTML renderer (Node-tested in test/modules/benchmark-report.mjs): times as dumbbell dots on log₁₀ axes (position, not bar length — length encodes nothing on a log axis), a ranked speedup overview against a 1× reference line, geo-mean/best-win stat tiles, per-suite table views, a cross-N scaling table on full runs, light+dark styling, hover/focus tooltips, no external assets.

Decisions: quick-by-default (full is opt-in), local gitignored artifact, Mitata suites only — the browser-side numbers stayed manual at this point (since superseded: the renderer benchmarks above made them a command, folded in via --renderer).

v3 → v4 parity gap analysis

A systematic sweep of the entire v3 public surface, diffed against v4. Sources: the v3 style registry (v3/src/style/properties.mts — 280 registered properties + 11 aliases across 21 groups), the docmaker API index for core and collection (cross-checked against the prototypes), the v3 renderer's event/gesture emission (load-listeners.mts), the layout and extension registries, and the documented init options — diffed against src/README.md plus source spot-checks of src/.

Every gap below is classified into one of four tiers: at parity, dropped by decided design (recorded, no action), gap with direction set (build when scheduled), and needs a call (API semantics are never improvised autonomously). A final tier lists proposed drops — v3 features we suggest not porting; none of those is decided until signed off.

At parity (verified, no action)

Core viewport/events/data/batching, the whole collection iteration/comparison/building surface (incl. eq/first/last/ slice/toArray/anySame/symmetricDifference/ closedNeighborhood), traversal, degree, flags/switches (incl. active/pannable), the full v3 algorithm surface, layouts grid/preset/circle/concentric/breadthfirst/random (+ eles.layout() plumbing), png/jpg export options, mount/unmount/destroy, stop(clearQueue, jumpToEnd) (since round 21 stop(jumpToEnd) — no queue)/delay/delayAnimation, box selection with selectionType, pinch zoom, the cxttap/dbltap/taphold gesture set, and data/scratch/json() export.

(Where v3 takes a selector these take collections/queries/predicates — the decided v4 form, not a gap.)

Dropped by decided design (recorded in src/README.md; ledger only)

Selector strings and cy.$(); classes; per-element style bypass/setters (style(name, value), removeStyle, flashClass); style functions; CSS-string stylesheets and the cytoscape.stylesheet() builder (follow from the { nodes, edges } object-sheet decision — worth recording explicitly); selection-dependent restyling (:selected blocks → shader accent ring); restore/clone/copy and cy.json() import; custom easing functions and spring(tension, friction) (→ spring(bounce)); event namespaces; v3 bubble order within a phase (registration order instead; compound bubbling itself landed round 14.5 with v3's cross-phase order); per-element font-family; viewport-fixed labels; renderTo; cy.notify/notifications/ noNotifications (dirty-driven renderer).

Added by the 2026-07-29 triage (below): the canvas-era perf degradation options (hideEdgesOnViewport, textureOnViewport + outside-texture-bg-*, motionBlur/motionBlurOpacity — obsolete under compute culling + adaptive render scale); background-blacken (subsumed by color mappers); bounds-expansion (bounds are computed correctly instead); and the legacy aliases (content, padding-{left,right,top,bottom}, no-dash shape spellings, redundant attr-family duplicates — one name per concept), less two recorded exceptions: autolockNodes and autoungrabifyNodes are kept (fifth design sitting, 2026-08-04).

The legacy-alias line was not true of the code (found 2026-08-03 by the round-29 docs check, and left as a call rather than patched): cy.autolockNodes() and cy.autoungrabifyNodes() were declared, wired and working, and round 29.1's alias table pinned them; the no-dash shape spelling roundrectangle likewise still compiled, where cutrectangle and concavehexagon threw. So the 2026-07-29 triage was applied unevenly and three names survived it.

It was one call, not three — "does the one-name-per-concept rule actually apply to these?" — and the fifth design sitting split it (executed as round 37.2): roundrectangle drops and throws with its siblings, from all three enums that took it; the two core aliases stay, as exceptions written into the ledger line above rather than left as drift. The line the call had to change was the roundrectangle one in test/decided-drops.mjs, which now pins the drop; the alias table in test/aliases.mjs keeps its two rows, with the reason. content and padding-{left,…} do throw, as does every other prop in this ledger — pinned since 29.3 by test/decided-drops.mjs.

Gaps with direction already set (build when scheduled)

  • Curved edges — the single biggest visual gap. Pass 12a (bundled bezier + self-loops) landed 2026-07-30, and pass 12b (unbundled-bezier, segments, round-segments, taxi, round-taxi) landed 2026-07-30/31 — see the round records. Still open from v3's curve-style: haystack and straight-triangle plus manual endpoints (the 12c pass).12c landed 2026-07-30/31, completing the family.

    Brings with it: self-loops (loop-direction/loop-sweep — a loop degenerated to a point in v4 when this was written), control-point-*, segment-*, taxi-*, radius-type, edge-distances, source/target-endpoint, source/target-distance-from-node, and the accessors controlPoints/segmentPoints/ renderedControlPoints/isBundledBezier. Design tier decided (2026-07-24): dual CPU/WGSL impls, conservative CPU bound for cull/fit, exact lazy .bb(), membership as a structural index. The 2026-07-29 triage added curve-style: haystack (+ haystack-radius) and straight-triangle to this surface: kept as real visual styles (offset-endpoint and triangle-shaped edges), not perf modes — v4's culling makes the perf rationale moot but the looks stay.

  • Ghost propslanded as round 13 A1 (2026-07-31). Kept in the 2026-07-29 triage (SBGN needs them), in a simplified form: a ghost duplicates only the basic node body — shape, border, background — at the offset, an extra instance draw, never a whole-cloth redraw of the full node (labels and other decorations excluded).

  • Overlay/underlay theminglanded as round 13 A2 (2026-07-31), core props included. The 2026-07-29 triage decided to port the props (the 10 overlay/underlay element style props plus the active-bg-* and selection-box-* core options) rather than keep the affordances baked in; the existing shader hover/active brighten, accent ring and DOM selection box become the styled defaults.

  • Multiline labelslanded as round 16 (2026-08-01), along with label bounding boxes. text-wrap/text-max-width/ text-justification/line-height/text-overflow-wrap (+ ellipsis), on the decided tier (shaping memoizes; model-space keeps it zoom-invariant).

  • Edge label autorotate (text-rotation: autorotate)landed 2026-07-29 (see the autorotate entry below); the flip rule call was taken as v3's verbatim undirected-slope angle. Per-element numeric text-rotation stays in the label-parity batch.landed as round 27.7 (2026-08-02), on any label.

  • Force-directed layoutlanded as round 18 (2026-08-01): cy.layout({ name: 'force' }), spring–electric with a CPU reference executor and an on-device integrator under the position lease. The call this entry left open (port cose vs a modern kernel) was taken for the latter. (Round 59, 2026-08-09, rebuilt the model after the round-18 one was measured unstable past node degree ~20 — see the round-59 plan and records.)

  • Compaction — slot-stable tier (id blob / CSR / dictionary reclaim) landed in round 11 with auto waste thresholds; the slot-moving tier still waits on the logged policy calls — the slot-moving tier landed as round 19 (2026-08-01), policy calls and all.

  • z-indexdropped outright (decided 2026-08-01, no round at all): draw order is structural and stays that way, so z-index, z-compound-depth and z-index-compare are not coming to v4 and neither is zDepth/sortByZIndex. See the decided-design bullet in the README. The mechanism this entry named (more z-ranks or a u32 index-indirection pass) survives only as the logged single-boolean elevated tier, if demand ever appears.

Needs a call (design open — grouped, with the v3 surface at stake)

(The still-open items here are collected, with their evidence and what changes when each is decided, in "Open calls for the maintainer" near the top of this file — read that first; this ledger keeps the per-item history.)

  1. Compound nodeslanded as round 14 (2026-07-31; the plan and per-item records are at the end of this file): hierarchy in the columnar store, auto-sized parents materialized into the position/size columns, the parents sheet group + structural query/case terms, ancestor-gated visibility + rendered effectiveOpacity, ported event bubbling, compound loop edges, the parent draw stream, and layout/tween/interaction rules. The original scoping notes, for the record: the largest single absence. Style: the 8-prop compound group + :parent visuals + padding/ padding-relative-to + z-compound-depth/z-index-compare + compound-sizing-wrt-labels.

    Collection: parent/ancestors/ children/descendants/siblings/orphans/nonorphans/ commonAncestors/isParent/isChild/isChildless/isOrphan, move({ parent }), forEachUp/Down, compound-relative relativePosition, effectiveOpacity semantics, event bubbling through parents, cose nesting. Needs its own design round: hierarchy in the columnar store, parent auto-bounds vs cull/bb, render order.

  2. Background images (16 props) — per-node images/icons are ubiquitous in real apps (background-image + fit/clip/position/ repeat/opacity/smoothing/crossorigin...). GPU shape: a texture atlas or array keyed per element; interacts with the fixed-atlas discipline. High app value; sizeable renderer feature. Landed as round 15 (2026-08-01, below): tiered texture arrays + mips, SVG zoom-promotion, an SDF icon mode, multi-image parity.

  3. Pie / stripe backgrounds (51 + 50 props) — SDF-friendly in principle; the call is whether v4 wants them (or a leaner generalization) at all. Call taken 2026-08-01 (third design sitting): yes, as the lean list-valued chart family designed for future chart kinds — scoped as round 23 (plan at the end of this file).

  4. Node visual parity batch — gradients (background-fill/line-fill linear/radial + stop props), corner-radius control, border-style/-cap/-join/ -dash-pattern/-dash-offset/-position, the node outline-* group (5), custom polygon via shape-polygon-points (per-element point data), and the unported shape keywords (round-* family, cut-rectangle, barrel, concave-hexagon, right-rhomboid, bottom-round-rectangle). Each is small-to-medium; needs a scope call on which subset earns its shader/channel cost. (background-blacken and bounds-expansion were in this batch until the 2026-07-29 triage dropped them.)

    Landed as round 13 (2026-07-31, B/C series): gradients, corner-radius, border-position, dash pattern/offset/cap, the outline group, the custom polygon.

    The shape keywords landed as round 27 (2026-08-02): the two plain-polygon ones as point tables, cut-rectangle/bottom-round-rectangle/barrel as parameterized fields, and the seven round-* keywords as sdPolygon( inward-offset ) − r — the identity that makes corner-rounding exact under anisotropic scaling, which is what the earlier "no clean closed form" note had missed. v3's node-shape vocabulary is complete. Still open: border-style/outline-style, held for exactly the scope call this item's own sentence above asks for — see the round-27.8 entry for the three cost tiers. (Call taken 2026-08-04: full coverage — scoped as round 38: scoping it turned up three further sub-calls the sitting did not reach, logged in open call 1 — all three taken at the sixth sitting, 2026-08-06, and round 38 landed 2026-08-08.)

  5. Arrow paritymid-source/mid-target positions, arrow-fill: hollow, arrow-width, arrow-scale, compound shapes (triangle-tee/circle-triangle/triangle-cross/ triangle-backcurve). Mid-arrows are cheap on straight edges but really belong with curved-edge midpoint math. Landed as round 13 (2026-07-31, B7/C1): arrow-scale, per-end fill/width, mid-arrows on the curve/route midpoint. Closed by round 27 (2026-08-02): the four compound heads landed in 27.6 and v3's nonlinear arrow-size formula in 27.3, so v3's arrow vocabulary is complete and arrow sizes match v3's in every width regime — measured, not asserted, with the live parity diff moving 4.459% → 0.013%. Recorded deviation: a hollow compound head falls back to filled. Re-opened by round 55 (2026-08-06), and the way it was closed is the lesson. The vocabulary is complete and the sizes match; what round 27 never measured is how the head relates to the line beneath it, because its parity scene used opaque filled heads whose own fill hides the difference, and every later curve scene set arrow-shape: none on the reasoning that arrows were where the two renderers differed. v4 implemented no arrow gap at all: v3 shortens the drawn line by arrowShapes[shape].gap(edge), v4 ran it to the node centre. Measured against v3 at the time: 3.5% of the frame for the wedge a filled head leaks around its tip, 11.8% for a hollow head showing the line through its interior, 26.7% for a translucent edge compositing line and head separately. Closed by round 56 (2026-08-07): v3's gap and spacing both port, the three scenes read 0.000%, 0.442% and 0 differing pixels, and the round found and fixed a second defect nobody had predicted — a hollow head's stroke reaches outside its polygon and was being clipped at the back corners. What is left of arrow parity is three recorded deviations rather than unbuilt work, all in src/README.md: mid arrows are not covered by a trim (open call 21, and may end up unsupported), v3's erase reaches under a head where a trim cannot, and two translucent heads that overlap each other composite where v3 flattens them. Plus two questions the round raised: the arrow-scale quantization (item

    1. and the two stages the trim cannot reach (item 24 — since closed by round 58, 2026-08-09).
  6. Label parity — placement (text-valign/text-halign grid vs v4's fixed below-node), per-element numeric text-rotation, source/target edge labels (10 props — second/third label streams), text-opacity, text-transform, font-style/font-weight, text-border-*, text-background-shape, and per-element min-zoomed-font-size vs v4's global labelFadePx/labelMinPx. Landed as round 13 (2026-07-31, B6/D series): the halign/valign grid, text-opacity/transform/border/background-shape, font-style/-weight, per-element min-zoomed-font-size, and the source/target label streams. Numeric text-rotation landed as round 27.7 (2026-08-02), on any label, alongside the autorotate keyword (edge-only — it resolves from an edge's slope). Also: labels are excluded from boundingBox() in v4 — v3's includeLabels (and the bb options object generally) affects fit() semantics; the conservative-label-bound design (already sketched for multiline) is the likely answer. Landed as round 16 (2026-08-01, below): the wrap family, and labels join bb/fit by default with { includeLabels } opt-out.

  7. Event vocabulary — v4 lacks the element state events (grab/grabon/drag/free/freeon/dragfree/ dragfreeon), the normalized device events (tapstart/ tapdrag/tapend + vmouse* aliases, raw mousedown/ mousemove/mouseup/click), tapdragover/tapdragout hover-during-drag, cxtdragover/cxtdragout, tapselect/tapunselect, and the viewport-gesture variants (dragpan/scrollzoom/pinchzoom). Event objects also lack preventDefault/stopPropagation and bubbling semantics. Mostly cheap plumbing, but every name is permanent API — one deliberate call on the v4 event vocabulary is better than accretion. Landed as round 17 (2026-08-01, below): the curated set plus the official pointer-event family.

  8. Interaction options + touch paritywheelSensitivity, touchTapThreshold/desktopTapThreshold, configurable taphold duration, pixelRatio, per-element events/text-events (pointer-transparency), box-selection: overlap mode (v4 is 'contain' only), two-finger cxttap on touch, and the three-finger box gesture (currently listed as not implemented).

    Scoped as round 20 (2026-08-01, plan at the end of this file): the option quartet + events/text-events + both touch gestures; pixelRatio found already landed; the overlap box mode deferred as a demand-gated hook (not v3 surface) — and landed as round 39.1 (2026-08-04) once the fifth sitting took the call, as the core option boxSelectionMode: 'contain' | 'overlap'. Two things that entry did not say: v3 spells the same choice as a per-element style prop (box-selection), and cy.elementsInBox() deliberately stays pure containment, so the mode is read by the gesture alone.

  9. Animation surfacestep callback, queue: false, renderedPosition targets, Animation object controls (pause/progress/reverse/apply/applying/completed — v4's handle has play/stop/promise), and style transitions (transition-property/-duration/-delay/ -timing-function): call whether transitions return as sugar over the animation system or stay out.

    Partially resolved 2026-08-01 (third design sitting): v4 animations need not match v3 — the queue is dropped outright (round 21) and step stays out; controls and transitions remain the open follow-up. **Remainder scoped 2026-08-01 (fourth design sitting) as round 24 (plan at the end of this file): transitions return with a v4-specific trigger taxonomy + the domain perf contract, and pause/resume/reverse land (progress stays a getter, apply/applying stay out). Round 24 landed in full the same day — item closed; the geometry tween (size-channel transitions

    • animation, one benchmarked round) was the successor follow-up, built as round 25 (2026-08-02) — also closed.**
  10. Extension systemcytoscape.use() and cytoscape(type, name, registrant) registration for layout/renderer/core/collection extensions. v4 has none; this gates the entire external ecosystem (fcose, dagre, elk, cola, edgehandles, ...). At minimum a v4 layout extension contract needs designing; core/collection extension points are a separate call. The layout contract landed as round 17 (2026-08-01, below): direct objects, no registry; core/collection extension points stay deferred (recorded) — and closed by the fifth sitting (2026-08-04): they stay out of 4.0 by decision, demand-gated exactly as logged.

    One thing the closure did not cover, found by round 45 and fixed there: the layout contract shipped no types, so the external authors this item exists for typed run( ctx ) as any. LayoutContext, LayoutImpl and CustomLayout export now — see open call 14.

  11. display vs visibility — v3 distinguishes display: none (no space) from visibility: hidden (occupies space) from zero opacity; v4 has one show/hide flag. Call: is one flag enough, and what do visible()/takesUpSpace() mean exactly. Resolved 2026-08-01 (third design sitting): both tiers exist — show/hide stays the display tier (now re-fanning bezier bundles, v3's structural semantics), visibility lands as a mapper-capable style prop keeping space and bundle ranks — scoped as round 22 (plan at the end of this file).

  12. Odds and ends — trued up 2026-08-03 (round 28.3), because three of the six entries had stopped being true:

    • cy.window()exists (core.mts, with a "v3 parity" doc comment). It was listed as a gap it had already closed.
    • panBy animation targetlanded as round 28.2 (2026-08-03).
    • layout instances as event emittersnot a gap but a decision: round 17 settled that lifecycle events fire on the core exactly once per run and layout instances stay non-emitters (recorded in the README's extension-contract section). It belongs in the decided-design ledger, not here. Genuinely open, each needing a call rather than an implementation:
    • cy.gc() — v3's manual garbage-collect hook. Round 19 gave v4 cy.compact() plus an automatic trigger, so the question was whether gc survives as anything but an alias. Landed as round 39.3 (2026-08-04): it survives as the alias, the alias table's 84th row, kept because an upgrading app already types it and v4 has no separate garbage-collection concept for it to name.
    • cytoscape.warnings() — the global console-warning toggle. v4 warns in several places (a deferred compact(), a full glyph atlas), so there is something to silence; whether a global mutable switch is the v4 spelling is the call.
    • graph-level data in the wire format — narrower than it read: cy.json() already exports it (core.mts), and the gap was the binary format (serializeElements), which carried elements only. Since cy.serialize() output feeds cy.add(), including graph data raised whether adding elements should overwrite the target's data() — a semantics call, not an omission to patch. Landed as round 39.2 (2026-08-04): format version 4, flag F_GRAPH_DATA, one JSON string written last so v2/v3 buffers keep loading; options.elements applies graph data and cy.add( buffer ) ignores it.

Proposed-drops triage (decided 2026-07-29)

The proposed-drops list was triaged with the user in one sitting; every entry now has a decision.

  • Dropped (added to the decided-design ledger above):
    • Canvas-era performance hackshideEdgesOnViewport, textureOnViewport (+ outside-texture-bg-*), motionBlur/ motionBlurOpacity. Obsolete under WebGPU + compute culling + adaptive render scale, which solve the same problem without degrading interaction.
    • background-blacken — subsumed by color mappers (compute the shade in the mapper range instead).
    • bounds-expansion — a manual bb-correction escape hatch; unnecessary when bounds are computed correctly.
    • Legacy aliasescontent, autolockNodes/ autoungrabifyNodes, padding-{left,right,top,bottom}, the no-dash shape spellings (roundrectangle etc.), attr-family duplicates beyond the ones already kept. One name per concept.
  • Kept (moved to "gaps with direction set" above):
    • curve-style: haystack (+ haystack-radius) and straight-triangle — ported as real visual styles, not perf modes, alongside the curved-edge work.
    • Ghost props (ghost/ghost-offset-*/ghost-opacity) — needed for SBGN, kept with simplified scope: the ghost duplicates only the basic node body (shape, border, background) at the offset — an extra draw, but simple — never a whole-cloth redraw of the full node (labels and other decorations excluded).
    • Overlay/underlay as style props (10 props + active-bg-* + selection-box-* core props) — port the props; the baked-in affordances (shader hover/active brighten, accent ring, DOM selection box) become the styled defaults.
  • Deferred into the multiline/label-bb round (the listed lean, now decided): text-metrics, box-select-labels — their v4 form is designed there; neither ported as-is nor dropped now.

Suggested sequencing (unchanged by the sweep, now grounded in it)

The sweep confirms the two headline pillars — curved edges and compounds — dwarf everything else in app impact, with background images the sleeper third (16 props, near-universal in production apps). Of the near-term autonomous work, slot-stable compaction landed as round 11 and edge-label autorotate landed 2026-07-29 — the autonomous shelf is clear.

The design queue, in suggested order: curved edges (12a — bundled bezier + self-loops — landed 2026-07-30 and 12b — unbundled/segments/taxi — 2026-07-30/31; 12c endpoints + haystack/straight-triangle remains; since complete) → compounds (landed as round 14, 2026-07-31) → background images + the node-visual scope call (ghost's simplified body-duplicate form slots in here) → the event vocabulary + extension contract calls (cheap to build once decided, and they unblock the ecosystem) → force layout. Overlay/underlay theming props ride with the interaction/visual batches.

The proposed-drops list was triaged 2026-07-29 (see the section above): four entries dropped into the decided-design ledger, three kept with direction, and text-metrics/box-select-labels folded into the label-bb round.

2026-08-01 design sitting: with rounds 12–14 landed, the remainder of the queue was scoped in one sitting (plans at the end of this file): z-index dropped outright (decided design, no round at all) → background images (round 15) → multiline labels + label bb (round 16) → event vocabulary + extension contract (round 17) → GPU force layout (round 18). All four rounds landed in full the same day — the queue is clear.

Since then: round 19 (slot-moving compaction) closed the last architecture item, round 20 closed gap item 8 (interaction options + touch parity), and the third design sitting (2026-08-01) scoped and landed rounds 21–23 (animation queue removal, the display/visibility split, node charts) — see the plans and records below.

What remains of the needs-a-call list: the animation controls/transitions follow-up (item 9's open half — scoped as round 24 by the fourth design sitting and landed in full the same day, 2026-08-01; the geometry-tween round it logged landed as round 25, 2026-08-02), the small parity remnants noted inline in items 4–6 (closed by round 27, 2026-08-02 — v3's node-shape and arrowhead vocabularies are complete and numeric text-rotation landed; the one remainder is border-style/outline-style, held for the scope call item 4 itself asks for).

Items 8's deferred overlap box mode, 10's core/collection extension points and 12's odds and endsall closed at the fifth sitting and after: overlap box mode landed as 39.1, extension points stay out by decision, and 12 split into cy.gc() (39.3), graph data on the wire (39.2) and cytoscape.warnings(), which is round 40's. (Dated for clarity: this paragraph is 2026-08-02's reading, superseded by "As of 2026-08-04, what remains" below.)

2026-08-02, rounds 26–27: round 26 built the authoring surface — JSDoc across the whole prototype (46% → 100%, gated) and the first shipped TypeScript declarations for cytoscape — and round 27 closed the visual-parity tail. What is left of the whole ledger: border-style/outline-style (a scope call, 27.8), item 8's overlap box mode, item 10's core/collection extension points, and item 12's odds and ends.

2026-08-03, rounds 28–29. Round 28 took the part of that remainder needing no design call: CPU-pick coverage for round 27's shapes (28.1 — a verification gap, not an API one), the panBy animation target (28.2), and item 12's own drift (28.3, above).

What remains of the ledger is entirely open calls — decisions, not implementations, and all of them (plus the contradictions rounds 28–29 found) are collected in "Open calls for the maintainer" near the top of this file: border-style/outline-style (27.8's scope call), the legacy-alias policy (one call over roundrectangle, autolockNodes and autoungrabifyNodes — all three survived the 2026-07-29 triage that says they were dropped), item 8's overlap box mode, item 10's core/collection extension points, and item 12's surviving three (cy.gc(), cytoscape.warnings(), graph data in the binary wire format). Nothing in the ledger is now blocked on effort.

Round 29 then worked a different axis entirely — not the ledger of what is unbuilt but a survey of what is unpinned — and found the alias surface untested (83 methods whose type declarations and runtime wiring are separate things), four public methods no spec mentioned, the decided-design drops enforced only by intention in three places (a string event qualifier crashed inside the emitter on the next event; a style function group was silently ignored; the collection methods crashed on other._refs or answered false), and curved edges unpriced on the CPU. It also closed 27.9 by measuring on the RX 580: round 27's shader branches cost nothing per frame. (This paragraph ended with a sentence calling that measurement "open and blocked on neither — just unrun" after 29.5 had run it — written during the round and left standing by 29.6's own sweep. Removed in 30.5, and noted here because it is the third round running that this summary has been the thing that drifted.)

2026-08-03, rounds 30–32. Continuing round 29's axis onto the part of the surface v4 states most and tests least — what it throws. Measured with source-mapped coverage: 34 of the 191 throw sites in src had never executed. 30.1 closed every Node-reachable one (20 specs, one of whose controls came back BAD and forced a sharper spec), 30.2 pinned the six export guards in the browser project, 30.3 took the untested public surface the survey turned up beside them (cy.stop(), renderedTargetEndpoint, two clustering metrics), and 30.4 shipped the measurement as scripts/throw-coverage.mjs — reporting only, since a coverage floor is a call, now logged as open call 8.

Reading at the close: 176 run, 13 browser-only, 2 unreachable by design, 0 Node-reachable and never run. Round 31 then asked what those throws say: it found the per-element bypass error advising the style function form — removed in round 8, throwing since 29.3 — fixed the message and its doc comment, took @throws on public throwing members from 7/16 to 16/16 under a gate, and covered mouseout/pointercancel, the last two names of the round-17 event vocabulary no test mentioned. Its lesson is about sweeps rather than about errors: the markdown had been right all along, and the wrong text was in a runtime string and a JSDoc block.

Round 32 finished the contract sentence's remaining clause — @param on every public member that takes arguments (143 → 221 of 221, gated) — with the boundary drawn by docmaker's own shape: arguments carry a description the generator emits, returns do not, so the @returns tail (63 of 276) is measured and logged rather than built.

2026-08-03, round 33. The benchmark sweep, on the user's scope call: benchmark everything possible, core through renderer. Fourteen suites became twenty-two; the surfaces that had no measurement at all — layouts, four algorithms, the style engine's apply and readback paths, loading and the wire format, picking/box-selection/bounds, the data sidecar and structured queries, events and the animation lifecycle, store internals, images and charts — now have one, plus a 117-row breadth pass over the rest of the public API and a third audit script reporting which members a benchmark calls. Open call 7 closed with it.

The round's most useful output is not the wins (they were mostly where earlier rounds said) but the five places v4 is slower than v3 or slower than its own design implies — the style getters at 13–21× (5.8× on re-measurement through the bundle; see round 34.0), the compound emit path never taking the no-listener fast path, the layout contract's per-run whole-graph materialization, mutableElements() and indexOf() — each measured, localized and logged rather than fixed, because a measurement round measures.

Six rows across the round were caught measuring nothing by design call 5 and rewritten, and one of those (curves.mjs's box-selection premium) had been published in the README since round 29.4.

2026-08-04, round 36. The completion round: after 35, what remained in this file was the twelve open calls plus a short tail that needed no decision at all, and this round is that tail and only that. @returns went from round 32's measured-and-deferred 63-of-276 to 276/276, written but deliberately not gated — round 32's boundary (docmaker emits a description per argument and has no return field) is where the gate stays. Writing it turned up that the @param gate had never walked the public tier's exported functions, so wire.mts and columnar.mts sat outside an audit reporting 221/221; now 229/229.

The browser-only throw tier, opened by round 30 and half-closed by 30.2's six export guards, is finished — four specs and three honest reclassifications, since three of the seven are guards no input reaches (one of them shadowed by a synchronous check twenty-five lines above it). Two public collection members no benchmark had ever called got rows, chosen so they walk rather than short-circuit.

And three measurements this file had promised and never taken were taken: --layout on the RX 580, the wall time of each report profile (quick 7.1 min, --all 17.4), and a re-runnable source for rounds 34–35's bundle figures, which reproduced round 35's numbers and refined one — the post-table spread is two populations, and the upper one is colour formatting rather than dispatch. The round's own finding is a stranded-doc-block check.

The defect — a later insertion landing between a doc block and its member — has happened eleven times here, and the coverage gate catches it only when the displacement leaves some member bare; when it lands on another documented member, coverage stays 100% and two members carry each other's prose. The check found six more on its first run, and one of them was shipping in dist/cytoscape.d.ts. It reports rather than gates, because the third shape of the defect — a block displaced onto a different documented member — is not statically detectable at all, and because it cannot tell a deliberately free-standing note from a displaced block.

2026-08-03, round 35. The maintainer read round 34's residual — "the style getters are still 2.3× and the cause is a 145-case switch" — and asked the obvious question: why is a 145-case switch there at all, and why not a direct lookup? Both halves were right. The count is not accidental (one entry per readable property, median two lines each), but the shape cost something real: V8 does not hash a string switch that large, so a property's read cost depended on its position in the file — which is also why rounds 33 and 34 understated the getters, having measured background-color, the fourth case.

Round 35 turned the switch into a Map of 111 readers behind a 153-property characterization spec, flattening the spread from 5.1× to 2.3× and making a whole-object style() 1.27–1.48× faster. The lesson worth keeping is not about switches: the round happened because someone asked why a number was shaped that way, after the measurement rounds had accepted it.

2026-08-03, round 34. The fix round for what 33 measured. All five paths are fixed — indexOf and mutableElements() at parity with v3, the emit path's new no-listener gate at 8 ns, the layout contract 420× cheaper per run, the style getters 5.8× → 2.3× — with no behaviour change and no pixels moved (168/168 browser specs, goldens byte-stable).

Two of the five findings were corrected while being fixed: the style gap was inflated by tsx's __name wrapper (the benchmark suites import src/, and for a closure-heavy hot path that measures the transpiler), and the row round 33 cited for the emit finding never reached the emit path at all — it was measuring compound auto-bounds invalidation. The transferable rule is now in AGENTS.md: check a hot-path finding against the built bundle before rewriting anything, since the planned readProp fix turned out to be a no-op in production and the real cost was normalizeProp doing a regex replace per read.

What is left of the five is a residual 2.3× on the style getters, which is no longer a hot spot with an obvious cause — it is the 145-case switch and the guard lookups that precede it. Not logged as an open call: it needs no decision, only appetite.

2026-08-04, the fifth design sitting — the production-readiness roadmap. With round 36 done, everything left in this file was open calls, and the sitting took all of them (the per-item records are in "Open calls for the maintainer" above).

What follows from the answers is rounds 37–51, planned at the end of this file: the governance close-out (37 — the two new gates, the alias split, the strictness closures), the full border-style/outline-style port (38), the decided feature tail — overlap box mode, wire graph-data, cy.gc() — (39), the error-policy sitting + cytoscape.warnings() (40 — the one question the sitting deliberately left open), the v4 Event + emitter (41), the v3/ restructure that makes v4 the package's default export (42), packaging/publish hardening (44), the JSDoc→docmaker generator (45), the v4 docs site (46), the migration guide + CHANGELOG (47), robustness/soak (48), cross-platform validation (49), release engineering + 4.0.0-alpha.1 (50), and the release bake to 4.0.0 (51).

(The numbers from 44 on are one higher than this sitting wrote them: round 43 was inserted later for the debug harness.) This sitting's edit touches PLAN.md only, at the maintainer's instruction; the README true-up (header, follow-up hooks) lands with round 37's docs-first commit — noted so the standing docs-travel rule's exception is on the record rather than silent drift. "Gaps with direction already set" was checked by name and needed nothing (its entries all closed by earlier rounds).

2026-08-04, round 41 — the v4 Event and emitter. Taken out of order (38 is waiting on the sub-calls above, 40 is a sitting), and worth it: it is round 42's precondition and it closed the last of the round-26.5 logged items. v4 now owns its event object and its emitter, event.target is typed, originalEvent is populated, and the namespace parsing v4 had been inheriting while its design disclaimed it is gone. Two of the round's own premises were wrong, both stated as facts about code nobody had re-measured — see the round record.

The emitter was not v4's only outward import (five utility modules remain, now audited by a spec instead of asserted by a sentence), and preventDefault()'s gesture half could not be enumerated "from v3-source reading" because v3 never reads the flag either. The DOM half landed; the enumeration is open call 12. That is three rounds in a row — 37.3, 37.4, 41 — that tripped on a stale claim in this file, which is now a standing note in AGENTS.md. The emitter swap itself is behaviour-neutral by measurement rather than by intent: the whole Node suite passed unchanged except the one spec round 37.4 had written to pin the behaviour this round removes.

2026-08-04, round 39 — the decided feature tail. Three independent small builds, all decided at the fifth sitting and none needing a new call: overlap box selection (39.1), graph-level data on the binary wire (39.2), and cy.gc() (39.3). Round 38 is deliberately not what followed 37: scoping it found three sub-calls the sitting had not reached (v3's double erases a stripe rather than drawing a second band; dashed borders need border-dash-pattern/-offset, which v4 has for edges and not for nodes; border-cap/-join have no v4 counterpart), which are logged in open-call 1 as that round's docs-first agenda rather than guessed at inside it.

What 39 is worth remembering for is its verification, not its code. Every overlap spec passed on the first run with the exact flattened walk deliberately removed — the conservative bb reject was answering every one of them — so three of them were measuring nothing until two "band inside the bb that the path does not reach" specs were added. The benchmark had the same disease one layer along: its curved row used curve-style: bezier, which bundles multi-edges only, so a fixture with no parallel pairs priced straight edges under a curved label.

Both are AGENTS.md's standing rule arriving in a round that had already read it, which is the argument for running the control rather than trusting the reading. And the round-37.1 gate fired twice, correctly: edits to graph-store.mts and to wire.mts's header comment moved two UNREACHABLE entries out from under their file:line keys, and the build failed naming them. That is exactly the failure 37.1 was built for, arriving in the very next round.

2026-08-04, round 37 — the governance close-out. The first of the sitting's rounds, and deliberately the smallest, because its gates protect every round after it. Throw coverage and @returns now gate (37.1), with the classification allowlists checked rather than merely written — a zero-tolerance gate is only as good as its escape hatch, and this one is keyed by file:line. The 2026-07-29 alias triage is finally applied as decided (37.2): roundrectangle drops from all three enums that took it, autolockNodes/autoungrabifyNodes stay as recorded exceptions, and both documents' ledger lines say so.

Constructor strictness closed at the type layer (37.3) and event-name openness documented (37.4).

Two of the five items corrected this file rather than executing it, which is the round's real character. 37.3 set out to write a compile-only test and found that src/index.mts — the package entry point, listed in PUBLIC_API since round 26 — contributed zero members to every audit, because the exported-function pattern did not spell export default function; all three of its tags were missing behind a green gate. That is round 32's blind spot and round 36's widening arriving a third time, and it says something about audits that "an audit's scope is part of its claim" has now had to be learned once per round that touches one. 37.4 set out to document the event contract and found the namespace record wrong: v4 was still importing v3's emitter, so namespaces worked in full v3 semantics — what was true is only that v4 never emits a qualified name. Both corrections land in the documents and in round 41's plan, which had described removing dead machinery — and round 41.2 has since removed the live machinery it actually was.

Round 42 (2026-08-04) executed the sitting's packaging decision and changed no behaviour: v4's source promoted from the old src/gpu/ to src/, the whole v3 file set moved into a self-contained, still-buildable v3/, and the root package.json became cytoscape@4.0.0-unstable with v4 as exports["."].

Both calls the plan left to docs-first were taken — the gpu-/webgpu- prefixes drop, and the five shared utility modules duplicate rather than stay shared, so nothing under src/ imports outside it — plus a third the plan did not anticipate: the v4 identity rename (bundles, declaration, UMD global and the default export are all cytoscape), which 42.6 then carried into the exported type names, so the whole public surface is unprefixed. Behaviour-neutrality was established by comparing blobs rather than by the suite alone: see the round record.

Round 43 (2026-08-04) is the odd one out, inserted at the maintainer's request between the restructure and the release sequence (which renumbered 44–51): the debug harness, v4's only manual page and the first thing anyone sees. It was broken — four fixtures 404'd after round 42 moved the v3 tree, silently, because a fetch had no .catch and nothing tested debug/ at all — and it was flattering nobody: its style sanitizer kept a 14-property whitelist and dropped every mapper, so v4's whole style surface was being discarded before it reached the core.

Now hand-authored production sheets per fixture (the real enrichmentmap.org style among them), two real compound graphs, the v3 page's control sections, and a module spec that compiles every sheet against its own fixture. It also fixed the active-bg indicator, and gave debug/ its first test.

And it needed a review pass one day later (2026-08-05, recorded as 43.10–43.13), which is the round's real lesson rather than a footnote to it: its own risk note said "the module spec proves the sheets compile, not that they still look right, so a property that changes meaning rather than disappearing will not be caught by anything but opening the page" — and the maintainer opening the page found three things, none reachable from any suite.

LiveReload had never connected on either project (it binds localhost, which resolves to ::1, while http-server -o opens 127.0.0.1); the harness's own event log turned one box selection into 22,406 forced layouts, 5,659 ms inside a 6,055 ms handler, because it read scrollHeight after every appended row; and the compound fixture was not the verbatim port 43.4 claimed — the node order had been sorted and v3's cols: 3 dropped, which between them made the parent boxes overlap.

Chasing the third turned up a library defect the page was merely displaying: the conservative fit() scan added a chord term to compound-loop edges that belongs to weight-extrapolated routes, so a compound graph fitted at a third of its size. Three specs and their controls landed with the fixes — and one of those controls came back BAD first, because grid takes its column count from the container's aspect ratio and a headless instance is not the page's shape.

2026-08-04/05, rounds 44, 45, 47 and 48 — the release sequence's decision-free part, taken in that order and skipping 46 (which needed 45 first) and 49–51 (which need hardware, or are a publish). What they have in common is worth naming, because it is the same shape four times: each round's most useful output was a thing its own plan was wrong about. Round 44 was written as three decisions and two of them were never open — v3's tracked dist/ already answers what ships at release, and src/ already ships as v3's does — so what the round actually delivered was the missing check that the manifest, the build and the tarball agree.

Round 45's plan cited a test:types:docs precedent that is v3's, and runs the other way round; building the generator then turned up event.mts outside the audit's public tier (the fourth instance of "an audit's scope is part of its claim"), optional class members invisible to every audit's member pattern, and a layout-extension contract that shipped no types at all. Round 47's property table would have been wrong in at least four entries had it been written from the ledger instead of measured against both libraries. And round 48 found four defects, three of them in a format nine rounds had called finished.

The transferable part is round 33's rule arriving in a fifth costume: a plan's statements about the code are claims to re-measure. Every one of these rounds spent its first hour measuring what it had been told, and every one of them found the telling wrong somewhere.

As of 2026-08-05, what remains. (Superseded by the 2026-08-06 paragraph below.) Unbuilt: round 38 (blocked on the three sub-calls in open call 1), round 40 (a design sitting), round 41.5 (open call 12), round 46 (the docs site — no longer blocked, since round 45 built its input), rounds 49–51 (cross-platform validation, release engineering, the release bake), and two tails: round 48's documented limit edges (the 256-layer image cap, a full glyph atlas, the export texture cap) and round 44's one release-time act — the first release build must actually commit the five dist/ bundles, which is round 50's.

Undecided: the error policy (round 40) and the preventable-gesture enumeration (open call 12) — still the only two genuinely open questions, both of which the ledger holds rather than any round record. Rounds 44, 45, 47 and 48 opened no new ones, but two of them changed public surface and both are logged in "Open calls for the maintainer" as items 14 and 15: 45 exported the layout contract's types, and 48 made twelve collection methods throw where they had been answering wrongly across instances. Neither needed a decision to make — one is a missing export, the other a wrong answer — but both are visible to a consumer, so neither is left to be discovered in a diff.

The 2026-08-05 review pass of round 43 adds a third (item 16): a no-argument cy.fit() frames a compound graph with related edges tighter, because the conservative scan was adding a chord term that belongs to a different curve kind. It also leaves one thing logged and unbuilt — a bounds round for the conservative fit box, which grows a disc around each endpoint centre by a global node-half maximum where several kinds' geometry is directional and per-edge; the compound fixture still fit at ~1.8× its exact box after 43.13. (That round became round 54 and landed 2026-08-08: fit zoom 0.607 → 0.822.)

As of 2026-08-06, what remains (supersedes the paragraph above, after rounds 46.6, 52's scoping, 53–53.2 and the sixth design sitting).

Unbuilt: round 38 (unblocked — its three sub-calls are taken)landed 2026-08-08, full coverage with five parity scenes and their controls — round 40 (the error-policy sitting, taxonomy-first prep approved), 41.5 (direction set — explicit toggles first; the enumeration lands at its docs-first), round 46 (the docs site), round 52 (the WGSL comment-strip — decided to build, and it must land before round 50 cuts the alpha)landed 2026-08-08, 19.0 KiB gzipped off the download with pixel-identical output — round 54 (the bounds round, newly scheduled before round 49)landed 2026-08-08, fit zoom 0.607 → 0.822 on the compound fixture with the sweep promoted to a standing gate — rounds 49–51 (cross-platform, release engineering, the bake), round 48's documented limit edges (landed 2026-08-08 as 48.6 — three browser specs, five controls), and round 44's release-time act.

Undecided: the error policy (round 40) and the preventDefault enumeration (item 12) — still the two genuinely open questions — plus two conditional ledger entries, miniray (item 17, measure-first, expected never taken) and the tween warm-up (item 18, revisit with data). Items 14–16 are ratified and closed; the executePlan() coverage gap and CI's removed job timeout stay recorded decisions rather than open ones.

2026-08-07, round 57 — the cleanup round. Six items the maintainer raised, all landed. Three are the repository looking after itself: oxfmt replaces this repo's own call-spacing (57.2), the two long documents stop opening with a wall of text and say plainly that v4 is not close to a release (57.3, 57.4), and the status build's path checker gets a maintained allowlist so its warnings mean something again (57.6). Two are for looking at pixels: four networks ported from v3's documentation demos (57.5), and v3's default look — selection on nodes, edges and arrowheads, and :active through the round-13 A2 overlay props (57.1).

What is worth carrying forward is, again, what measurement said rather than what the round built.

A formatter is a free control on every tool that reads the sources as text. Reformatting the tree falsified all their layout assumptions at once and found four defects: five public members whose parameters wrapped were skipped by the @param gate (232/232 was reading a surface of 239, with five untagged behind it), memberBody stopped at a wrapped parameter line and again at a multi-line return type so @throws detection fell silently, an export const f = that broke after the = vanished from all four audits, and the throw gate had a false pass — a guard inside a module-level arrow const read as covered through the misattribution the script documents as its own blind spot, while no spec had ever fired it.

None of that was caused by the formatter; all of it was hidden by the previous layout.

The item that looked like a colour change was a design question. "The default stylesheet should look like v3's" measured to almost nothing on the property surface — #999 on both sides for nodes and edges since round 1, and 68 of 72 differing (group, property) pairs are spelling — because the two things that differed are not properties at all.

A selected edge in v4 was indistinguishable from an unselected one, and FLAG_ACTIVE had existed since round 6 with nothing reading the bit. Building both turned up the divergence the round could most easily have shipped silently: v3's selection colour lives in the default stylesheet, so any user block naming a colour beats it, while v4's — as first built — was drawn by the shader and always won.

That divergence turned out to be the question rather than a footnote. The reasoning for accepting it was "there is no :selected to write", and v4 has no selectors but it does have conditions — so the rule went into v4's own default stylesheet as { when: { selected: true } }, spread before the user's block, which is v3's precedence rather than an approximation of it. Round 57.1d has the whole of it, including what came out of the shaders and why the default sheet costs nothing.

And a spec written for a new fixture found a defect on its first run, which is the argument for writing the property rather than the smoke test: the debug demos' arrow-fill never resolved to hollow, because a clause helper built its keys with Object.keys and turned the boolean true into the string 'true'. Twelve identical filled heads would have looked entirely plausible.

Amended 2026-08-07 (round 56, then closed by round 57). Round 57 (cleanup) joined the unbuilt list here and has since landed in fulloxfmt, the two documents' readability and their readiness language, four debug/ networks from v3's demos, the status build's path allowlist, and the default look moved onto v3's: state is a case condition, and v3's :selected, :parent:selected and :active blocks are entries in v4's default stylesheet. So the unbuilt list above stands as written.

The same amendment added two questions to the undecided one: the arrow-scale quantization (item 23 — closing it spends the six reserved packing bits, which a 17th arrow shape also wants) and where the arrow trim cannot reach (item 24 — edge labels and the layer strokes; a binding rather than a decision).

And a caution that applies to this paragraph in particular: it is an inventory of what has been written down, not a claim that finishing it finishes v4. The maintainer can name several rounds that are not logged yet, and rounds 43, 46.5, 55 and 56 were each inserted after the sequence they interrupt was already planned. Round 57 item 4 exists because these summaries had drifted into reading like a complete plan.

Amended 2026-08-07 (during round 57). Two of those unlogged rounds are now logged, as ledger items 25 (bring the bypass UX back, spelled as a case mapper) and 26 (split the big implementation files, the way src/algorithms/ already is). Neither is scheduled and neither is a question for the maintainer — they are directions with their measure-this-first noted, which is the form that makes an idea pickable up later. They are also the concrete demonstration of the sentence above: the list grew by two in the same week it was called incomplete.

Amended 2026-08-08 — three rounds landed in one pass. Everything in the unbuilt list that was fully specced and runnable on this machine closed in a day: round 52 (WGSL minification — 19.0 KiB gzipped off the download, pixel-identical by the exact goldens), round 54 (the bounds round — fit zoom 0.607 → 0.822 on the compound fixture, and its new randomized sweep caught a pre-existing taxi soundness hole on its first run), and round 38 (border-style/outline-style at full coverage — the last unported style pair, with the plan's own ellipse approximation replaced by exact arc length after its deviation scene failed to discriminate). Round 40's approved taxonomy-first prep also ran: 198 throw sites classified, ~11 demotion candidates in two families, the measured recommendation in the round-40 section.

What the queue now holds: round 40 (blocked on its design sitting, prep done), 41.5 (its docs-first proposal was written later the same day — the section at the end of this file — and awaits the maintainer's reaction), round 46 (the docs site — unblocked but large and only sketch-specced), rounds 49–51 (other platforms, release credentials, the bake), round 48's documented limit edges (landed later the same day as 48.6 — the three limit fixtures, each specced at its exact edge, with five controls), and round 44's release-time act. Nothing else fully specced remains buildable without the maintainer or different hardware — and with 48.6 in, round 48 is complete.

Amended 2026-08-09 — the seventh sitting cleared the maintainer's half of the queue by declining it. Round 40 closed with no new surface (errors and warnings stay as built; no cytoscape.warnings() — the decision record is in the round-40 section), and round 41.5 does not run (toggles are the whole gesture-control story; preventDefault() is browser-level only — the record is in the 41.5 section). Neither produces a Landed section, because neither lands code; each section carries its decision record instead. The same sitting scheduled ledger item 24 as round 58 — the arrow trim reaching edge labels and the layer strokes, decision-free renderer work with negligible measured risk — and round 58 landed the same day (0.000% on both new close-up scenes, controls failing at 7.7x and 27.9x their bounds; the record is beside the plan). Its scene drafts added ledger item 27 (v4's edge underlay/overlay band is width + 2 × padding wide where v3's is 2 × padding — logged, not patched). What the queue holds: round 46 (the docs site — unblocked, large, sketch-specced), rounds 49–51 (other platforms, release credentials, the bake), and round 44's release-time act. The genuinely open ledger questions: item 23 (the arrow-scale reserve, deferred by the maintainer), item 18 (the tween warm-up, revisit with data), and now item 27 (the layer band width).

Amended later on 2026-08-09: round 59 (the force layout rebuild, raised by the maintainer) landed the same day — the summary paragraph above and the round-59 plan and records at the end of this file carry it. The queue and the open questions are otherwise as the previous paragraph left them.

Amended again on 2026-08-09: round 60 (the performance record, kept honest — raised by the maintainer) landed the same day. The status site gains cross-commit benchmark comparison pages — per-row p50 across the published runs of one (machine, profile), movers beyond ±10% with the frozen-v3 twin as a per-row noise control, and a whole-run drift figure (verified against the archive's own two renderer runs: drift ×1.014 where round 52.2 independently measured 1.018) — plus benchmark rows for the unpriced recent rounds (the 57.1d state-condition partition and what a select now restyles, the round-59 spectral-vs-scatter seed split, the 57.9 hit halo) and the tests the tooling was owed (executePlan(), the 53.2-recorded gap, and the benchmark index's judgements). It adds ledger item 28 (cy.collection( anything ) silently ignores its argument where v3 builds from it — found by one of the round's own benchmark controls) to the open questions; the queue is otherwise unchanged. 60.4 then ran both profiles fresh and the comparison found a real regression on its first use: under the default stylesheet — which conditions on selected since 57.1d — every select/unselect restyles, so bulk select went 47.9 µs → 6.3 ms at 2k with the v3 control flat: v4 flipped from 38× faster than v3 to 3.3× slower on that row. The measured headroom is logged in the 60.4 record (route a state flip through the 57.1d partition records instead of refreshMapped); the renderer tier read clean at −0.7% drift over 290 shared rows.

Amended once more on 2026-08-09: round 61 closed that regression the same day — the state flip routes through the partition-record diff (541.9 → 63.5 µs on the bundle-measured 256-band select; the mutators row back to ~8.5× faster than v3), with geometry/label/transition cases falling back to the full write. The queue and the open ledger questions (18, 23, 27, 28) are unchanged by it.

Amended 2026-08-14: round 71 (cyext, the extension toolchain — raised by the maintainer) joins the queue with a full plan at the end of this file. It is independent of the reserved rounds — it touches none of 46/49/50's subjects, and it deliberately stays out of round 50's release-engineering territory (its release command is minimal, provenance parked for 50) — and it feeds round 51: the example extension it ships is the "external layout through the round-17 contract" consumer that round 51's bake re-runs against the published tarballs. The queue is otherwise as the previous paragraphs left it.

Amended later on 2026-08-14: the tenth sitting swept the idea backlog into a fifteen-item shortlist (the sitting's record is at the end of this file) and scheduled two things from it: round 72 (the algorithm perf follow-ups logged by 65.8/69.6/70.4, gathered into one round — full plan at the end of this file), and a scoping round for the WebGL fallback renderer (ledger item 18b) to run before the fallback's pre/post-4.0.0 positioning is decided — the maintainer explicitly declined to make that call without data on what WebGL2 can carry of the v4 contract. The reserved rounds 46/49/50/51 and the open ledger questions (18, 23, 27) are unchanged.

Amended the same day, again: every shortlisted item now carries a full plan — rounds 72 through 86, all at the end of this file. 72 the algorithm perf follow-ups; 73 the WebGL2 fallback scoping round (the feasibility record, not code); 74 the worker-pool CPU executor (ledger 29); 75 the DX polish bundle; 76 the small style wins bundle; 77 SVG vector export; 78 headless Node image generation; 79 official JSON schemas; 80 node charts (heat, bars, the slice ceiling); 81 the annotations layer; 82 cluster hulls + collapse/aggregation proxies; 83 GPU edge bundling; 84 attribute-table and filter affordances; 85 the layouts round; 86 the worker-hosted renderer. The numbers are identifiers, not an execution order: 72 runs first (decided at the sitting), 73 early because its record gates the release-shape decision, 86 explicitly depends on 73's outcome, and the rest interleave small bundles between the large arcs at the maintainer's discretion. Planning verified every plan's premises against the source, and two shortlist items dissolved on contact: gradients are already landed (round 13 C2 shipped the whole surface, golden and v3 parity scene included — round 76 closes the demand log honestly instead of building), and container auto-resize mostly exists (an unrecorded ResizeObserver since round 42 — round 75 records, routes and tests it rather than building it). Three plans recommend splitting at a named seam before execution (76's screen-space sizing, 82's hulls/collapse halves, 85's force constraints); each split is the round's first Open item. Every plan ends with an Open: paragraph of maintainer decisions to take at that round's own sitting — none are decided by the plans themselves.

Slot-stable compaction

The buildable tier from the compaction analysis (next section): the append-only structures that leak under churn now meter their waste and reclaim it automatically on a threshold, extending the policy the insertion-order list has always used (compactOrder at > half stale). No element slot moves — refs, draw order and the GPU mirrors are untouched — which is what the analysis identified as making auto-trigger safe for this tier; the slot-moving tier's policy calls (ref survival, trigger, draw order) stay open below. Each piece lands as an isolated commit with Node tests.

  • En route fix: adjacency's overlayCount counted +1 per addEdge but decremented per overlay-list entry (an edge holds two: out[source] + inn[target]), so it could hit zero with entries still live and let addBulk build a "fresh" CSR under a non-empty overlay, drawing bulk edges ahead of earlier incremental ones in per-node incident order. It now counts entries; regression test pins the ordering.

  • Id blob (store/id-map.mts): remove() meters the removed id's stranded UTF-8 bytes; when they exceed half the blob (≥ 4 KiB floor), the live ranges compact into a fresh right-sized blob, so peak-then-small graphs also shrink back toward the floor. The probe table stores (group, slot) codes, never byte offsets, so it — and the per-slot hashes and decoded-name cache — survive compaction untouched; probe-table tombstones already self-reclaimed via the rehash in ensure(). Cost is O(live bytes), amortized over the removals that stranded the waste. A 20k add/remove churn loop that used to strand ~200 KB now holds the blob ≤ 8 KiB.

  • CSR adjacency (store/adjacency.mts): removals strand CSR entries (fixed per-node segments can't refill) and post-build adds accumulate in the per-node overlay arrays — both metered now (csrStranded/overlayEntries). When their sum exceeds half the live entry count (64-entry floor), GraphStore rebuilds CSR from the live edges in insertion order — the same two counting passes as the bulk build — folding the overlay back into the compact typed- array shape and dropping the stranded space.

    Insertion order is what the incremental paths produce anyway, so per-node incident order is preserved across a rebuild (the one exception: an edge re-pointed by moveEdge sits at its re-add position until a rebuild returns it to insertion order). A side effect closes a gap from round 5: a purely incremental graph (never bulk-loaded) used to keep all its edges in JS overlay arrays forever; it now folds into CSR once past the floor, at geometric intervals (amortized O(1) per add).

  • String dictionaries (store/data-store.mts): dicts only grew, so entries whose last reference was overwritten or cleared leaked under churn. String columns now keep a per-entry refcount (one extra indices read per write); when dead entries exceed half the dict (8-entry floor) the dict compacts — live entries keep their relative order, the indices column remaps in place (bound CPU evaluators hold the array and col by reference), and a per-column epoch bumps. Values never change, only the private index space, so no mapper output moves (ordinal domains are explicit — there is no dict-order-derived domain).

    GPU interplay: onDictRemapGraphStore.markDataWrite over the whole column (watched keys re-upload their remapped index shadow), and the mapper runtime packs dictEpochs beside dictSizes — the span handler reconfigures on either mismatch, since a same-frame shrink-then-regrow can return the dict to its packed length with a different index mapping (spec-pinned: the epoch test fails on the length check alone). The ingest path also compacts adopted wire dicts that arrive with unreferenced entries.

Verification: typecheck, lint, test:js (1638 → 1645) and test:modules (58) green per commit. Write-path cost checked against the pre-round baseline (benchmark/mutators.mjs at N=2k, same machine, same run): remove+re-add 5.45 vs 5.32 ms/iter (noise), data set at parity — after re-splitting the DataStore write path so the numeric case stays inlinable (the first cut regressed numeric bulk writes ~16% by growing write() past the inline budget; caught by the baseline comparison, pinned back to 50.5 vs 50.7 µs).

Churn measurement (sliding-window store scenario: 20k nodes / ~21k edges stable, 1k-node bands removed and re-added with fresh ids and per-element strings): after 40 rounds the id blob holds 699 KB vs 1.84 MB pre-round, the string dictionary 21.2k entries vs 60k, and adjacency lives in typed-array CSR (38k live entries, 41k capacity, 4k overlay) vs 42k permanent JS-array entries; at 80 rounds the pre-round numbers keep growing linearly (3.03 MB blob / 100k dict) while round 11 stays flat (492 KB / 23.1k) — churn profile 2's unbounded-in-time leak is closed.

The webgpu Playwright projects could not be validated on this Linux machine: the SwiftShader adapter acquires (vendor google/swiftshader) but renders blank — identical failures on the pre-round baseline commit, so a pre-existing environment limitation, not this round; the mapper-runtime epoch/repack behaviour is pinned by the Node mock-device suite instead, and the webgpu projects should be re-run on a machine with a working adapter before release. (Resolved 2026-07-29 — the blank rendering was a Linux canvas-presentation issue in headless Chromium, fixed with ANGLE-on-Vulkan compositing flags; all 51 specs now pass on this machine. See the next entry.)

The Linux WebGPU test environment fix

Root-caused and fixed the "adapter acquires but renders blank" failure that kept the webgpu/visual Playwright projects from validating on Linux (round 11's open verification debt). Probing the Playwright-launched Chromium (1.61.1, channel: 'chromium' new headless) with the failure split into stages showed:

  • Dawn rendering was never broken. With the repo's flags, the adapter acquires and an offscreen render → copyTextureToBuffer → map readback produces correct pixels on both the SwiftShader adapter and the hardware one (RX 580, RADV, Mesa 25.3.6 — Vulkan 1.4 is healthy on this box).
  • Canvas presentation was the failure. Under the default Linux GL compositor, ctx.configure()/getCurrentTexture() on a WebGPU canvas killed the instance ("A valid external Instance reference no longer exists"); under --use-angle=vulkan alone the canvas configured but composited transparent. Composited (screenshot) pixels — what the specs assert — stayed blank either way, which is exactly the round-11 symptom.
  • The fix: --use-gl=angle --use-angle=vulkan --enable-features=Vulkan routes Chromium's compositor through ANGLE-on-Vulkan, and the shared-image canvas path presents correctly for both the hardware and the SwiftShader-pinned WebGPU adapter. Added to the webgpu and visual projects in playwright.config.js, gated on process.platform === 'linux'--use-angle=vulkan does not exist on macOS (Metal), so the known-good macOS configuration is untouched.
  • Determinism and CI are unaffected. The SwiftShader pin still applies to the WebGPU adapter (only compositing uses the AMD device), and the goldens generated on macOS pass here unchanged — confirming cross-platform golden stability. Simulating a no-Vulkan-driver machine (a CI runner) yields a null adapter → the specs soft-skip exactly as before, so CI behaviour is unchanged.
  • One quirk noted, no action needed: drawImage() from a live WebGPU canvas into a 2D canvas still reads transparent under these flags — no spec uses that path (they decode page.screenshot() or use cy.png() readback, both working).

Verification: 39/39 webgpu + 12/12 visual specs green on this machine (all 10 golden diffs within tolerance against the checked-in macOS-generated PNGs, both v3-parity diffs within their 2% bound) — round 11's "re-run on a machine with a working adapter" caveat is cleared, and this Linux machine can run the visual projects going forward.

Compaction, analysed

Discussed 2026-07-27 while planning round 10 and deliberately left out of that sprint: the analysis below is settled. The slot-stable tier landed in round 11 (above) with auto thresholds — the "plausibly auto regardless" lean below, taken. The slot-moving policy calls were decided with the user 2026-08-01 and the tier landed as round 19 (the plan and Landed sections at the end of this file); the analysis below is kept as the record that motivated it.

When compaction is motivated — three distinct profiles:

  1. Shrink (big removals without re-add — e.g. a filter UI cuts 200k elements to 20k). Dead slots pile up and highWater never falls: every compute dispatch (cull count/scan/scatter, mapper eval) still runs over highWater lanes; every CPU columnar scan (scanRefsInto, boundingBox, refsInBox, CPU pick) still iterates highWater slots — cost proportional to the peak graph, not the current one. CPU columns and GPU mirrors stay at peak capacity, and one-coalesced-span dirty tracking uploads dead bytes when writes straddle dead regions.
  2. Churn (sustained remove+add at stable size — streaming / sliding-window dashboards, expand/collapse exploration). The free-list recycles slots, so the tables don't grow — but three append-only structures leak unboundedly in time: the id blob (removed ids' UTF-8 bytes + probe entries never reclaimed; new ids append fresh bytes), the CSR adjacency (removed edges strand CSR space; incremental adds accumulate in the per-node overlay), and string-dictionary data columns (dictionaries only grow). This is the most motivated real-world case — and it is invisible to a dead-slot-ratio meter, since slots recycle.
  3. Peak-then-small memory reclaim (transient huge load, then narrow): capacity stays at peak until slots compact and columns realloc down.

Not motivated: add-only or stable graphs (zero waste), and moderate removal on big graphs (cull already keeps draw cost O(visible); dead slots only cost pass-iteration width and memory).

The tier split — the tiers differ by trigger meter, not just difficulty. Blob/CSR/dictionary compaction is slot-stable: no identity moves, no renderer or ref implications, metered by plain waste counters — it could safely run automatically. (That is exactly how it landed in round 11: waste-over-half thresholds with small floors, no new API.)

Slot compaction moves live elements, is metered by dead-slot ratio, and carries all the policy weight: outstanding refs (plain {group, slot, gen} objects in user-held collections, plus packed-int membership-set caches — they cannot be found and rewritten eagerly), z-order (slot order is draw order), GPU full re-upload (the existing resized path), and remap of in-flight animation slot lists.

Open policy questions — these apply to the slot-moving tier only (round 11 took the slot-stable lean of (b)); options discussed — all three decided with the user 2026-08-01, see the round-19 plan at the end of this file: (a) ref survival across a slot move — a forwarding table with lazy ref repair + an epoch stamp invalidating cached membership sets (taken), vs handles-survive-collections-stale, vs everything-stale; (b) trigger — explicit cy.compact() vs auto thresholds (both taken: auto threshold + the explicit call); (c) draw order after compacting — stable (visually a no-op) (taken) vs restore-insertion-order (heals the recycled-slot z-order wart at the cost of a visible change and a per-slot sequence number).

Settled adjacent question: removed-element readability is orthogonal to compaction — v4 already gave it up when it chose tombstones + a free-list (the next add may recycle the slot), and the round-10 design call above makes that permanent. Compaction changes nothing for removed refs under any option: a removed ref matches no forwarding entry and its generation is already stale, and the cached id()/group() live on the JS handle, not in the columns.

Curved edges

The head of the design queue: v4 renders curve-style: straight only, and the curve families are the single biggest visual gap — bundled bezier is v3's default look, and a self-loop currently degenerates to a point in v4 (a standing correctness wart, not just a missing style). The design tier was decided 2026-07-24 (the expensive-GPU- geometry model): dual CPU/WGSL implementations that agree by construction — never one side reading back the other — with a conservative CPU over-approximation for cull/fit, exact lazy CPU eval memoized per element for public .bb(), and bundle membership as a cheap CPU structural index rebuilt on edge add/remove/move, not per frame.

The 2026-07-29 triage added haystack (+ haystack-radius) and straight-triangle to this surface as real visual styles. This section slices the work into passes and records the implementation calls so the passes can run under the round-10 process rules (isolated commits, docs in-commit, full verify per item, escalation on any real API call discovered mid-implementation).

Implementation calls (made at planning):

  • Geometry ports v3's math verbatim. Control points, loop construction, segment/taxi routing and endpoint math come from the same formulas v3 uses (v3/src/extensions/renderer/base/coord-ele-math/ edge-control-points.mts — the step-size stagger for bundles, the loop-direction/sweep construction, the distance/weight frame for unbundled beziers and segments, the taxi turn logic), so curves are pixel-comparable in the live v3-parity harness and existing figures reproduce. No silent simplifications: any spot where v3's math can't ride the GPU path becomes its own logged call.

  • Curves evaluate in the vertex stage from live endpoint positions plus per-edge curve parameters. Rendered curves are instanced segment strips — K quads per edge instead of 1 — whose VS computes the curve point analytically from the two endpoint positions (the same buffer straight edges already fetch) and a small per-edge parameter record (curve kind, bundle offset, loop angles, segment/ taxi params). The parameters are position-independent (offsets and weights in the endpoint-relative frame), so drags, layouts and position tweens follow on-GPU with zero rebuild, exactly like straight edges, arrows and edge labels today.

    Variable-length params (segment lists) live in a param blob + per-edge offset column — the one storage-layout addition. K is fixed per curve family (bezier/loops subdivide; segments/taxi are exact polylines, K = their true segment count), with a far-zoom LOD reduction as a tune knob resolved by goldens + the renderer benchmark during 12a — not an API surface.

  • One flattening, every consumer. The CPU twin evaluates the same closed forms: exact lazy .bb() (memoized, invalidated by the same dirty channels that invalidate the render), CPU pick against the flattened polyline at the same subdivision the shader draws (so pick agrees with pixels by construction; the GPU pick tile draws the same segment strips for the edge stages), and cull/fit read the conservative bound — endpoint hull expanded by the maximum control offset (bundle stagger, loop extent, segment/taxi excursion) — per the decided tier.

    Arrows sit on the endpoint node's boundary along the curve's end tangent; edge labels anchor at the curve midpoint (t = 0.5) computed in the VS, so autorotate's angle generalizes from the endpoint delta to the midpoint tangent.

  • Box selection keeps endpoint-center containment in 12a (the existing straight-edge approximation, already a recorded deviation); upgrading refsInBox is revisited with 12b when the CPU evaluator covers every family. (Since done: 12b's revisit took v3's exact rule — curved edges test their curve boundary endpoints, which is what v3's 'contain' actually checks, rather than the flattened polyline; straight edges keep centers.)

Pass split (each pass lands as isolated commits with Node geometry tests pinned against v3's math, a golden scene per family, a live v3-parity scene under the standard tolerance bound, and a follows-drag/tween Playwright spec pinning the zero-rebuild property):

  • 12a — bundled bezier + self-loops (landed 2026-07-30 — see the round 12a record; the default v3 look, and the loop fix): curve-style prop (straight | bezier), control-point-step-size, control-point-weight, loop-direction, loop-sweep; the parallel-edge bundle membership index (keyed on the unordered endpoint pair, incremental on add/remove/move()); the curve-params column + VS quadratic-bezier eval for render and pick tile; arrows on end tangents; edge labels at the curve midpoint; conservative bound into cull/fit/boundingBox; exact lazy .bb(); isBundledBezier/controlPoints/ renderedControlPoints accessors.
  • 12b — unbundled-bezier + segments + taxi (+ round variants) (landed 2026-07-30/31 — see the round 12b record): control-point-distances/-weights, edge-distances, segment-distances/-weights/-radii, radius-type, round-segments corner arcs, taxi-direction/taxi-turn/ taxi-turn-min-distance, round-taxi radius; the param-blob storage for variable-length lists; segmentPoints.
  • 12c — endpoints + the triage keeps: source/target-endpoint (keyword/percent/coordinate forms), source/target-distance-from-node; haystack (+ haystack-radius) as hash-stable intra-node endpoint offsets (the decimation trick's determinism, applied to endpoints); straight-triangle. Mid-arrows (mid-source/mid-target, arrow-scale, arrow-fill: hollow, compound arrow shapes) stay in the arrow-parity needs-a-call batch — not pulled in here.

Perf: the renderer benchmark gains a curved variant of the pan scenes (expected cost is ~K× edge vertex work, bounded by cull and decimation; record the numbers in the round record).

Open calls — both signed off 2026-07-30 (as the leans):

  1. v4's default curve-style is straight — the perf-first default at v4's target scales; parity scenes and apps opt into bezier explicitly. A deliberate divergence from v3's bundled-bezier default, recorded in src/README.md.
  2. bezier bundles multi-edges only, verbatim v3: a lone edge between two nodes stays a straight line under curve-style: bezier; only parallel edges fan out (the odd-bundle middle edge is straight too, v3's rule). Pixel-comparable in the live v3-parity harness.

Edge-label autorotate

The last item on the autonomous shelf, cleared while planning round 12: text-rotation: autorotate for edge labels, one isolated commit.

  • API: text-rotation is an edge style prop — keywords none (default, horizontal) | autorotate, constants or mappers (enum kind, so case conditionals work, matching the other label channels). Numeric rotations throw (per-element numeric text-rotation stays in the label-parity needs-a-call batch), and the prop throws on the nodes group (node labels don't rotate in v4). Readback follows the stored-truth rule: the sidecar entry when labelled, else the sheet.

  • The flip-rule call (the one that was open): v3's verbatim — the label angle is the edge's undirected slope, v3's atan(dy/dx) (labels.mts:95), so the baseline stays within (−90°, 90°] and text never reads upside-down; vertical edges read top-to-bottom at +90° either direction. The WGSL implements the same rule with no trig: it sign-normalizes the endpoint delta (negated when it points left, or straight up at dx = 0) and uses the unit vector as the rotation frame (autorotateFrame).

  • Mechanism: rotation happens in the vertex shader from the live endpoint positions, so autorotate inherits the edge-label zero-rebuild property — drags, layouts and position tweens re-angle the label on-GPU (spec-pinned: making a vertical edge horizontal re-uploads ≤ 64 B, one position row). The model bakes only a flag: bit 31 of the glyph instance's owner word (element slots stay far below 2³¹; the dead sentinel is the full-ones word, so no collision). The background quad carries the flag too — a text box rotates with its text — and the edge-glyph cull kernel tests the exact rotated-rect AABB in the same rotation frame as the VS, so cull and draw can't disagree.

    Node glyph paths are untouched, and the non-rotated edge path keeps its original arithmetic — pre-existing goldens pass unchanged.

  • Verification: typecheck + lint clean; 1650 Node tests (5 new in test/edge-labels.mjs: entry + readback, defaults + sheet-resolution, throws for numbers/unknown keywords/nodes-group, case mappers, node-entries-never-rotate); 40/40 webgpu Playwright specs (new: a vertical-edge spec pinning the dark-pixel bounding box flipping from wide to tall under autorotate, plus the ≤ 64 B re-angle on an endpoint move); 13/13 visual (new edge-label-autorotate golden: a downhill run, a direction-flipped uphill run with its background box rotated along, and a vertical top-to-bottom run — all pre-existing goldens unchanged).

Bundled bezier and self-loops

Ran under the round-10 process rules (isolated commits, docs in-commit, full verify per item, escalation on real API calls). Items landed in CPU-first order; each entry below was written in the commit that landed it. (Since superseded: pass 12b — unbundled/segments/taxi — landed 2026-07-30/31, see its round record; pass 12c — endpoints + haystack/straight-triangle — remains in the round-12 plan above.)

  • Curve geometry module + contract columns. src/curve-geometry.mts is the CPU half of the dual-impl discipline for curves: v3's math ported verbatim (bundle stagger (0.5 − n/2 + i)·step, loop rays loopDir − π/2 ∓ sweep/2 at radius 1.4·step·(j/3 + 1), the edge-distances: intersection frame with the impossible-bezier clamp, endpoints on the node boundary toward the near control point, the loop's two C1-continuous quadratics through the control midpoint), with node boundaries at the arrow shader's approximation tier (ellipse/rect exact, round-rect as box, polygon as inscribed ellipse — recorded deviation).

    Also: curvePointAt/flattenCurve (the drawn subdivision, CURVE_SEGS =

    1. and the conservative curveDeviation hull bound for cull/fit. Contract: edge.curveParams column (f32×4; kind packed at [3] so the curve shaders fit the vertex stage's 8-storage-buffer budget)
    • CURVE_* kinds + the store-managed FLAG_CURVED bit the cull kernels will split the edge streams on. 17 Node specs pin the port against hand-derived v3 values (incl. the antiparallel-edge world-invariance of the stagger sign and the C1 loop join).
  • Curve style props + bundle index + param derivation. Five edge props (curve-style straight|bezier, control-point-step-size, control-point-weight, loop-direction, loop-sweep — v3 defaults; angles take numbers-as-radians or deg/rad strings, constants and mappers alike, stored-truth readback off the styled record, nodes group throws).

    store/curve-index.mts owns the styled records and derives edge.curveParams: a lazily-built parallel-edge pair map (straight-only graphs pay nothing but a loop check per edge add), always-maintained per-node loop lists, and pending-pair lazy flush (takeDelta / boundingBox / accessor reads) so a bulk load or style apply derives each pair once. v3 rules pinned: 2-bundle ±step/2 stagger, odd-middle straight, lone-bezier straight, per-edge step, antiparallel sign flip, loop j-stagger per (direction, sweep), and re-derivation on add/remove/move()/restyle/mapper-refresh.

    store.boundingBox() grows its edge term by the conservative hull deviation, and store.curveSlack() gives the frame-level bound the cull kernels will use (monotone maxima — never shrinks, costs only cull efficiency). 24 Node specs (test/curve-index.mjs).

  • Curve-aware accessors + the exact lazy edge bb. isBundledBezier() (style check, v3 semantics — true for the lone edge that renders straight), controlPoints() (one point for a bundled bezier, two for a loop, undefined for straight — v3's surface) + renderedControlPoints(); midpoint() returns the curve midpoint (v3's rs.mid) and source/targetEndpoint() return the curve's boundary endpoints for curved edges (straight edges keep the node-center approximation).

    eles.boundingBox() reads the exact lazy tier: store.curveBBAt() flattens the curve at the drawn subdivision and memoizes per slot against a geometry epoch (any geometry write invalidates all cached boxes at once — sound, cheap, and consistent with the position-tween lease). boundingBoxAt (animated-layout fit targets) expands curved edges by the conservative hull deviation. 16 Node specs (test/curve-accessors.mjs).

  • Renderer: the curved-edge pipeline, cull stream and pick. CURVED_EDGE_SHADER + CurvedEdgePipeline: one instance per curved edge drawn as a strip of CURVE_SEGS quads whose VS evaluates the curve (the WGSL twin of curve-geometry.mts — same intersection frame, boundary approximations, clamps) from live positions + the params column; vertices extrude along the curve normal at their own t, so adjacent quads share exact edge geometry and the strip is watertight without miters. The vertex stage binds exactly 7 columns

    • the visible list (the base 8-storage-buffer budget); paint columns (line color/opacity/line-style) moved to the fragment stage via flat instance fetch, and dashes ride a per-vertex polyline arc-length varying. Cull: a new curvedEdge kind splits the edge draw on FLAG_CURVED (the straight predicate rejects the bit) — same five inputs, chord test grown by frame.curveSlack (the Frame uniform's spare pad slot), no decimation on the curved stream; CullInfo gained indexCount so one scan kernel serves both 6-index quads and 6×CURVE_SEGS strips.

      The pick pass draws the same strips (edges-only tile, pickCull.curved), so pick coverage equals pixels by construction; image export gained the curved group too. One init-order bug found by the specs: the mirror's construction-time full upload ran before the lazy curve flush whose usual flush point (takeDelta) is discarded at init — flush now runs first.

    Verified: 3 new webgpu specs (fan-off-the-chord with pixels at the CPU-computed renderedMidpoint — the dual-impl guarantee made a test; ≤64 B re-shape on drag; pick on the bulge vs chord; loops render as loops), 2 new goldens (bezier-bundles, self-loops), and a live v3-parity curve scene measuring 0 differing pixels (8px strokes so pixelmatch's AA skip can't mask placement error, plus an ink guard) — 59/59 Playwright, 1707 Node, 59 module tests, typecheck + lint green; pre-existing goldens byte-identical.

  • Arrows on curve end tangents. The insight that made this a small change: a quadratic's end tangent points from the control to the endpoint, so the curved arrow is the straight arrow math with the control point substituted for the far endpoint (source end uses c1, target end c2 — coincident for a bundled bezier). CURVED_ARROW_SHADER/CurvedArrowPipeline ride the curved cull stream's new single-quad args block (the scan kernel now writes a second [6, n, 0, 0, 0] at byte 20 of the indirect buffer, so strip streams can also drive one-quad-per-instance draws).

    Budget cut, recorded: no node-border column fits in the 8-buffer vertex stage, so curved-edge arrow tips sit on the size/2 boundary and the frame uses border-exclusive halves — exact for the default border 0, ≤ border/2 off otherwise (revisit with 12c endpoints). New curved-arrows golden (bundle fan converging on the target, an antiparallel pair, a loop arrow riding the in-ray tangent); 60/60 Playwright, 1707 Node, 60 module tests green.

  • Edge labels at the curve midpoint + autorotate tangent. The edge label VS binds the curve inputs (7 storage buffers + the visible list — exactly the vertex-stage budget) and anchors curved owners at the curve midpoint computed from live positions, so curved-edge labels keep the zero-rebuild property. Autorotate generalizes for free on beziers — a quadratic's t = 0.5 tangent is the chord direction, so the existing endpoint frame is already exact — and loops rotate along their c1→c2 midpoint tangent.

    The edge-glyph cull (at its own 8-buffer budget, no params binding) grows its chord-midpoint test by the frame's curve slack for FLAG_CURVED owners; rotated curved labels take a frame-independent anchor-centred bound (a loop's rotation frame differs from the chord's). New webgpu spec (glyphs at the CPU-computed renderedMidpoint, none on the chord, ≤ 64 B re-anchor on drag) + curved-edge-labels golden (bundle labels per-curve, an autorotated boxed label tilted with the chord, a loop label on the loop tangent); 62/62 Playwright, 1707 Node, 60 module tests green.

  • Renderer benchmark: the curved pan scene. A new gen-25k-curved scene generates its 50k edges as parallel pairs (a lone bezier renders straight, so a random-edge scene would measure nothing) with curve-style: bezier opted into on both sides; the runner also gained the platform-gated Linux ANGLE-on-Vulkan flags from playwright.config.js — without them it silently fell back to SwiftShader (and the software rasterizer then lost the device under the curved load).

    Same-machine A/B on this box (AMD RX 580 / RADV, dpr 2, 1280×800, scale pinned 1), GPU device-time p50, straight gen-25k vs gen-25k-curved: continuous-pan fit-all 3.3 → 8.6 ms (~2.6× for 24 quads/edge over every edge — well under a 60 fps frame; wall clock stays vsync-bound at 16.7 ms on both scenes), zoomed-in 20× 4.4 → 3.8 ms (culling keeps the curved stream cheap), far-zoom 1.2 → 6.4 ms — the documented no-decimation trade-off on the curved stream showing up exactly where expected (revisit with 12c's haystack). v3 canvas ~650 ms/frame fit-all either way (bezier barely moves its cost); init 3.0 s v3 vs 169 ms gpu; hover-while-panning pick p50 ~18 ms on this box.

    Round 12a is complete: props, derivation, accessors, exact bb, render, cull, pick, arrows, labels, goldens, parity and benchmarks all landed.

Unbundled bezier, segments and taxi

Pass 12b of the round-12 plan above, under the round-10 process rules. Items landed CPU-first; each entry below was written in the commit that landed it. Round 12b is complete: props, blob storage, per-edge derivation, accessors, exact bb, render, cull, pick, arrows, labels, box selection, goldens, live v3 parity and benchmarks all landed — final tallies in the goldens/parity entry at the end.

  • node.outerHalf derived column — the 12b binding budget. The curved-edge/curved-arrow/edge-label vertex stages all sat at WebGPU's base 8-storage-buffer budget after 12a, leaving no slot for the variable-length curve param blob 12b needs (segment/control lists can't fit the fixed f32×4 params column). The fix is a derived column: node.outerHalf = size/2 + borderWidth/2 per axis (v3's outerWidth/outerHeight frame), written through by the store on every node size/border write, never by the style engine.

    The four boundary-consuming shaders (curved edge, straight + curved arrows, edge labels) bind it in place of the size + border pair — one binding freed in each — and GraphStore.curveEvalAt reads the same column, so the CPU twin and the WGSL consume identical f32 half-extents by construction.

    Two side effects, both improvements: the 12a border-exclusive curved-arrow deviation is gone (tips sit on the border-inclusive outer boundary, like straight arrows — the curved-arrows golden uses border 0, so goldens are unchanged), and border writes now invalidate the pick-tile cache through the derived column's span (node.borderWidth itself is pick-neutral, but borders move curved pick geometry — a latent 12a gap). Node specs cover the write-through and its dirty span.

  • CPU route geometry (curve-geometry.mts): the CPU half of the dual-impl discipline for the three 12b families. evalRoute computes the interior route points — unbundled-bezier controls and segment points from v3's weighted-frame + perpendicular-offset formulas ('intersection' and 'node-position' frames, keeping v3's quirk that the normal always comes from the intersection frame), and the full verbatim taxi routing (auto/explicit directions, percent/px turns incl. negative = from-target, min-distance clamps with the Z- and L-shape fallbacks, node-body offsets, the forced-direction growth case) — plus boundary endpoints toward the first/last route point.

    computeCorner is v3's getRoundCorner as a pure function (spec-pinned directly against v3/src/round.mts output across windings, arc- vs influence-radius, limit clamps and collinear corners).

    The drawn strip stays one indirect draw of CURVE_SEGS quads for every family: quadPiece maps subdivision indices onto route pieces (multibezier: one C1 quadratic per control through inserted midpoints; polylines: legs, with corner arcs interleaved when round) such that piece boundaries land exactly on subdivision indices — legs stay pixel-straight and corners exact regardless of quad distribution. That requires pieces ≤ CURVE_SEGS, so interior counts are capped (MAX_MULTI_CTRL = 8 controls, MAX_CURVE_PTS = 11 segment points — a recorded deviation from v3's unbounded lists; derivation clamps with a warning).

    routeMidpoint ports v3's label-anchor/autorotate rules per family (even/odd counts, the round arc-apex case with its arc tangent). Contract: CURVE_MULTI/ CURVE_SEGMENTS/CURVE_TAXI kinds + FLAG_CURVED_BOX (taxi routes — and weight-extrapolated routes — are not chord-bounded, so kernels without a params binding will cull them against the endpoint AABB grown by slack + chord length). 33 Node specs (test/curve-routes.mjs).

  • The curve param blob (store/curve-blob.mts). Blob-backed kinds store their variable-length records in one f32 pool the renderer mirrors as a storage buffer; the params column holds the header [blobOffset, dev, n, kind] — no column-layout change, and records are position-independent, so drags/layouts/tweens still cost zero blob traffic. Record layouts (multi: mode + d/w pairs; segments: mode + round + d/w/r/arc quads; taxi: 8 fixed floats) are documented in the module.

    Storage behaviour follows the round-11 slot-stable policy: append allocation with per-slot ranges, same-length rewrites in place, freed ranges metered, and automatic compaction past waste > half live (256-float floor) — a compaction rewrites records in slot order and reports moves so the store rewrites the header offsets as normal column spans (geometry unchanged, so the bb memo epoch is untouched).

    StoreDelta gains an optional curveBlob span/resized entry and ModelView exposes curveBlob()/curveBlobLength(); GraphStore.setCurveParamsBlob writes record + header + FLAG_CURVED/FLAG_CURVED_BOX, feeds the monotone dev/box maxima behind curveSlack(), and fixed-kind writes release any blob record the slot held. 10 Node specs (test/curve-blob.mjs).

  • Style props + per-edge derivation. curve-style gains the five 12b keywords; the full prop surface (control-point-distances/ -weights, segment-distances/-weights/-radii, radius-type, edge-distances, taxi-direction/taxi-turn/ taxi-turn-min-distance/taxi-radius) parses with v3 defaults, list props accepting arrays or space-separated strings, and stored-truth readback (lists as space-separated strings, percent turns as percent strings). Scalars/enums are mapper-capable; list props are constants-only (recorded scope note). edge-distances: 'endpoints' throws until 12c.

    The CurveIndex derives blob records per edge (the 12b families never bundle): v3's min(dists, weights) count rule, last-radius/type repetition, the weight clamp to [-1, 2] with out-of-[0, 1] weights marking FLAG_CURVED_BOX, taxi always box-bounded, and the interior-count caps. Pair interplay pinned: blob-family members never join nor get clobbered by bezier bundle re-derivations, and a blob edge restyled to straight resets through the per-slot pending path (the pair map is bezier-lazy and may not exist).

    Loops: unbundled families take control-point-distances[0] as the loop distance (v3), step-size fallback when unset; segments/taxi loops keep the 12a all-loops-render-as-loops deviation. Conservative-bb call sites (store scan + boundingBoxAt) use the header deviation, with box-bounded edges adding the node-half margin (+ chord length for extrapolated weights). 26 Node specs (test/curve-derivation.mjs); one 12a spec updated (the keyword-throw now pins haystack).

  • Route accessors + the exact lazy bb. GraphStore.curveRouteAt is the route twin of curveEvalAt (which now correctly returns null for blob kinds instead of misreading their headers as bezier params): blob record + live positions/outerHalf/shapes → the evaluated CurveRoute.

    On top of it: segmentPoints()/renderedSegmentPoints() (v3's getSegmentPoints — defined for segments and taxi, whose derived routing points read back; undefined otherwise), controlPoints() extended to the unbundled-bezier control list (segments/taxi stay undefined, v3's split), midpoint() via the per-family routeMidpoint rules, source/targetEndpoint() as the route's boundary endpoints, and curveBBAt flattening routes at the drawn subdivision into the same epoch-memoized exact-bb cache. 12 Node specs (test/curve-route-accessors.mjs) pin hand-derived geometry incl. the taxi bb and memo invalidation on moves.

  • Renderer: the route WGSL twin, blob mirror and box cull. ROUTE_WGSL mirrors the CPU route evaluator step for step — the frame, the full taxi routing, computeCornerW (getRoundCorner), the piece allocator and routeVertexW/routeMidpointW — reading the same blob the CPU reads, mirrored by ColumnMirror as one storage buffer under the usual span/realloc rules (delta.curveBlob; a realloc bumps mirror.version, so bind groups rebuild). The curved edge VS binds the blob as its 7th vertex buffer (back at exactly the 8-buffer budget) and branches per kind: bezier/loop keep the 12a analytic path byte-for-byte (goldens stable), route kinds evaluate routeVertexW at their subdivision index with discrete miter normals from the neighbouring indices — exact miters at sharp polyline corners (v3's canvas join, extrusion scaled 1/cos(θ/2), clamped at 6), chord-normals elsewhere, canonical per index so the strip stays watertight; extruding along the miter keeps the perpendicular half-width exact, so the FS's AA is unchanged. Dashes keep the chord-sum arc length over the drawn polyline. The curved cull kernel branches on FLAG_CURVED_BOX to the endpoint-AABB test grown by slack + chord length (taxi and extrapolated weights are not chord-bounded); the edge-glyph cull grows its anchor test the same way for box owners. The pick tile draws the same strips, so pick coverage equals pixels for every family — spec-pinned. 4 new webgpu Playwright specs: segments polyline + ≤64 B re-route on drag, taxi axis-aligned legs + leg-vs-diagonal picking, round-segments corner-cutting vs the sharp corner (and the arc-apex midpoint), and the unbundled-bezier S through its inserted midpoint with a clear mirrored band. All 66 Playwright specs green; 12a goldens byte-stable through the shader restructure.

  • Arrows + edge labels on routes. The curved-arrow insight generalizes: a route's end tangent runs from the first/last interior route point to the boundary endpoint, so the arrow is the straight arrow math with that point substituted (taxi arrows ride the final axis-aligned leg). Budget: the curved-arrow vertex stage needed the blob, so this end's arrow colors moved to the fragment stage — the VS no longer collapses no-arrow ends to degenerate quads (they rasterize a small fully-transparent quad instead; the frame uniform now binds V|F for edgeDim).

    Edge labels of route edges anchor at routeMidpointW in the VS, and autorotate takes the midpoint tangent as its frame (v3's per-family disp rules) — both zero rebuild, both spec-pinned: taxi arrows purple on the final leg (and no ink on the chord diagonal), segments labels at the route midpoint with a ≤64 B re-anchor on drag. 68/68 Playwright specs; the 12a curved-arrows golden is byte-stable through the fragment-stage color move.

  • Box selection: the curve-endpoint upgrade (the revisit deferred from 12a). refsInBox now tests a curved edge's curve boundary endpoints — exactly v3's on-boundary 'contain' rule, via the full-family CPU evaluator (curveEvalAt / curveRouteAt); straight edges keep the endpoint-center approximation (recorded deviation). 2 new Node specs (segments and taxi containment, incl. the cut-the-launch-point miss cases).

  • Goldens, live v3 parity and the benchmark check. Three new golden scenes — unbundled-bezier (S-splines across orientations, a dashed run, the unbundled loop), segments-families (sharp miter vs radius-18 round corners on the same zig-zag lists, a vertical round run, dashes riding legs) and taxi-families (auto/explicit directions, px and percent turns, round-taxi corners, arrows on the final legs, the forced-direction growth case) — byte-stable across repeat runs.

    One combined live v3-parity scene covering all five families measured 0 differing pixels at 8 px strokes (the same ink-guarded pixelmatch bound as 12a's parity-curves): the route geometry lands identically on both renderers; the known miter-vs-round join difference is absorbed by AA classification. Renderer benchmark re-run on the same box (RX 580, dpr 2, scale 1): the 12a curved scene's device times are unchanged (fit-all pan 8.61 vs 8.6 ms, zoomed-in 3.81 vs 3.8, far-zoom 6.18 vs 6.4) — the route branch and blob binding cost the bezier path nothing measurable; wall clock stays vsync-bound at 16.7 ms while v3 canvas runs ~670 ms/frame on the same scene.

    Final tallies: 1793 Node + 60 module tests, 72/72 Playwright specs (6 new webgpu, 3 new goldens + 1 new parity in visual), typecheck + lint clean.

Endpoints, haystack and straight-triangle

Pass 12c of the round-12 plan above, under the round-10 process rules. Items landed CPU-first; each entry below was written in the commit that landed it. Round 12c is complete: props, derivation, accessors, exact bb, render, cull, pick, arrows, labels, box selection, goldens, live v3 parity and benchmarks all landed — the round-12 curved-edges plan (12a/12b/12c) is done.

  • Contract + CPU geometry: endpoint blocks, haystack, triangle (2026-07-30).

    Three additions to the curve contract: CURVE_HAYSTACK and CURVE_TRIANGLE are straight-stream kinds (FLAG_CURVED stays clear — haystack rides the straight pipeline and its far-zoom decimation, resolving 12a's "curved stream is never decimated" revisit by construction), and CURVE_HAS_ENDPT flags a blob-backed kind (MULTI/SEGMENTS/TAXI) whose record is prefixed by a fixed 10-float endpoint block — [mode, a, b, pctBits, dist] × 2 — resolving source/target-endpoint and source/target-distance-from-node. Modes are v3's edgeEndpoint forms (outside-to-node default, inside-to-node, outside-to-line, point with per-component %/px units, angle with the 12-o'clock start folded in at parse time); distances shorten via v3's shortenIntersection clamp rule. Structural calls, recorded in the geometry module doc: a straight edge with manual endpoints derives as CURVE_MULTI n = 0 (the route degenerates to the chord between the resolved endpoints — routeVertex/routeMidpoint already handle it), and a bundled bezier with manual endpoints promotes to CURVE_MULTI n = 1 (its control formula is identical — pinned by a spec against the 12a analytic path). edge-distances: 'endpoints' re-bases the frame on the raw manual anchors with v3's recalcVectorNormInverse normal. Haystack endpoints are center + (cos/sin(angle) · outerHalf · radius) with hash-stable angles from the edge's id hash (deterministic across sessions and machines — v3 uses Math.random(), so haystack scenes are only statistically v3-comparable; v4 also scales by outer halves where v3 uses inner size — identical at border 0, recorded). 17 Node specs (test/curve-endpoints.mjs) pin the block resolution, the n = 0 chord, the bezier-promotion equivalence, the endpoints-frame rebase, taxi distances, and the haystack point/angle math.

  • Style props + derivation (2026-07-30). curve-style gains haystack | straight-triangle; new edge props haystack-radius (validated [0, 1], v3 default 0), source/target-endpoint (keyword | 'x y' point with per-component %/px units | angle as deg/rad string or plain radians; the -or-label keywords throw — no label bb in v4), and source/target-distance-from-node (non-negative). edge-distances: 'endpoints' parses; derivation enforces v3's both-ends-manual rule and falls back to intersection with v3's warning otherwise.

    Scalars (haystack-radius, the two distances) are mapper-capable; the endpoint props are constants-only (the point form is a list — the 12b scope rule).

    Derivation (CurveIndex): haystack derives per edge into the straight-stream params (id-hash angles via the store's blob-native id hashes, so two loads of the same graph derive identical haystacks); triangle likewise; any edge with a non-default endpoint spec derives its blob record with the 10-float block prefix and the kind flag — straight → MULTI n = 0, bundled bezier → promoted MULTI n = 1 (derivePair consults the spec; the odd-middle/lone rules produce endpoint chords), taxi → modes forced default (v3's keyword override) with distances kept, dropping the flag when nothing remains.

    Cull soundness: px point offsets fold into the record's header deviation; pct offsets are measured in node-half units — ≤ 1 is covered by the slack's node-half term, > 1 marks the edge FLAG_CURVED_BOX and feeds a new monotone endptPctMax term in curveSlack(); haystackSlack() (radiusMax × node half) is the bound the straight-stream cull tests will grow by in the renderer item. Haystack styling also suppresses arrows at the style layer (v3 draws none; stored-truth arrow getters read 'none' — recorded), and refsInBox tests haystack offset points (v3's haystackPts).

    Readback: curve-style/haystack-radius off the styled record; endpoints as canonical strings (keywords, 'x y' with % suffixes, '<rad>rad' angles); distances as numbers. 21 Node specs (test/curve-12c-derivation.mjs); two 12b-era specs updated to the new surface (haystack/edge-distances no longer throw). 1831 Node tests, typecheck + lint green.

  • Accessors + exact bb (2026-07-30). Haystack edges answer sourceEndpoint()/targetEndpoint() with their offset points (v3's haystackPts), midpoint() with the offset-point average (v3's rs.mid), and boundingBox() with the exact offset-point span; endpoint-flagged route kinds flow through curveRouteAt automatically, so manual-endpoint edges answer every accessor — resolved endpoints, chord midpoints, the promoted bundled bezier's control point, distance shortens on taxi — off the shared route evaluator, and the exact lazy bb covers manual endpoints outside the chord with the usual epoch-memoized invalidation.

    controlPoints() returns undefined for the straight-with-endpoints chord (MULTI n = 0 — no controls, matching v3's straight surface). 11 Node specs (test/curve-12c-accessors.mjs); 1842 Node tests, typecheck + lint green.

  • Renderer: straight-stream kinds, endpoint WGSL twins, cull slack (2026-07-31). The straight edge shader restructured: paint columns (line color / opacity / line-style) moved to the fragment stage via flat instance fetch (the curved pipeline's split), freeing vertex slots for edge.curveParams + node.outerHalf + node.shape — 6 VS storage buffers + the visible list.

    The VS branches on the straight-stream kinds: haystack offsets both endpoints by (cos/sin(angle) · outerHalf · radius) from live positions (drags follow on-GPU), and straight-triangle computes boundary endpoints and tapers the half-width to zero at the apex (the FS's varying half-width keeps the AA exact; dashes skip triangle fills, v3's fill path; the pick FS inherits the taper, so picking matches the drawn triangle).

    ROUTE_WGSL gained the endpoint-block twins (rawEndptAnchorW/resolveEndptW, the kind-flag strip, the n = 0 chord aims, and the edge-distances: endpoints frame rebase) — the label VS's route branch and the curved pick tile inherit them; route arrows now anchor at the route's resolved endpoint (q[0]/q[n+1] — for default modes exactly the old boundary point, for manual endpoints v3's arrowStart/End), aiming along the end tangent (the far endpoint for the n = 0 chord). The edge-label VS anchors haystack owners at the offset midpoint with autorotate along the offset line. The Frame uniform grew 48 → 64 bytes with haystackSlack (radiusMax × node half, monotone): the straight-edge cull and the edge-glyph cull grow their corridor/anchor tests by it, so haystack never culls wrong while staying decimated like any straight edge. 4 new webgpu Playwright specs (haystack offset line + pick, triangle taper + taper-matched picking, manual endpoints off the chord + ≤ 64 B drag re-anchor, arrows at a shortened endpoint with the gap behind them) — 54/54 webgpu, 22/22 visual (goldens byte-stable through the shader restructure, parity scenes 0 px), 1842 Node tests, typecheck + lint green.

  • Goldens, live v3 parity and the benchmark check (2026-07-31). Three new golden scenes — haystack (8 edges at radius 0.9; the id-hash angles make the scene deterministic across machines, which is what lets a haystack golden exist at all), straight-triangle (three orientations + an arrowed apex) and manual-endpoints (a px point source end, an angle target end, a source distance and an unbundled bezier under edge-distances: endpoints) — stable across repeat runs.

    Three new live v3-parity scenes, all measuring 0 differing pixels at 8 px strokes: parity-endpoints (the same endpoint config across orientations — v3's shorten matches v4's dist rule exactly at arrow gap 0), parity-triangle, and parity-haystack0 — haystack at radius 0 pins the haystack pipeline against v3 exactly (both sides collapse to center-to-center lines); radius > 0 has no exact v3 parity by construction (v3 seeds with Math.random()), which the deterministic golden covers instead — the recorded deviation.

    Renderer benchmark re-run (same box, RX 580, dpr 2, scale 1): device p50s unchanged from the 12b record — straight gen-25k fit-all/zoomed/far 3.34/4.40/1.26 ms (was 3.3/4.4/1.2), curved 8.61/3.81/6.30 ms (was 8.6/3.8/6.4) — the paint-to-FS restructure cost nothing measurable, and far-zoom haystack rides the straight stream's decimation by construction (the 12a revisit closed). Final tallies: 1842 Node + 60 module tests, 54/54 webgpu + 28/28 visual Playwright specs (3 new goldens, 3 new parity scenes), typecheck + lint clean. Round 12c is complete — and with it the whole round-12 curved-edges plan.

Style-prop parity

A prop-level sweep of the v3 style registry (v3/src/style/properties.mts: 280 registered props + 11 aliases) against the v4 engine, asking one question per prop: is it implementable entirely under existing design decisions — a new channel column plus parse/mapper/stored-truth-readback plumbing plus fragment-stage shader work, the pattern rounds 10 B2/B3/B4 (line styles, label visuals, arrow shapes) established — with no new subsystem and no open API-semantics call? Roughly 55 props qualify; they are this round.

The paint props are cheap for a structural reason: colors/opacities fetch in the fragment stage (the flat-instance-fetch precedent), so they never touch the 8-storage-buffer vertex budgets that constrain geometry work. This round refills the autonomous shelf; the design queue (compounds → background images → event vocabulary/extension contract → force layout) is not consumed by it.

  • C3 custom polygons (2026-07-31). shape: 'polygon' + shape-polygon-points land on the round-11 blob pattern: a second CurveBlob pool holds each node's flat unit pairs, slot-stable compaction rewrites the packed offset|count<<24 ref that rides the borderGeom radius word (meaningless for polygons), and the column mirror ships the pool as one more growable buffer (delta.polyBlob).

    The node FS gained customPolySD — iq's exact sdPolygon over the blob range scaled to device space, so AA, borders and the depth prepass's interior test stay crisp under anisotropy like the generated shapes — and CPU pick runs point-in-polygon over the same record: dual consumers of one ref, agreeing by construction. Binding budget: the poly blob is the node stage's 9th storage buffer, so the node pipelines split into two layouts — main/prepass drop the ghost column (their entry points never read it), the ghost pipeline drops node.flags (no accent/hover on ghosts) — each landing at exactly 8 FS storage buffers; the ghost FS also gained the C2 gradient branch it was missing.

    Points are constants-only, validated (even count, >= 3 pairs, [-1, 1] range — v3's evenMultiple/min/max rules), capped at 32 points (recorded), default to v3's unit square, read back as the space-joined list, and free their pool record on non-polygon restyle and node removal. WGSL lesson repeated: ref is a reserved word (caught by the console-error guard).

    Verification: 9 Node specs (test/shape-polygon.mjs: parse / readback / validation / blob refs / free-on-restyle / pick inside-ness incl. a pool-rewrite case), a webgpu spec (draw + pick agree on the point list at pixel level), the shape-polygon golden (concave arrow outline over bordered / anisotropic / small nodes), and parity-polygon vs v3 at 0.005% (6 px of AA on a shared concave-arrow scene — pure geometry, near pixel-exact). 1919 Node tests, 106 Playwright specs, typecheck + lint green.

  • D1 font-style + font-weight (2026-07-31). Both land as global constants riding the font-family rule: the store's face triple (labelFont/labelFontStyle/labelFontWeight) feeds the atlas's CSS font shorthand (style weight ${SDF_FONT_SIZE}px family), and any change marks every labelled slot dirty so the atlas reset and the glyph-run rebuild land in one pass — no new columns, no shader changes. Values: v3's sets (normal | italic | oblique; the weight keywords plus the numeric hundreds 100..900, read back as strings); edges-group use and mappers throw via the generalized GLOBAL_FONT_PROPS guard (same messages as font-family).

    The playwright page gained the real Open Sans 700-italic @font-face so the D1 golden pins an actual face, not browser synthesis. No v3 pixel parity for labels by recorded design (raster + placement differ) — the pins are the labels-bold-italic golden (label tolerance) and a webgpu spec asserting bold ink > normal ink in the label band plus a nonzero italic-vs-upright pixel diff. 7 Node specs (test/font-props.mjs). 1926 Node tests, 108 Playwright specs, typecheck + lint green.

  • D2 min-zoomed-font-size (2026-07-31). Per-element, as planned: the prop rides the label sidecar (mapper-capable, both groups, v3's default 0 = no floor) and bakes into each glyph as a precomputed zoomDprMin = minZoomed / fontSize — the Glyph struct grew 12→14 words (56-byte stride, one f32 + explicit pad) — so both glyph cull kinds test frame.zoomDpr < zoomDprMin before the global labelFadePx/labelMinPx predicates: v3's eleTextBiggerThanMin (fontSize × zoom × pxRatio < minSize ⇒ hide), evaluated on-GPU per glyph with zero per-frame CPU work, and the background quad hides with its text.

    Fixed en route: setLabel's no-op equality check learns the new field (a restyle changing only the floor previously kept the stale sidecar). No label pixel parity vs v3 by recorded design — the pin is a webgpu LOD spec (floored + unfloored labels: both draw at zoom 1, only the floored one vanishes at zoom 0.7, and it returns at zoom 1 — a pure cull, no rebuild) plus 4 Node specs (test/min-zoomed-font-size.mjs). 1930 Node tests, 109 Playwright specs, typecheck + lint green.

  • D3 text-valign/text-halign (2026-07-31). v3's 3×3 node-label anchor grid, mapper-capable and node-only (the edges group throws, like v3 forcing edge labels to center/center). The sidecar entry carries the node-extent base (anchorX = (halign−1)·w/2; anchorY per valign with the round-10 4 px label margin on the top/bottom rows) plus block-fraction shifts (halign −0.5/0/+0.5 of the laid width; valign −1/−0.5/0 of the laid height) that the glyph builder resolves once the run's real dimensions are known — placement only, no shader or cull changes, and the background box anchors with its text.

    Recorded deviation: v4's default text-valign stays 'bottom' (the round-10 below-node placement every existing golden pins); v3 defaults to 'top'. The v3 padding-based gap is approximated by the fixed 4 px label margin (v4 has no padding prop). No label pixel parity vs v3 by recorded design — pins are the label-align golden (all nine (halign, valign) pairs with background boxes) and a webgpu spec asserting ink moves above-left for top-left and below-right after a bottom-right restyle, with the opposite bands empty. 6 Node specs (test/text-align.mjs). 1936 Node tests, 111 Playwright specs, typecheck + lint green.

  • D4 source-label/target-label families (2026-07-31). All ten props land: source/target-label (constants or the data(key) passthrough, refreshing on data writes), -text-offset (non-negative, mapper-capable), -text-margin-x/y and -text-rotation (none | autorotate) — with the remaining text channels (font, color, boxes, opacity, transform, min-zoomed-font-size) shared with the main label, exactly v3's unprefixed reads. Two more sidecar streams (edgeSource/edgeTarget in the widened LabelStream type) feed two more GlyphBuffers from the same builder; the glyph word 13 pad became the endParam encoding (sign picks the end, |v|−1 the arc offset — the +1 bias keeps offset 0 distinct from the midpoint streams). The edge label VS re-anchors end glyphs by walking the drawn path — v3's calculateEndProjection on-GPU: straight/haystack segments exactly, bezier/loop as a 32-sample polyline of the quad chain (v3 itself walks a ~16-segment approximation), route families along the route polyline (v3's allpts walk — both ignore corner rounding) and multibezier at 8 samples per quad chain link; autorotate takes the local tangent. The shared edge-glyph cull kind grows the viewport slack by half the chord for end glyphs (the anchor can sit anywhere on the path); two more CulledGroups of the same kind and two more draws through the same LabelPipeline (its bind cache re-keyed per (uniform, stream)). Edge removal and restyles clear the streams. No label pixel parity vs v3 by recorded design — the pins are the end-labels golden (straight + bezier pair with autorotate + taxi + loop, boxed labels) and a webgpu spec asserting the straight-edge anchors land at v3's exact arc positions (boundary + offset) and slide on restyle. 8 Node specs (test/end-labels.mjs). 1944 Node tests, 113 Playwright specs, typecheck + lint green. Round 13 complete.

Sequencing: pass 12c (the round-12 plan above) runs first, then this round's phases in order — the 2026-07-29 triage keeps (ghost, overlay/underlay) lead, per the discussion that produced this plan. Process: the round-10 rules verbatim (isolated commits, docs in-commit, full verify per item, escalation to "Needs a call" on any real API-semantics question discovered mid-implementation; goldens regenerated autonomously when a visual change is intended).

Tier discipline (the existing invariants, applied to the new channels):

  • Colors and opacities are paint: fragment-stage fetch, eligible for the GPU mapper eval kernel and paint tweens where the packing fits, always CPU-evaluable.
  • Anything read by bb/fit, the CPU pick replica, or a columnar scan is geometry: eagerly CPU-evaluated, with its bounds/pick consumers extended in the same commit — corner-radius is read by the CPU pick inside-test; node outline-width/-offset, overlay/underlay padding and ghost offsets grow the store bb scan the way border-width already does.
  • List props are constants-only (the 12b scope rule: a mapper value is one number/keyword, not a list), capped where they feed fixed-iteration shader loops, caps recorded as deviations.

Implementation leans recorded at planning (so the passes can run autonomously):

  • Gradients interpolate in sRGB, matching v3's canvas gradients — the live parity harness is the point of porting them. (OKLab stays the default for mapper ranges; a gradient is a v3-parity visual, not a data encoding.)
  • font-style/font-weight follow the font-family rule: global constants (one font per atlas); per-element forms stay out.
  • Dashed border-style/outline-style/text-border-style stay out (dashing an SDF boundary needs perimeter parameterization — the recorded B2 reason); these props ship with solid semantics only where the rest of their group lands.
  • text-valign/text-halign are placement only: labels stay excluded from boundingBox() (the recorded deviation), so the anchor grid carries no bb implications.
  • Arrow scalars are draw-only in v4 (arrows are not pickable and not in bb — both existing recorded deviations), so arrow-scale/ arrow-width/arrow-fill are pure FS/quad-sizing work. (Half-superseded by 57.10: arrows pick now. Not-in-bb stands.)

Phase A — the 2026-07-29 triage keeps (direction already set)

  • A1 Ghost props (ghost, ghost-offset-x/y, ghost-opacity) — the decided simplified form: one extra instance draw of the basic node body (shape, border, background) at the offset, never labels or decorations. Offsets grow the bb scan (geometry tier). Landed 2026-07-31 — see the round-13 record.
  • A2 Overlay/underlay theming — the 10 overlay-*/ underlay-* element props plus the active-bg-* and selection-box-* core props; the baked-in affordances (shader hover/active brighten, accent ring, DOM selection box) become the styled defaults. Overlay/underlay padding grows bounds (geometry tier); underlay draws under the node within the existing pass order. Landed 2026-07-31 in three slices — see the round-13 record.

Phase B — paint & stroke channels (pure FS + channel plumbing)

  • B1 Opacity split: background-opacity, border-opacity, line-opacity, text-opacity — v3 semantics (element opacity is the master multiplier; effective = opacity × channel opacity). Early-z's guaranteed-opaque predicate consumes the product (more conservative, never wrong); text opacity folds into glyph alpha and reads back folded (the outline/background-opacity precedent). Landed 2026-07-31 — see the round-13 record.
  • B2 border-position (inside | center | outside — a pure SDF band offset) + corner-radius (a scalar channel feeding the existing round-rectangle SDF; CPU pick inside-test reads it — geometry tier). Landed 2026-07-31 — see the round-13 record.
  • B3 line-cap (butt | round | square — endpoint cap SDF in the edge FS) + line-dash-pattern/line-dash-offset (arbitrary patterns over the existing arc-length varying; constants-only lists, pattern length capped). Landed 2026-07-31 — see the round-13 record.
  • B4 Edge casing: line-outline-width/-color — a border band on the edge strip (straight and curved), colors fetched fragment-side. Landed 2026-07-31 — see the round-13 record.
  • B5 Node outline-*: outline-color/-opacity/-width/ -offset as an SDF band outside the shape (distance ∈ [offset, offset + width]); solid only. Bb scan and conservative bounds grow by offset + width; the pick body stays the shape itself (v3-consistent). Landed 2026-07-31 — see the round-13 record (the band derives as offset/2 past the border's outer edge, matching v3's scaled-path stroke exactly for circles).
  • B6 Label box parity: text-transform (none | uppercase | lowercase, applied when the glyph run is built), text-border-width/-color/-opacity (a border on the existing text-background quad), text-background-shape (rectangle | round-rectangle on the quad's SDF). Landed 2026-07-31 — see the round-13 record.
  • B7 Arrow scalars: arrow-scale, arrow-width, arrow-fill: hollow (an FS ring test on the existing arrow SDFs). Compound arrow shapes stay out (recorded in round 10 B4). Landed 2026-07-31 — see the round-13 record.

Phase C — re-triaged: 12a/12b built the machinery (these sat in needs-a-call batches; this plan's sign-off pulls them onto the shelf, since the expensive part now exists)

  • C1 Mid-arrows (landed 2026-07-31 — see the round-13 record): mid-source-*/mid-target-* arrow props — anchored at the curve/route midpoint with the midpoint tangent, exactly the anchor + frame edge labels and autorotate already compute in the VS (straight edges use the chord midpoint). One more quad per enabled end off the edge cull streams.
  • C2 Gradients (landed 2026-07-31 — see the round-13 record): background-fill (linear-gradient | radial-gradient) + background-gradient-stop-colors/ -stop-positions/-direction; line-fill + line-gradient-stop-colors/-stop-positions. Stop lists constants-only and capped (cap recorded); node FS evaluates along the gradient frame, edge FS along the arc-length varying; sRGB interpolation per the lean above.
  • C3 shape-polygon-points (landed 2026-07-31 — see the round-13 record) (custom polygon): the per-element unit point list lives in a blob (the curve-blob storage pattern, round-11 compaction rules), the node FS runs the generated sdPolygon loop over the blob range, and CPU pick runs point-in-polygon over the same points — dual consumers of one record, agreeing by construction. Unit points are normalized, so the bb term stays the node box.

Phase D — label props with recorded constraints

  • D1 font-style + font-weight (landed 2026-07-31 — see the round-13 record) as global constants (the font-family rule: one font per atlas; a change resets the atlas and re-lays-out every label).
  • D2 Per-element min-zoomed-font-size (landed 2026-07-31 — see the round-13 record): a sidecar channel baked per glyph run, tested in the glyph cull predicate beside the global labelFadePx/labelMinPx (which stay the defaults).
  • D3 text-valign/text-halign (landed 2026-07-31 — see the round-13 record) for node labels: v3's 3×3 anchor grid, anchor math off the node half-extents (node.outerHalf is already a bindable column); placement only per the lean above.
  • D4 source-label/target-label families (10 props; landed 2026-07-31 — see the round-13 record): two more glyph streams from the round-10 B5 template, anchored at v3's offsets along the edge (source/target-text-offset as arc distance via the route evaluator), each with its own margins and rotation per v3. The chunkiest item — last for a reason.

Excluded from this round, with reasons (each stays in its parked tier; none of these is newly decided): dashed border/outline/text-border styles (perimeter parameterization); round-* polygon variants, cut-rectangle, barrel, concave-hexagon, right-rhomboid, bottom-round-rectangle (no closed form under anisotropic scale — recorded in round 10 B1); multiline props (text-wrap, text-max-width, text-justification, line-height, text-overflow-wrap, text-metrics, box-select-labels — their round designs label bb); the background-image family (texture-atlas architecture call); pie/stripe (wanted-at-all call); the compound group; z-index props (coupled to the compaction draw-order call); transition-* (animation-surface call); display/visibility split, events/text-events, box-selection: overlap (interaction calls); and everything in the dropped-by-decided-design ledger.

Verification per item: parse/readback/mapper Node specs; a golden scene per visual group; live v3-parity scenes where the visual is v3-comparable (gradients, casing, caps, mid-arrows, the valign grid); the WGSL identifier/validation guards as usual. The renderer benchmark re-runs only for items touching hot paths (B1's early-z predicate, C2's node-FS cost).

Landed

Executed the round-13 plan below under the round-10 process rules. Each item landed as isolated commits with docs in-commit; the records below were written per item, in the same commits as the work.

  • A1 Ghost props (2026-07-31). ghost ('yes' | 'no'), ghost-offset-x/y, ghost-opacity (validated [0, 1]; v3 defaults — a ghost is invisible until given opacity) — node-only, all four mapper-capable ('case' works for ghost as an enum).

    The decided simplified form, verbatim: a new node.ghost column ([offX, offY, opacity, enabled], f32×4) drives a ghost pass — the node shader gained vsGhost/fsGhost entry points drawing the body (shape, border, background — no accent ring, no hover/grab brighten, no labels) at the offset with alpha × ghost-opacity, off its own cull stream (a new 'ghost' cull kind: node SHOWN + enabled + visible opacity + the offset quad on screen), drawn after edges/arrows and depth-tested 'less' at NODE_Z so ghost fragments under opaque node interiors are killed — exactly v3's node-over-ghost layering, for free off the early-z prepass. Zero-cost when unused: the store tracks a live ghost-enabled count and the renderer skips the ghost cull + draw entirely at 0. Ghost offsets are geometry: both bb scans (store fit + collection) grow by the offset body when enabled. Deviations, recorded: ghosts are not pickable (v3 same — decoration only), and box selection ignores ghost extents (v4's refsInBox tests the body box only). 8 Node specs (test/ghost.mjs), a webgpu spec (ghost at the offset, not pickable, follows drags on-GPU, old spot clears), a ghost golden (three shapes with borders at one offset), and a parity-ghost live v3 scene — 0.945% mismatch (AA-classification seams only; for label-free nodes v3's whole-node ghost redraw is the body duplicate, so the scenes are directly comparable). 1850 Node tests, 55 webgpu + 30 visual specs, typecheck + lint green.

  • A2 (nodes): overlay/underlay layers (2026-07-31). The 10 overlay-*/underlay-* element props for nodes (edge layers are the next A2 slice): color/opacity/padding mapper-capable, shape (round-rectangle | ellipse) and corner-radius (number | 'auto' — v3's min(w/4, h/4, 8), resolved in the shader from live extents) as constants; v3 defaults (opacity 0, padding 10).

    Two packed Uint32Array×4 columns ([rgba folded, padding×256, shape, radius×256|auto]) drive one NODE_LAYER_SHADER instantiated per layer, drawn off a shared 'nodeLayer' cull kind (two CulledGroups, each binding its layer's column): the underlay after ghosts and under the bodies (depth-tested — early-z hides it under opaque interiors, v3's layering for free), the overlay after the bodies. Layer opacity folds into the stored alpha (readback follows the arrow-color precedent); element opacity does not multiply (v3). Padding is geometry: both bb scans grow by the enabled layer's pad. Zero-cost when unused (per-layer live counts gate cull + draw).

    Deviations, recorded: v4 overlays draw under the label layer (v3 draws overlay over its node's label); overlays are not pickable and box selection ignores their pads. 8 Node specs (test/node-layers.mjs), a webgpu spec (overlay wash + underlay ring), a node-layers golden, and a parity-node-layers live v3 scene at 0 px differing. 1858 Node tests, 56 webgpu + 32 visual specs, typecheck + lint green.

  • A2 (edges): overlay/underlay strokes (2026-07-31). The layer paint props (overlay-color/-opacity/-padding + underlay) now apply to edges too: the edge geometry re-stroked at width + 2 × padding (pre-derived at style-write into packed Uint32Array×2 columns — [rgba folded, strokeWidth×256] — so the layer shaders need no width binding), the underlay under the edges, the overlay over edges + arrows, both under the nodes (v3's layering).

    New vsEdgeLayer/vsCurvedLayer entry points ride the existing edge/curved visible lists with a VS collapse for disabled instances (no new cull kind; per-layer live counts gate the draws — zero cost when unused); the curved layer draw has its own bind group layout that omits the widths column — pipeline layouts count against the per-stage 8-storage-buffer limit even for bindings a shader never references, which the Playwright console-error guard caught as an invalid-pipeline cascade on the first cut.

    Haystack offsets and the straight-triangle taper apply to layer strokes too; layer strokes are solid (no dashes) with butt caps where v3 rounds stroke ends — a recorded deviation confined to the ends. overlay-shape/ -corner-radius stay node-only (v3 ignores them on edges; v4 rejects them). Edge-layer readback: color folded, padding = (stroke − width) / 2. Node-layer suite extended (edge cases); an edge-layers golden (straight + taxi + loop under both layers) and a parity-edge-layers live v3 scene at 2.047% mismatch (the caps + AA). 1858 Node tests, 90 Playwright specs, typecheck + lint green.

  • A2 (core): selection-box + active-bg theming (2026-07-31).

    The sheet gains an optional core group — the v4 home for v3's core-selector props, constants only (there is no element to map over): selection-box-color/-opacity/-border-color/ -border-width theme the DOM selection box (previously hardcoded ≈ v3 colors; now v3's exact defaults — #ddd at 0.65 with a 1px #aaa border — applied per show, so a sheet swap restyles the next box), and active-bg-color/-opacity/-size drive the background-grab indicator: v3's active-bg circle, shown at the press point while the background is grabbed (v4 implements it as a DOM circle above the canvas, like the selection box — a recorded implementation note: v3 draws it into the canvas, so it never appears in v4 exports), radius = active-bg-size screen px (v3's size/zoom-in-model ⇒ screen-fixed rule).

    A2 is now complete (nodes + edges + core). 4 Node specs (test/core-style.mjs — defaults, camel/kebab parsing, sheet-reset, throws) and a webgpu spec (themed box colors mid-drag; the circle appears on a background press at 2×size px and hides on release). 1862 Node tests, 91 Playwright specs, typecheck + lint green.

  • B1 Opacity split (2026-07-31). background-opacity, border-opacity (nodes), line-opacity (edges) and text-opacity (both groups) land as write-time folds into the stored channel alphas — no new columns, no shader changes: fill alpha ×= bg opacity, border ×= border opacity, line ×= line opacity, and the label sidecar folds text-opacity into the text/outline/background alphas alike (v3's parentOpacity).

    Element opacity stays its own column multiplied in the FS, so v3's effective = channel × element holds; the arrow fold gains the line-opacity factor (v3's effectiveArrowOpacity = opacity × lineOpacity), threaded through foldedArrow, the kernel's constOpacity, and the edge-opacity tween's arrow targets. All four are mapper-capable (CPU-evaluated). GPU-eval interplay, the recorded scope note: a non-1 (or mapped) channel opacity demotes that color channel's kernel eval to the CPU path — the kernel would overwrite the folded bytes — via a paintInputs exclusion (a mapped line-opacity also demotes the arrow colors).

    Early-z stays sound for free: the prepass already discards nodes whose stored fill alpha < 1. Readback is folded (stored alpha / 255 — the outline/arrow precedent), and a line-transparent edge reads its arrows as 'none'. 7 Node specs (test/opacity-split.mjs — folds, mappers, the kernel demotion, ranges) and a parity-opacity-split live v3 scene at 0.934% mismatch (translucent AA seams). 1869 Node tests, 92 Playwright specs, typecheck + lint green.

  • B2 border-position + corner-radius (2026-07-31). One new node.borderGeom column ([cornerRadius | −1 = auto, borderPosition]). border-position (center | inside | outside — and v4's default flips to v3's center: the border band now straddles the boundary, [−bw/2, +bw/2]; v4 had silently drawn all borders inside, an unrecorded deviation this closes — parity-basic fell 0.766% → 0.072% and parity-transform 0.486% → 0.238% on the spot).

    corner-radius (number | 'auto') feeds the round-rectangle SDF everywhere the radius appears — node FS, ghost FS, the depth prepass' interior test, and the CPU pick replica — with 'auto' now v3's min(w/4, h/4, 8) (v4 had used min(w, h)/8; also closed). The node/ghost quads, node cull and ghost cull grow by the border's outward extent (the ghost cull uses the full border width — the compute stage had no slot left for the position column; conservative only). Both props are mapper-capable (enum/number, CPU — geometry tier: the pick reads them). bb keeps the outerHalf center convention for all positions (v3's outerWidth does the same — recorded).

    Caught by the guard en route: the first ghost-cull cut hit 9 compute storage buffers. 4 goldens regenerated as the intended visual change (nodes-edges-arrows, polygon-shapes, selection-accent, ghost); a new parity-border-geom scene (three positions × explicit radii) measures 0 px differing. 4 Node specs (test/border-geom.mjs) + the CPU-pick suite pinned to the new auto rule. 1873 Node tests, 93 Playwright specs, typecheck + lint green.

  • B3 line-cap + dash patterns (2026-07-31). line-dash-pattern (constants-only list, normalized to two on/off pairs — odd patterns double per canvas semantics, longer ones truncate, a recorded cap), line-dash-offset and line-cap (butt | round | square; cap + offset mapper-capable) land in two columns (edge.dashPattern f32×4, edge.dashMeta [offset, cap]) bound fragment-side on both edge pipelines.

    The dash mask became a proper 2D coverage: dashInsideSd (signed model-px distance inside the nearest on-segment, wrap-exact) + dashCoverage — butt is the plain product (pixel-identical to the old mask, so the pre-B3 goldens held), round is a capsule about the segment, square extends each dash by the half width. Dashed edges use the per-edge pattern (v3); dotted stays [1, 1]; triangle fills ignore line-style (v3).

    A dash-phase deviation found and fixed: v3 launches the pattern at the source boundary while v4's straight edges measured u from the node center — the straight VS now subtracts the source boundary offset (haystack lines keep their offset-point origin, matching v3's haystackPts), taking the new parity-dash-props scene (pattern + offset + all three caps) from 2.501% to 0 px differing. Caught en route by the Node WGSL-identifier guard's runtime sibling: meta is a WGSL reserved word.

    Line-end caps are dash-segment-only (quads don't extend past the endpoints; v3's default butt behaves identically) — a recorded deviation. 6 Node specs (test/dash-props.mjs); the line-styles golden regenerated for the intended phase shift. 1879 Node tests, 94 Playwright specs, typecheck + lint green.

  • B4 edge casing (2026-07-31). line-outline-width/ line-outline-color ride the A2 layer machinery verbatim: an edge.casing column in the layer record layout ([rgba folded by v3's effectiveLineOpacity = opacity × line-opacity, strokeWidth×256 = width + outline width — v3's lineWidth]), drawn by the existing vsEdgeLayer/vsCurvedLayer entry points between the edge underlay and the edge line, on every family (haystack offsets and the triangle taper included). Both props mapper-capable; zero-cost when unused (casingCount gating).

    A kernel-owned element opacity would leave stale casing bytes, so an enabled (or mapped) casing demotes the opacity mapper to the CPU path — the B1 exclusion list extended. parity-casing (straight

    • bezier pair + taxi under an 8 px casing) measures 0.061% — the recorded butt-vs-round stroke-end deviation only. 5 Node specs (test/edge-casing.mjs). 1884 Node tests, 95 Playwright specs, typecheck + lint green.
  • B5 node outlines (2026-07-31). outline-color/ -opacity/-width/-offset (solid only — outline-style stays out with border-style, the perimeter-parameterization limit). The node.borderGeom column widened to Uint32Array×4 ([radius×256 | auto, position, outlineRgba (opacity folded), width×256 | offset×256 ≪ 16]) — the node FS sat at exactly 8 storage buffers, so the outline packs into the existing binding.

    The ring renders as a second disjoint SDF band at borderOutward + offset/2 (v3 strokes a path scaled by (size + bEff + width + offset)/size, which reduces to exactly this band for circles/squares — pinned by parity-outline at 0 px including an offset-10 case and a bordered case; anisotropic shapes deviate from v3's scaled-path stroke by construction, recorded). Ghost bodies draw their outline too (v3). Node quads/cull grow exactly; the ghost cull grows by the new monotone outlineSlack() via the Frame's last pad (no binding left there); both bb scans grow by offset/2 + width.

    All four props mapper-capable; readback folded/packed. 5 Node specs (test/node-outline.mjs); the B2/CPU-pick suites re-pinned to the packed format. 1889 Node tests, 96 Playwright specs, typecheck + lint green.

  • B6 label box parity (2026-07-31). text-transform (none | uppercase | lowercase — applied at glyph-run build, as v3 transforms before measuring), text-border-width/-color/ -opacity (a band drawn inward from the padded background box — the bg quad's unused outline instance fields carry the border, so the glyph layout is unchanged) and text-background-shape (rectangle | round-rectangle, v3's auto radius — the shape flag rides the solid quad's free uv1.x). The label FS's solid branch became a proper quad SDF (corner-space + quad-size varyings), so round boxes and borders AA exactly; all five props are mapper-capable and stored-truth readback follows the folded rule.

    text-border-style stays out with the other dash-a-boundary styles. No live v3 parity by design: label raster and placement differ from v3 (the round-9.6/9.7 decisions), so the visual pin is the label-tier label-boxes golden (uppercase transform, bordered box, round bordered box in the fixed web font) — v3 comparison for label props is structurally excluded, as recorded since round 9.6. 6 Node specs (test/label-box.mjs). 1895 Node tests, 97 Playwright specs, typecheck + lint green.

  • B7 arrow scalars (2026-07-31). arrow-scale (edge-wide, positive; quantized ×16 into the shapes word's top byte — quantized readback, recorded), source/target-arrow-fill (filled | hollow — flags at bits 16/17) and source/target-arrow-width (px | 'match-line' | %, resolved against the edge width at style-write into a new edge.arrowWidths column).

    Both arrow shaders restructured: exact sizing moved to the fragment stage — the quad covers the frame's monotone arrowScaleMax (a Frame pad slot) and the FS renders the exact per-edge scale within it, which is what lets the curved arrow VS (whose 8 storage-buffer slots were all taken) stay untouched; hollow fills render as an |sd| ring at the per-end stroke width. Scale/fill are mapper-capable; widths are constants (keyword/% forms).

    No pixel parity vs v3 by design: v4 keeps its own linear arrow sizing (round-10 B4's recorded decision; v3 uses max((13.37 w)^0.9, 29) with a 29-unit floor), so arrow sizes never coincide — the visual pins are the arrow-scalars golden (scale 2, hollow ends, thick hollow strokes) and a webgpu hollow-ring pixel spec. 6 Node specs (test/arrow-scalars.mjs). 1901 Node tests, 99 Playwright specs, typecheck + lint green.

  • C1 mid-arrows (2026-07-31).

    mid-source/mid-target-arrow- shape/-color land exactly as re-triaged: two folded color columns plus the mid shape ids packed into the arrowShapes word's free bits (18..20 / 21..23 — every ARROW_* id fits in 3 bits), drawn by new vsMidArrow entry points on both arrow pipelines whose End uniform generalized to an endId (target / source / mid-target / mid-source, four cached bind groups each). Straight edges anchor the tip at the chord midpoint (the haystack offset midpoint for kind 6 — the straight arrow layout gained the curveParams binding, landing at its 8-buffer budget exactly); curved edges reuse the label VS's midpoint machinery — the curve midpoint/loop c1→c2 tangent analytically, routeMidpointW for the route families — so mid arrows follow drags/layouts/tweens on-GPU like everything else; mid-source points backward (v3's midsrcArrowAngle). Mid arrows are always filled at standard width (the mid fill/width props are unsupported — recorded), shapes/colors are mapper-capable, stored truth reads transparent mids as 'none', and per-edge draws gate on a live midArrowCount. Fixed en route: a latent round-10 gate bug — the arrow-draw enable checked shape === 'triangle', so constant vee/chevron/circle/... sheets never drew arrows at all; now any non-'none' shape draws. (Follow-up, same day: the B7 arrow-scalars golden predated this fix — its scene's constant source-arrow-shape: circle arrows never drew when the golden was generated — so it went stale the moment the gate was fixed; regenerated in its own commit once the C3 full-suite run caught the 0.931% drift.) Sizing shares B7's v4-linear formula (no pixel parity vs v3 by the recorded B4 decision) — the pins are the mid-arrows golden (straight + bezier pair + taxi + haystack) and a webgpu spec asserting purple mid-arrow ink at the CPU-computed renderedMidpoint() of both a straight and a curved edge. 3 Node specs (test/mid-arrows.mjs). 1904 Node tests, 100 Playwright specs, typecheck + lint green.

  • C2 gradients (2026-07-31).

    background-fill (solid | linear-gradient | radial-gradient) with background-gradient-stop-colors/-stop-positions/-direction (v3's eight to-* keywords), and line-fill with line-gradient-stop-colors/-stop-positions. Storage: one packed Uint32Array×8 record per element ([meta kind|dir|count, 5 stop colors, packed positions]) — stops cap at 5 and stop lists are constants-only (recorded); fills/directions are mapper-capable enums. Stops interpolate in sRGB (the plan's lean: v3's canvas gradients; OKLab stays the mapper default), positions spread evenly when unset and clamp monotone (canvas semantics), and the channel opacity folds into each stop. Binding budget: the node FS was full, so the shape id folded into borderGeom (bits 16..19, written with the style's other geometry) freeing the shapes binding for the gradient record; edge gradients bind fragment-side on both edge pipelines, with the drawn span (boundary-to-boundary for straight, the polyline arc length for curved) as a new flat varying so linear fills run v3's extent and radial fills mirror about the midpoint. The depth prepass conservatively discards gradient fills (translucent-anywhere); plain-LOD discs keep the flat base color (recorded). parity-gradients (three-stop linear on rectangles + a gradient line vs v3) measures 0 px differing — the sRGB lerp matches canvas exactly; the gradients golden covers directions, radial, ellipse and curved-line fills. 6 Node specs (test/gradients.mjs). 1910 Node tests, 102 Playwright specs, typecheck + lint green.

Compound nodes

The head of the design queue: parent/child hierarchy, auto-sized parent nodes, compound draw order, ancestor-gated visibility/opacity, event bubbling, compound loop edges, and the compound style/query/API surface. Design discussed and signed off in one sitting (2026-07-31); this section records the calls and the pass split so the round can run under the round-10 process rules (isolated commits, docs in-commit, full verify per item, escalation on any new API-semantics question).

Two process amendments, user-set for this round: docs land first (this plan section and the README pointer are their own commit before any implementation), and each item is tests-first — its specs are written and seen red before the implementation brings them green, landing together as the item's isolated commit so every commit on v4 stays green.

Signed-off design calls:

  1. Parent styling takes both decided forms. (a) The sheet gains a parents group overlaying the nodes group for parent slots — constants or mappers, defaults = v3's :parent block (shape: rectangle, padding: 10, background-color: #eee, border-width: 1, border-color: #ccc). (b) The case mapper's when gains structural boolean conditions ({ parent: true }, { child: true }). Query objects gain the matching parent/child boolean keys. The v3 :parent:selected tint is dropped — v4 never restyles on selection (the shader accent ring is the selection affordance); recorded deviation.
  2. Event bubbling is ported (reversing v4's flat-emit rule for compounds only): element events bubble child → ancestors → core with v3 semantics — event.target stays the originator, stopPropagation()/return-false halts the walk. The flat no-compounds path stays byte-identical (zero cost).
  3. Pass-1 scope: hierarchy + traversal API + move({ parent })
    • remove-cascade + auto-bounds with padding + parents-under- descendants draw order + parent drag moves subtree + parent labels, plus ancestor-gated visibility, rendered effectiveOpacity (ancestor product), compound loop edges, and min-width/min-height as a simplified centered clamp — the four bias props (min-width-bias-left/right, min-height-bias-top/bottom) are dropped by decided design (their px-reinterpreted-as-percent rule and ratio normalization don't earn their surface; the centered clamp is exactly v3's default-bias behavior).

      Future-round note (user-set): revisit asymmetric parent spacing with a cleaner mechanism — e.g. four per-side padding props — rather than resurrecting the biases.

  4. Dropped/recorded: z-compound-depth/z-index-compare (the z-index round); compound-sizing-wrt-labels: 'include' throws (labels are excluded from bb in v4 — the prop parses, 'exclude' is the only accepted value); the bias props and :parent:selected (above).

Global decisions:

  • Flag bits (contract.mts; 4096+ free): FLAG_PARENT = 4096 (has ≥1 child), FLAG_CHILD = 8192 (has a parent), FLAG_SELF_HIDDEN = 16384 (own display state). FLAG_VISIBLE is redefined as the effective shown bit (own state AND no hidden ancestor) — every consumer (WGSL SHOWN, the cull predicates, scanRefsInto, boundingBox, CPU pick, and the edge kernels' both-endpoints-SHOWN tests) already reads it, so ancestor gating and edge gating land with zero shader or scan changes. Store-managed derived bits follow the FLAG_CURVED precedent.
  • Parent geometry is materialized into the real node.size/node.position columns by a lazy pull-based flush (the CurveIndex pattern), so bb, cull, pick, refsInBox, the mirror and all shaders need zero geometry changes. GraphStore.flushDerived() = hierarchy.flush() then curves.flush(), replacing every curves.flush() call site — hierarchy first, because curve derivation (loops, compound loops, endpoint math) reads the sizes/positions the hierarchy flush writes.
  • Verified at planning: no StyleEngine.dependsOnSelection exists at HEAD (it left with the selector removal) — the parent-flip restyle hook is built fresh; and v3 edge effectiveOpacity is the edge's own opacity (edges have no parent), which v4 ports.

Design (per subsystem):

  • HierarchyIndex (store/hierarchy.mts, new; modeled on store/curve-index.mts — host-callback object, pending sets, flush()). State: parent: Int32Array (−1 = orphan) + parentGen (recycle safety; mismatch ⇒ orphan + warn-once), children: Map<slot, slot[]>, depth: Uint16Array, per-parent CPU style inputs (padding, unit, relativeTo, minW/H, fallback size), baseOpacity (pre-fold), resolved padding cache, pendingParents, parentCount, orderDirty.

    setParent cycle-guards by ancestor walk (cycle ⇒ warn + no-op, v3), maintains children/depth/flags, marks old+new chains pending, invalidates the subtree's incident edges in the CurveIndex, and fires the style flip + structural-case refresh hooks.

    flush() expands pending to ancestors, sorts depth-descending (children-before-parents replaces recursion), computes direct-children bb from raw columns (skipping effectively hidden children), applies padding (px or % of children-bb w/h/average/min/max), the min-size centered clamp, and the degenerate-children fallback (stylesheet size at the stored position), then writes through materializeGeom — raw column writes + dirty marks + updateOuterHalf + geoEpoch++ + label re-anchor when size changed + incident-edge curve invalidation. materializeGeom bypasses setPosition, so no child shift and no re-marking: flush cannot re-enter itself.

  • Parent setPosition (public path): shift all descendants by the delta via raw writes (locked children move too — v3), write the parent, mark only its ancestors pending (uniform subtree translation keeps its own derived center exact). The bulk position writers and shift/positions gain v3's dedupe rule: skip elements whose ancestor is also in the written set.

  • Flush triggers: the four position writers (slots with FLAG_CHILD), size/border writes (beside the updateOuterHalf hooks), add/remove/reparent, compound style writes, visibility toggles. Drained from flushDerived() at takeDelta (before mirror sync), boundingBox, refsInBox, the collection bb sites, and the pick entry.

  • GPU tween demotion: a position animation whose slots include any FLAG_CHILD/FLAG_PARENT node is not GPU-eligible (a GPU lease leaves CPU positions stale ⇒ stale auto-bounds; a tweened parent must shift children per tick). Reparenting while a GPU position tween is live settles all active GPU position tweens to the CPU (rare structural op; recorded).

  • Draw order / cull / pick: a new parentNode cull kind whose input iteration is a CPU-maintained permutation (parentOrder, parents sorted by (depth asc, slot asc), rebuilt on hierarchy change — compaction preserves input order, so parents paint shallow-under-deep); bindings positions + outerHalf + flags + parentOrder (+3 outputs) = 7/8.

    The existing node cull predicate excludes FLAG_PARENT (flags already bound), which also removes parents from the depth prepass — mandatory, since a prepass-written parent interior would early-z-kill the edges/children that must draw over it (parents lose the early-z benefit; recorded — they are few and flat). drawScene draws parent bodies right after the prepass, before edge underlays, reusing the main node pipeline. Parent ghost/underlay/overlay/label bands keep their existing post-edge positions — recorded z deviations deferred to the z-index round. CPU pick becomes two passes mirroring draw order: leaves descending (skip FLAG_PARENT), then parents in reverse parentOrder, with a shared order helper so pick and draw can't diverge.

    Dragging a parent needs no drag-set union (parent setPosition shifts the subtree); FLAG_GRABBED is not set on descendants (minor recorded deviation).

  • Visibility + opacity folds: setVisibility sets/clears FLAG_SELF_HIDDEN and recomputes effective FLAG_VISIBLE over affected subtrees (pruned walk), marking parents pending (hidden children leave the bb). visible() reads the effective bit; the display readback reads !FLAG_SELF_HIDDEN. node.opacity stores the effective value (base × ∏ ancestor bases — the round-13 B1 fold pattern, with the base tracked CPU-side); a parent's opacity write refolds its subtree, gated on parentCount > 0 so the non-compound path is unchanged. style('opacity') reads the base; effectiveOpacity() the fold. GPU-mapped node opacity (and width/height) demote to CPU while compounds exist (the kernel would overwrite the fold; auto-size owns parent sizes).

  • Bubbling: phase-based fan-out in core._emitOnEle — flat mode (no compounds, or orphan/edge target) is exactly today's single emit; phased mode emits per chain element child → ancestors → core, checking isPropagationStopped() between phases (the shared emitter's existing machinery). Ref-qualified listeners match the phase ref; predicates run against the phase element; unqualified listeners match only the core phase (still fire exactly once). callbackContext returns the phase element (v3's currentTarget); event.target stays the originator.

  • Style/query: SHEET_KEYS gains 'parents'; the block takes node props plus padding, padding-relative-to, min-width, min-height, compound-sizing-wrt-labels. The engine holds a second computed-const record (nodes overlaid with the parents block); apply picks by FLAG_PARENT; parent width/height divert to the fallback size (auto-bounds owns node.size). The parent-flip hook re-applies the flipped slot's constants, re-bakes its label entry, transfers width/height ownership both ways, and refreshes structural case deps (pseudo-keys '::parent'/'::child' in the deps map).

    Matcher: parent/ child boolean keys OR-composed into the flag test like selected; group: 'edges' + a structural key throws. Any channel where the parent overlay differs while GPU-mapped demotes to CPU.

  • Compound loop edges: the CurveIndex host gains relation(a, b) from the hierarchy; ancestor/descendant edges (and parent self-loops) derive a CURVE_MULTI-family blob record with v3's findCompoundLoopPoints math verbatim (two control points off the min top-left corner, loopW = 50, per-end stretch max(0.5, log(w·C))), box-bounded (FLAG_CURVED_BOX). Applies regardless of declared curve style (v4 has no edge:compound selector — mirrors the forced self-loop rule; recorded). Re-derives on reparent and on endpoint resize during hierarchy flush.

  • Model/API/format: parent becomes a reserved first-class key — skipped by def/columnar data ingest, immutable via data() (reparent via move()), synthesized on read like edge source/target. Def ingest resolves parent in a second pass after the batch's nodes exist (forward refs OK; unknown parent ⇒ warn + orphan, v3). Wire format: version bump + optional nodes parent section (u32 index, sentinel); ColumnarNodes.parent?.

    Collection: the full traversal surface (slot-native), remove() cascade over descendants, identity-preserving move({ parent }) with moveout/move, compound-relative relativePosition, real padding(), and parent width() readback subtracts 2·padding (the column stores the padded/drawn size; paddedWidth() returns the column) — v3 parity. cy.hasCompoundNodes() goes live. Layouts position non-parents only; boundingBoxAt force-derives.

Pass split (tests-first per item; each lands green as its own commit(s) with docs in-commit):

  • 14.0 Docs-first — this plan section + the README pointer (landed as its own commit before any implementation, per the user-set process amendment).

  • 14.1 Hierarchy model — landed 2026-07-31.

    FLAG_PARENT/FLAG_CHILD (contract bits 4096/8192, node-only, store-managed like FLAG_CURVED); store/hierarchy.mts — the HierarchyIndex (host-callback object like the CurveIndex): parent: Int32Array (−1 = orphan) + link-time parentGen (recycle guard, warn-once), sparse children lists, depth, live-parent count, and the lazily-rebuilt parentOrder() (depth-asc, slot-asc) draw permutation. setParent cycle-guards by ancestor walk (warn + no-op, v3's dropped-ref rule), maintains flags/depths (subtree walk on reparent) and no-ops on same-parent writes; removeNode now throws while children remain (the 14.2 collection cascade removes them first) and severs the node's own link. Store delegates (setParent/parentOf/childrenOf/ depthOf/isAncestorOf/parentCount/hasCompounds/ parentOrder); cy.hasCompoundNodes() is live. The parent data key is reserved first-class: def ingest skips it (14.2 resolves it as hierarchy), data('parent', v) throws (reparent is move()), and reads synthesize from the hierarchy like edge source/target (whole-object data() includes parent only when parented). Tests-first: 12 specs in test/hierarchy.mjs written red, then green — 1956 Node tests, typecheck + lint clean.

  • 14.2 Collection API + lifecycle — landed 2026-07-31. Slot-native traversal on the hierarchy: parent (always a proper collection — v3's raw-ref single-element shortcut and its ignored-selector wart are not ported), parents/ancestors (level-by-level, nearest first), children (link order), descendants (pre-order), siblings (via parent().children() − self; orphans are nobody's siblings), orphans/nonorphans (filters of the calling collection), commonAncestors (closest first; an edge member empties the result, v3), and the isParent/isChildless/isChild/ isOrphan predicates (booleans, first-element semantics).

    Lifecycle: remove() cascades over descendants + their incident edges (packed-seen closure; nodes removed depth-descending so the store's children-first rule always holds); move({ parent }) re-parents in place — identity preserved, moveout before / move after per changed node (listener-gated), unknown parent a silent no-op (v3), cyclic assignment warns + drops with no events; def ingest resolves data.parent in a second pass after the batch's nodes exist (forward refs in any order; numeric parents coerce to string ids; unknown/non-node parents warn + orphan — v3's silent-drop case upgraded to a warning); element json() carries parent via the synthesized data object and round-trips through add().

    Tests-first: 17 specs in test/compounds-api.mjs red then green — 1973 Node tests, typecheck + lint clean.

  • 14.3 Auto-bounds flush — landed 2026-07-31. Parent geometry is derived lazily and materialized into the real node.position/node.size columns, so bb/cull/pick/mirror need zero geometry changes.

    HierarchyIndex gained the pending set (markGeo marks whole ancestor chains with early-exit; markAncestors for pure translations), per-parent compound style (setCompoundStyle: padding px/% + relative-to, min-w/h), and flush(): deepest-first over pending parents, direct children's border-inclusive extents off node.outerHalf (hidden children excluded — v3's display:none bb rule), % padding against the pre-clamp children bb (v3), the centered min clamp, and the degenerate fallback to the stashed style size at the stored position.

    The stored size is the padded/drawn box: width()/height() readback subtracts 2·padding (v3's autoWidth), paddedWidth/paddedHeight return the column, outerWidth = padded + border, padding() answers the resolved pad. Writes go through materializeParentGeom — dirty spans, updateOuterHalf, the nodeHalfMax cull meter, geoEpoch, and a store-side label re-anchor (the sidecar entry's halign/valign reconstruct from its block-fraction shifts, so no engine round-trip) — and never re-mark: the flush can not re-trigger itself (spec-pinned). GraphStore.flushDerived() = hierarchy then curves, replacing every curves.flush() site; drains at takeDelta/bb/refsInBox/ accessors.

    Triggers: the four position writers (a parent setPosition flushes, then shifts its subtree by the delta — v3's beforePositionSet — with locked children moving too; bulk writers take per-slot sequential semantics under compounds), size/border writes (markGeo; a style size write on a parent also refreshes the stashed fallback), add/remove/reparent, and show/hide (hidden children leave the bb). Collection: shift() gains v3's ancestor-in-set dedupe; parent moves emit position for shifted descendants (listener-gated, v3); compound-relative relativePosition (get + both setter forms); parent-flip restores the stashed style size.

    Tests-first: 14 specs in test/compound-bounds.mjs red then green (two real bugs caught red-green: the parent-move delta and the bulk shift both read pre-flush positions — both now flush first) — 1987 Node tests, typecheck + lint clean.

  • 14.4 Ancestor visibility + effective opacity — landed 2026-07-31.

    FLAG_SELF_HIDDEN (16384) records the element's own show/hide state; FLAG_VISIBLE is now the effective shown bit (own state AND no hidden ancestor) recomputed by GraphStore.setVisibility over affected subtrees with pruning (an unchanged effective bit means a consistent subtree) — every consumer (WGSL SHOWN, cull, scans, bb, CPU pick) reads the one bit unchanged, changed nodes mark their chains' auto-bounds stale, reparenting re-resolves the moved subtree, and a child's own hidden state survives parent toggles (v3). refsInBox gained the drawn-edge rule (both endpoints shown — closing a pre-existing gap where a hidden endpoint's edges stayed box-selectable). Effective opacity renders: the node opacity column stores base × ∏ ancestor bases (bases tracked sparsely; writes fold at setScalar, a parent's write refolds its subtree, reparenting refolds against the new chain, recycled slots drop their state), style('opacity') reads the base while effectiveOpacity()/transparent() read the fold, edges keep their own opacity (v3 — verified against v3 source), and a GPU-mapped node opacity demotes to CPU while compounds exist (paintInputs + a store→engine onCompoundsToggled paintVersion bump on the 0↔>0 transitions). Tests-first: 11 specs in test/compound-visibility.mjs red then green — 1998 Node tests, typecheck + lint clean.

  • 14.5 Event bubbling — landed 2026-07-31. Element events on parented nodes now run in phases — origin → ancestors (child→parent) → core — implemented as _emitOnEle re-emitting one shared Event with a moving _gpuPhaseRef, so stopPropagation() (or return-false) carries between phases and halts the walk (v3).

    Per phase: ref-qualified element listeners fire in their own element's phase with the callback context set to that element (v3's currentTarget) while event.target stays the originator; unqualified core listeners fire once, in the core phase; predicate listeners keep v3 delegation semantics — once, against the originator, at the core (verified against v3's core-selector delegation, which also matches the target once). Flat emits (no compounds, orphan/edge targets) never stamp the phase fields and take exactly the old single-emit path — byte-identical behavior and zero cost. Within-phase order stays registration order (the recorded deviation narrows to within-phase only).

    Tests-first: 9 specs in test/compound-events.mjs red then green — 2007 Node tests, typecheck + lint clean.

  • 14.6 Parents sheet group + compound props — landed 2026-07-31.

    The sheet gains parents: channel props that overlay the nodes group for parent slots with v3's order-based precedence — the default :parent overlay (rectangle, #eee fill, 1px #ccc border) < user nodes block < user parents block (v3 applies blocks in order; the 14.9 parity scene caught the first cut assuming specificity ordering) — plus the compound props (padding px or 'N%', padding-relative-to, min-width/min-height, compound-sizing-wrt-labels where 'exclude' is the only accepted value, 'include' throws — labels are excluded from bb; compound props are constants-only and throw outside the parents group). Padding defaults to v3's

    1. Engine mechanics: a third GroupDef compiled from the merged props (parents-block mappers evaluate for parent slots only); applyBulk/refreshMapped partition node slots by FLAG_PARENT; mapper escalations re-partition via allSlotsFor; the readback paths route through defFor(ref); stylesDependOnData consults the parents deps; store.setCompoundStyle lands per parent at apply. Flip restyle: a leaf↔parent flip re-applies the slot against the right group via a store onParentFlip hook (defaults differ, so flips always visibly restyle — v3); parent style width/height keep flowing into the stashed fallback (the 14.3 ownership rule). GPU demotion: channels the parents overlay resolves differently (the default overlay's background/border colors, any user parents-block prop) demote a nodes-group GPU mapper to the CPU path while compounds exist — the kernel evaluates every slot and would repaint parents with the nodes value. Readback: compound props answer from the per-parent record (leaves read the zero defaults). Tests-first: 9 specs in test/parents-style.mjs red then green; the 14.3 bounds suite pins raw math by zeroing the new defaults in its sheet — 2016 Node tests, typecheck + lint clean.
  • 14.7 Structural query + case keys — landed 2026-07-31. Query objects gain parent/child booleans (parent: false = v3's :childless, child: false = :orphan), OR-composed into the one flag test like selected — pure columnar scans, no scanRefsInto changes. Structural keys are node concepts: an explicitly-edges query throws, an unrestricted one just never matches edges (v3's pseudo semantics).

    The case mapper's when gains the structural forms { parent: bool } / { child: bool } — a structural condition stands alone (AND it with data conditions via the when array form) and compiles to the reserved '::parent'/'::child' keys the engine's value reader answers from the hierarchy flags, so deps registration, evaluation and refresh all reuse the data-condition machinery verbatim. A reparent fires a pseudo-key refreshMapped on the moved node (store.onReparented); parent flips already restyle fully via 14.6's hook. Tests-first: 8 specs in test/structural-query.mjs red then green — 2024 Node tests, typecheck + lint clean.

  • 14.8 Wire + columnar parent sections — landed 2026-07-31.

    ColumnarNodes.parent?: Uint32Array — payload node indices, NO_PARENT (0xffffffff) sentinel — with toColumnarElements lifting def parents into it (unknown in-payload parents warn + orphan; the parent key never lands in the data columns), bulk store ingest linking after the flags fill (out-of-range indices throw the self-contained rule; cycles ride the setParent guard — the first payload link holds, the closing link warns + drops), and the wire format gaining the node-parent section (flag 512, written right after positions). Wire version bumps to 3; the reader accepts 2–3 (a v2 buffer can never carry the parent flag, so old payloads load unchanged — spec-pinned by re-stamping a compound-free v3 buffer as v2). cy.serialize() flushes derived geometry and exports the live hierarchy as payload indices (second pass — a parent may sit later in slot order than its children), round-tripping selection + positions + parents. Tests-first: 7 specs in test/compound-wire.mjs red then green — 2031 Node + 60 module tests, typecheck + lint clean.

  • 14.9 Parent draw stream, cull, pick — landed 2026-07-31. Parent bodies draw in their own stream right after the depth prepass (under every edge layer — v3's compound order), off a new parentNode cull kind whose input iteration is the CPU-built (depth asc, slot asc) permutation: the compaction scaffold's write expression is now parameterizable, and the parent kernel writes the permuted slot, so its visible list is already in paint order (outer parents under inner ones) with zero sorting on-GPU.

    Bindings: positions/sizes/flags/ borderWidths + the parentOrder buffer (uploaded only when the hierarchy's order object changes identity) at exactly the 8-storage budget, with the ghost cull's conservative extent tier (full border + the frame outline slack).

    The main node cull (and with it the depth prepass) excludes FLAG_PARENT — flags were already bound, zero new bindings — which is also what keeps early-z from killing the edges/children that draw over parent interiors (parents lose the early-z benefit; recorded — few and flat). CPU pick became two passes mirroring draw order: leaves descending, then parents in reverse permutation (deepest wins), so a parent can never swallow its children's picks; the pick entry and export/serialize paths flush derived geometry first. Two real bugs caught by the new harness: the renderer's init-time mirror full-upload ran before the hierarchy flush (the exact 12a init-order lesson re-hit — parents rendered at their pre-derive columns; init now calls flushDerived()), and 14.6's specificity assumption was wrong — v3 precedence is order-based, so a user nodes block overrides the default :parent overlay (the parity scene showed v3 parents in the user node color; the merge order and GPU-demotion set were corrected, with the parents-style suite re-pinned). Verifies: 3 new compound CPU-pick specs, a webgpu behavioral spec (child-over-parent pixels, padding band, edge-over-parent, pick in band vs child, parent follows child), the compounds golden (nesting/padding/borders), and the parity-compounds live v3 scene at 2.09% under a 3% bound — the residual is a recorded deviation: v3's node bb includes the border's miter-corner overshoot (~(√2−1)·border/2 per side on cornered shapes), which compounds inherit as slightly larger parent boxes with bordered children; v4's child extents are the plain border-inclusive outerHalf. Full suites: 2034 Node tests, 116/116 Playwright (54+3 webgpu... all pre-existing goldens byte-stable), typecheck + lint clean.

  • 14.10 Compound loop edges — landed 2026-07-31.

    An edge between a node and its own ancestor/descendant (or a self-loop on a parent) routes around the outside — v3's findCompoundLoopPoints verbatim (two controls off the endpoints' min top-left corner, (1 + 50^1.12/100)·dist·(j/3+1) offsets, stretch max(0.5, ln(outerWidth·0.01)) per end) — as a new CURVE_CMPD kind rendered exactly like a loop (two C1 quadratics through the control midpoint) with control points evaluated from live positions/outer halves in both implementations, so drags and auto-bounds resizes follow with zero re-derivation. Routing applies whatever the declared curve style (v3's edge:compound default block makes related edges bezier-compound by default, so behavior matches; unbundled styles take control-point-distances[0] and j = 0 — v3). Derivation rides the CurveIndex: a relation is a pair-map build trigger (bundle indices), reparenting invalidates the moved subtree's incident edges, leaf↔parent flips re-route self-loops, and flush() loops until settled (a per-edge derivation that discovers a relation hands its pair back). Cull: box-bounded (FLAG_CURVED_BOX) plus a derivation-time excursion bound in curveSlack (2× stretch margin — stretch grows only logarithmically with node size; parent resizes refresh the bound; recorded). Two kind-space traps found: the WGSL analytic-vs-route dispatch (params.w <= 2.0) sent the new kind into the blob-route path — six dispatch sites now special-case it (the first golden run caught taxi-like garbage) — and CURVE_HAS_ENDPT = 8 collided with the naïve next kind id, so CURVE_CMPD = 16 sits above the endpoint-flag range with a contract note (raw-kind tests only, before any strip).

    Verifies: 9 Node specs (test/compound-loop-edges.mjs, v3-formula control points, relation lifecycle, slack/flags, live resize), the compound-loops golden, and parity-compound-loops live vs v3 at 0.022% (the outside-to-line vs outside-to-node endpoint difference is invisible at this scale). 2043 Node tests, 118/118 Playwright, typecheck + lint clean.

  • 14.11 Interaction + tween demotion + layouts — landed 2026-07-31.

    Layouts position leaves only (v3): layoutPositions filters parents (auto-bounds derive them from their placed leaves), the grid slot path filters FLAG_PARENT slots, the grid handle path / circle / concentric / breadthfirst filter their node lists, and preset skips parent entries in both forms (a preset parent write would shift its whole subtree). boundingBoxAt skips parent bodies — the leaves' hypothetical boxes stand in; the padding margin is not modeled (a recorded fit-target approximation). GPU tween demotion: a position animation whose node targets carry FLAG_PARENT|FLAG_CHILD is not GPU-eligible (a lease would leave the CPU columns the auto-bounds derivation reads stale, and a tweened parent must shift its subtree per tick — CPU-only semantics); unrelated leaves in compound graphs stay eligible. Reparent settle: AnimationManager.settleGpuAll() (factored from detachDriver) runs from the store's reparent hook, so live leases settle to the CPU before the moved slots fall under CPU-side derivations. Interaction needed no pointer changes: a parent drag is just position() (the 14.3 subtree shift), and drag-all-selected with a parent + its child rides the collection shift() dedupe. Tests: 6 Node specs (test/compound-layouts.mjs) + a Playwright drag spec (parent-band drag moves the subtree by the pointer delta; a selected parent+child pair moves exactly once). 2049 Node tests, 119/119 Playwright, typecheck + lint clean.

  • 14.12 Debug scene + benchmarks + true-up — landed 2026-07-31. debug gained a ?network=compound generated scene (clustered leaves under ~N/20 parents, every 4th parent nested, intra-cluster edges plus a sprinkle of child→parent compound loops).

    benchmark/compound.mjs (Mitata, v3 vs v4 at BENCH_N; instances torn down after the run — v3 compound instances leave live timers behind): at N = 2k, parent drag (subtree shift + bb settle) 263× v3 (1.14 µs), child drag + parent re-derive 59× (1.50 µs), reparent round-trip 142× (0.64 µs). Flush cost at scale (200k leaves under 1 000 parents, 200 children each, 200k edges; direct measurement): init 1.81 s, a full re-derive of all 1 000 parents 2.7 ms, a parent-drag frame (200-child subtree shift

    • flush + delta) 17.6 µs, a child-drag frame 11.8 µs — auto-bounds are noise at frame rate. Renderer benchmark gained gen-25k-compound (25k × 50k under 1k parents, leaves clustered per parent — scattered members would make every parent span the whole graph, overdraw rather than a representative scene): on this box (RX 580, dpr 2, scale 1) the gpu side holds vsync (16.7 ms wall p50) in every scenario — fit-all, zoomed-in, far-zoom, labels on — while v3 canvas runs ~2 s/frame fit-all and ~240 ms zoomed-in; init 296 ms vs 5.1 s. Final docs true-up in this commit. Round 14 is complete.

Risks tracked per item: flush re-entrancy (raw-column reads only); parent width() readback consistency across style/bb APIs; recycled parent slots (gen guard); leaf↔parent flips (size stash/restore, label re-anchor); deep-nesting drag cost (markChildGeo early-exit; the benchmark item guards it); mid-tween reparent settle; parent decoration bands above edges (recorded, z-index round); the shared pick/draw order helper; wire backward compat (optional section).

Design sitting — z-index dropped, rounds 15–18 scoped

Decided with the user in one sitting. Every round below runs under the round-10 process rules plus the round-14 amendments, now standing policy: docs land first (each round's 0-item commits its plan section + README pointer before any implementation) and every item is tests-first (specs written and seen red before the implementation brings them green, landing together as the item's isolated commit).

The z-index call — dropped outright. v4 ships no z-index, no z-compound-depth, no z-index-compare, and no built-in grab-raise either. Reasoning, recorded: element stacking is a document/UI concept without a strong graph use case — node overlap is a layout artifact rather than an authored arrangement, layered emphasis is already served structurally (parents under edges under leaves under labels; overlay/underlay props; opacity dimming), and v3 carried the prop triple at the cost of a whole-scene comparator sort per frame.

The compound worry raised in the sitting (edges into child nodes must stay visible) is already answered by the round-14 stream split: parent bodies draw under all edges, leaves above them. Consequences, now permanent (all were already recorded deviations): draw order is structural + slot order within a stream; a grabbed node does not pop above later-inserted nodes; parent decorations (ghost/underlay/overlay/label bands) keep their post-edge positions. sortByZIndex/zDepth close with the props.

The only logged future extension, if real demand ever appears, is a single boolean elevated tier (one extra batch per group drawn over the leaf stream) — never arbitrary integer stacking; logged, not planned.

Queue after the sitting: background images (round 15) → multiline labels + label bb (round 16) → event vocabulary + the extension contract (round 17) → GPU force layout (round 18).

Background images

The 16-prop background-image family — the "sleeper third" pillar of the 2026-07-29 sweep (near-universal in production apps). All calls below signed off in the 2026-08-01 sitting.

Signed-off design calls:

  1. Storage is size-tiered texture arrays with hardware mips — not a shelf atlas, not batch-per-image. Unique images dedup by URL into an ImageRegistry (the string-dictionary discipline: refcounted entries, round-11 waste-threshold reclaim); each image rasters into a layer of a per-tier texture_2d_array (128² / 512² / 1024², rgba8, full mip chain generated at upload), native w/h kept per entry for UV/aspect math. Layers are slots: free-list alloc/reclaim, growth by realloc-copy.

    Rationale from the sitting, recorded: mips make minification cheaper as well as crisper (coherent low-mip reads vs scattered full-res texels — an unmipped atlas is a bandwidth spike at far zoom); array layers churn and grow like every other store structure, where a shelf atlas fragments toward a repack-the-world cliff; and the draw stays one instanced call per stream (batch-per-unique-image was ruled out — it breaks the cull → indirect-draw shape). Cap: images raster at most at the top tier (1024²; the imageMaxSize renderer option moves the cap) — a recorded deviation for large photo sources.

  2. Full-color SVG stays crisp by zoom-promotion. A vector source has no native resolution, so a fixed raster is our artifact: per unique SVG the renderer tracks the max on-screen device-px demand among visible users (a CPU-side max over unique images riding existing per-frame state) and, when demand exceeds the current raster by ~1.5× (with hysteresis), re-rasters into the next tier asynchronously and swaps the (tier, layer) ref — momentary softness that self-corrects, the glyph-atlas loadingdone precedent. Promotion ends at the cap tier (recorded blur past it). Raster sources never promote (source resolution is their ceiling, as in v3).

    Exports re-raster: png()/jpg() raster visible SVG images at the export scale before encoding (the export path is already async), preserving the WYSIWYG guarantee at high scale.

  3. SDF icon mode — the glyph trick, generalized. A large class of node images (SBGN glyphs, icon sets) are monochrome silhouettes — glyph-shaped data. The per-image background-image-type: 'auto' | 'sdf-icon' (explicit, never sniffed — detecting "really monochrome" SVGs is fragile) sends sdf-icon sources through the glyph pipeline: one raster at 128², the glyph atlas's exact EDT, a single-channel r8 array layer (~16 KB vs ~1.3 MB for a 512² rgba mip chain), rendered by threshold + fwidth AA — crisp at every zoom with no promotion machinery — and tinted at render time by background-image-color (the label-color precedent), which makes icon color mapper-drivable.

    Recorded: a multi-color source in icon mode collapses to its alpha-thresholded silhouette in one color — well-defined, documented; full-color imagery belongs to auto.

  4. Multi-image parity. v3's image arrays port: up to 4 images per node (a fixed FS loop — cap recorded, the round-13 list discipline), composited in v3's layer order (the exact order is pinned against v3 in the live parity scene during implementation), each with its own per-image props. Per-node image lists are blob-pool records (the curve-blob/polygon pattern: packed per-image entries — registry ref + fit/position/ size/offset/repeat/flags/opacity/tint — with round-11 compaction), one packed offset|count ref column on nodes.

  5. Prop surface (14 of v3's 16, plus the two new props): background-image (URL / data-URI; list-capable), background-fit (none | contain | cover), background-image-opacity, background-position-x/-y, background-offset-x/-y, background-width/-height (auto | %/px), background-repeat (no-repeat | repeat-x | repeat-y | repeat), background-clip, background-image-containment (inside | over), background-image-smoothing (yes | no), background-image-crossorigin (anonymous | use-credentials | null), plus background-image-type and background-image-color (keyword sets and %-defaults are v3's, verified against v3 source at implementation).

    background-width/height-relative-to is not ported (one name per concept: leaves have no padding in v4, and a compound parent's stored size is already the padded box — matching v3's include-padding default; the inner variant is the unported spelling, recorded). Mapper rules (the 12b list discipline): list forms are constants-only; the single-image forms of background-image, background-image-opacity and background-image-color take mappers (data(key) URLs resolve through the ordinal-dictionary path — the icon-per-type pattern; case works as everywhere).

    All image props are draw-only paint evaluated on the CPU into the blob records — a mapped image channel does not join the GPU eval kernel (recorded scope note).

  6. Async loading policy. Images decode off the hot path (fetch + createImageBitmap, crossorigin per prop); a node whose image hasn't landed draws its other layers and self-corrects when the upload lands (dirty the touched slots — the late-font precedent). A failed load warns once per URL and renders imageless (recorded; no per-element error state). Headless instances parse/validate and store records with no raster (Node-testable); ghosts do not carry images (the A1 simplified-body rule, recorded).

  7. Geometry non-interaction + LOD. Images never grow boundingBox() (unclipped overflow is not in bb — consistent with the bounds-expansion drop) and never affect picking (the pick body stays the shape). The FS skips image sampling below the imageMinPx on-screen node size (default ~8 px; below ~3 px the plain-disc LOD already owns the pixel) — recorded.

  8. Bindings budget. Image sampling is FS-only: three rgba tier arrays + one r8 icon array + samplers ride the sampled-texture binding budget (16 per stage at base limits — separate from the 8-storage-buffer budget), and the image blob pool is one more FS storage buffer, rebalanced per the C3 split precedent if a layout overflows.

Pass split (tests-first per item; docs in-commit):

  • 15.0 Docs-first — landed with the design-sitting commit (0f0ee859): this plan section + the README pointer preceded all round-15 implementation.

  • 15.1 ImageRegistry + loader (2026-08-01) — src/image-registry.mts: entries dedup by (kind, crossorigin, url) with refcounts; freed ids recycle through a free-list and report to the renderer via takeFreed() (the layer reclaim channel); rgba tier assignment from the decoded longest side (128/512/1024, cap tier clamps); sdf-icon entries raster at the fixed SDF_IMAGE_SIZE and carry no rgba tier; decode runs behind an injectable async rasterizer (setDecoder kicks entries acquired headless — the mount path), failures warn once per url and stay failed (re-acquire never re-kicks), and a decode resolving after its entry was freed is dropped by object identity so recycled ids can never take stale rasters.

    promote(id, demandPx) re-rasters vector entries at the smallest covering tier (the 15.6 meter's primitive; raster sources and covered demands no-op). Tests-first: 10 specs in test/image-registry.mjs red then green — 2059 Node tests, typecheck + lint clean.

  • 15.2 Props + model (2026-08-01) — contract first: node.imageRef (offset | count << 24 into the new image-record pool — a third CurveBlob with round-11 compaction, relocations rewriting the ref column) + delta.imageBlob + ModelView.imageBlob()/images.

    GraphStore.setNodeImages packs IMG_STRIDE(12)-float records (entry id, mode flags, opacity, pos/offset/size values + unit bits, sdf tint at 2 bytes/float) and acquires new registry entries before releasing old ones, so shared urls never transit refcount 0 on restyle; the imageless fast path is one ref-column read; removeNode releases through the same call. Style: all 16 props parse/validate/read back (v3's keyword sets and defaults; per-image lists distribute last-value-repeats; relative-to throws as unported; image props are node-only), stored-truth readback reads the blob records (lists space-joined, the 12b convention). Mappers: the string-interning enum channelbackground-image compiles as an enum mapper whose parseEnum interns urls per compile (case thens, ordinal ranges and raw passthrough data values alike), covering both icon-per-type and photo-per-node; -image-opacity and -image-color are plain number/color channels; every other image prop rejects mappers (the 12b list rule). Tests-first: 17 specs in test/background-image.mjs red then green — 2076 Node tests, typecheck + lint clean.

  • 15.3 RGBA draw path (2026-08-01) — the tiered-array draw, in its own pass + pipeline: the node FS sits at exactly 8 storage buffers, so imaged nodes draw one extra instanced quad off the same culled visible lists (leaf stream right after the node bodies, parent stream right after the parent bodies — v3's layering), imageless instances collapsing in the VS and the whole pass skipped at store.imageCount() === 0.

    render/image-arrays.mts: per-tier texture_2d_arrays with full mip chains (blit-generated — WebGPU has no generateMipmaps), layers as slots (TierAllocator: free-list, doubling growth with live-mip copy-over, 256-layer base-limit cap warn-once), and the entry-indexed image table storage buffer (status/tier/layer + natural + raster dims) that gates sampling and scales UVs into partially-filled layers.

    The FS walks the blob records in list order compositing later-over-earlier, samples with textureSampleGrad (explicit gradients hoisted to uniform flow, so the per-record branching is legal), emulates smoothing: no by texel-center snapping, masks clip: node by the node SDF — containment inside clips at the border's inner edge (border stays visible; a translucent border shows fill, not image — recorded beside the B1 band rule), over at the shape boundary — and confines repeat tiles to the node box (recorded). clip: none rects grow the quad in the VS.

    The mirror gained the image blob's realloc/span twin; the browser decoder (render/image-decoder.mts: fetch + createImageBitmap, SVG via img + canvas at target size, decode-time downscale into the cap tier, crossorigin modes with null narrowed to same-origin — recorded) attaches at init and detaches on destroy. WGSL lesson re-hit and re-recorded: ref is reserved (the console-error guard caught it). Verifies: 6 Node specs (test/image-arrays.mjs, tests-first), the images-basic and images-cover-clip goldens, and parity-images vs v3 at 0.000% — fit/position/opacity math is pixel-exact. 2082 Node tests, 122/122 Playwright, typecheck + lint clean.

  • 15.4 Multi-image compositing (2026-08-01) — the 15.3 FS loop verified across full multi-image records: a Node spec pins per-image independence of every list prop at its index (fit / repeat / clip / containment / smoothing / type, four distinct registry entries), the images-multi golden pins four overlapping images with per-image sizes/positions/opacities and a half-translucent source (blend math), and parity-images-multi vs v3 at 0.000% pins the layer order — v3's canvas draws ascending index with source-over, so later list entries composite on top (not the CSS first-on-top convention; verified against v3's drawImages loop and now pixel-pinned).

    The cap-overflow warn landed in 15.2. 2083 Node tests, 124/124 Playwright, typecheck + lint clean.

  • 15.5 SDF icon mode (2026-08-01) — the glyph trick, generalized: sdf-icon sources raster once through the decoder's alpha-grid path (SVG via img + canvas, rasters via bitmap + canvas — a multi-color source collapses to its alpha silhouette; recorded), the glyph atlas's exact computeSdf EDT runs at upload, and the field lands in a dedicated r8 texture_2d_array (fixed 128², layers slot-allocated as tier index 3 in the shared TierAllocator, no mips — the field re-thresholds at any scale).

    The FS icon branch samples with the same explicit gradients and applies an analytic AA width (fwidth is illegal in the non-uniform record loop: coverage per screen px = sampled texels-per-px / SDF_RADIUS), tinting by the record's background-image-color — so icon color is mapper-drivable while the raster is shared. Pins: the images-sdf-icons golden (tint mapper, red + teal hearts from one SVG entry) and a programmatic crispness spec — at zoom 6 the sdf edge transition stays ≤ 2 px while the rgba path's 128px raster ramps ≥ 3 px (the same node restyled between exports, since background-image-type is constants-only — recorded). 2083 Node tests, 126/126 Playwright, typecheck + lint clean.

  • 15.6 SVG zoom-promotion + export re-raster (2026-08-01) — the demand meter: per unique vector entry, the max on-screen device-px demand among its shown, in-viewport user nodes (one scan over the imageRef column), debounced 250 ms behind viewport events and re-checked when fresh uploads land (a graph built zoomed-in promotes on arrival); demand > raster × 1.5 (the hysteresis — wheel jitter never thrashes) calls registry.promote, which snaps to the covering tier and clamps at the cap. No demotion — the round-11 waste policy is the eventual reclaimer (recorded simplification).

    Exports re-raster: exportImage promotes at the export view's zoomDpr (no viewport test) and awaits registry.whenSettled() (bounded 2 s; in-flight tracking landed in the registry with its own Node specs), syncing the fresh rasters before the export frame encodes. Fix fallout caught by the suite: the 15.5 crispness spec's rgba contrast switched to a raster square — the meter (correctly) sharpened its auto SVG.

    Pins: zoom 6 → rasterPx ≥ 512 + edge ramp ≤ 3 px after settle; png({ scale: 6 }) promotes and exports crisp while the screen never demanded it; the WYSIWYG self-diff gained an imaged phase (scale-1 exports still pixel-match the screen). 2084 Node tests, 128/128 Playwright, typecheck + lint clean.

  • 15.7 LOD + benchmark + true-up (2026-08-01) — imageMinPx (renderer option, default 8): the image VS collapses the quad when the node shows below the floor in displayed px (the labelMinPx semantics; export uniforms use the export scale — a figure's own resolution), so far-zoom scenes pay zero image sampling; pinned by a Playwright spec (no image ink at 20 px under a 30 px floor, ink appears at zoom 2).

    The renderer benchmark gained gen-25k-images (25k × 50k, four icon types via data.itype, styled through the ordinal url mapper on the gpu side and type selectors on v3; icon data-uris built at page runtime) — the scene is wired like its siblings; numbers were not recorded on this box (software adapter — a different machine class, per the benchmark's own warning). Correction (2026-08-03): that parenthesis is wrong for the same reason 18.5's was — this box has an AMD RX 580 and the benchmark reaches it (see the hardware validation pass below); the images scene simply has not been measured. The ordinal-url mapper form is Node-pinned. Final docs true-up in this commit.

    Round 15 is complete. 2085 Node tests, 129/129 Playwright, typecheck + lint clean.

Risks tracked: upload bursts on initial load (decode is already async; uploads coalesce per frame); WGSL non-uniform texture access (explicit-gradient sampling or sample-both-select — chosen at implementation, pinned by goldens); crossorigin/tainting differences between decode paths; registry leaks under style churn (refcount specs); multi-image FS cost (the benchmark item guards it).

Multiline labels and label bounding boxes

The multiline/label-bb round the parity triage kept deferring to — text-wrap and friends, plus the labels-in-bb call. All calls signed off 2026-08-01.

Signed-off design calls:

  1. Labels join boundingBox() and fit() by default (v3 parity — the most user-visible payoff: fit stops cropping labels). boundingBox(options?) gains an options object — { includeLabels: true } default, unknown keys throw — honored by element/collection bb, renderedBoundingBox, the store's whole-graph scan (no-arg fit/center), getFitViewport, animated fit:/center: targets and boundingBoxAt. Because label shaping is write-eager and memoized (it runs on text/font/wrap writes, never per frame — the model-space decision), node-label laid dims sit in the sidecar before any bb read: the store scan's node-label term is the anchored laid box (cheap and exact).

    Edge labels keep the dual tier: the scan uses a conservative anchor bound (chord midpoint / end-offset position ± block + margins + curve slack), public .bb() the exact anchor via the route evaluator. Goldens whose fits change regenerate once, in the landing item (recorded).

  2. The wrap family (v3 semantics; node labels, edge labels and the D4 end-label streams alike): text-wrap (none | wrap | ellipsis, default none), text-max-width (model px), line-height (multiplier, default 1), text-overflow-wrap (whitespace | anywhere), text-justification (auto | left | center | right, auto side-aware per v3). wrap honors embedded \n and breaks at text-max-width; ellipsis truncates with ; none keeps today's single line. All mapper-capable (CPU-evaluated, the label sidecar tier).

  3. Shaping stays CPU — memoized, write-driven. One pure module (extending label-layout.mts): breaker + justification + block metrics, keyed by (text, face, font-size, wrap, max-width, overflow-wrap, line-height); glyph runs rebuild only on shaping-input writes. The earlier design sketch of a GPU metrics pass is retired as unnecessary (recorded): shaping costs ~µs/label and runs on writes only; the offload slot stays logged if a profile ever disagrees.

  4. Renderer: multi-line glyph emission into the existing GlyphBuffer ranges (per-line x offsets by justification, y by line-height), the text background/border box takes the block extent, the text-valign/halign grid anchors the block, autorotate rotates the block as a unit, and the fade/min-zoomed-font-size cull predicates are unchanged (the block AABB grows the cull bound).

  5. The parked props' v4 forms (from the 2026-07-29 triage): box-select-labels becomes the core option boxSelectionIncludesLabels (default false, v3's default) — one more term in refsInBox off the same laid dims; text-metrics's v4 form is the public exact measure eles.labelBoundingBox() (laid block at the anchor, memoized) — an API, not a style prop.

Pass split (tests-first per item; docs in-commit):

  • 16.0 Docs-first — landed with the design-sitting commit (0f0ee859), before any round-16 implementation.

  • 16.1 Shaping engine (2026-08-01) — src/label-wrap.mts: breakLines (v3's text-wrap semantics — none collapses newlines, wrap honors \n + greedy word wrap with whitespace overflow vs anywhere mid-word splits, ellipsis truncates one line with '…'), layoutLabelBlock (lines stacked by lineHeight × em, justified inside the block, block centered about x = 0), and estimateBlock — the same breaking logic over flat per-char advances, which is what keeps the 16.4 label bb meaningful headless: the store estimates dims with no renderer, and rendered instances upgrade them to exact laid dims (a recorded approximation).

    Advances are injected, so one breaker serves both consumers by construction. 11 Node specs in test/label-wrap.mjs. 2096 Node tests, typecheck + lint clean. (The memo lands with the LabelLayer integration in 16.3, where the atlas-keyed cache lives.)

  • 16.2 Props + sidecar (2026-08-01) — the five wrap props parse/read back/map with v3's keyword sets and defaults (text-wrap none | wrap | ellipsis, text-max-width 9999, line-height 1, text-overflow-wrap whitespace | anywhere, text-justification auto | left | center | right); all five are mapper-capable (the sidecar tier), both label groups. The sidecar entry stores the resolved justification (auto folds against text-halign at write — v3's hanging-label rule; edges center) while style('text-justification') reads back the declared value incl. 'auto', as v3.

    Label dims live in the store (labelDimsAt/setLabelDims, per stream): setLabel estimates immediately via estimateBlock — the headless bb input — and the renderer's glyph build upgrades to exact laid dims (never marking label-dirty — no rebuild loop); dims changes bump the geometry epoch, since labels join bounding boxes in 16.4. label-wrap.mts moved to the gpu root (a dual-consumer module, the curve-geometry precedent). One historical pin updated: gpu-style's unsupported-prop example was text-wrap, which now exists — it pins background-blacken (dropped by decided design) instead.

    Tests-first: 10 specs in test/text-wrap-props.mjs red then green — 2106 Node tests, typecheck + lint clean.

  • 16.3 Renderer (2026-08-01) — LabelLayer lays every stream through layoutLabelBlock behind the shaping memo (keyed on text + scale-free wrap params, cleared with the atlas face — hit counters exposed for the 16.5 benchmark), feeds exact laid dims back to the store per build (the 16.4 bb term's upgrade path), and switched the alignment shifts + text-background box from ink extents to block metrics (advance width × line-stacked height — ink undershot multi-line blocks); the change stayed within the label goldens' tolerance, so no golden churn.

    Autorotate needed nothing: glyphs rotate about the anchor individually, so a multi-line block rotates as a unit by construction. Pins: the labels-wrap golden (three-line wrap under left/center/right justification via mappers, ellipsis truncation, unwrapped control) and labels-wrap-edge (a two-line autorotated edge label with its block-sized box). 2106 Node tests, 131/131 Playwright, typecheck + lint clean.

  • 16.4 Label bb (2026-08-01) — labels join boundingBox()/fit() by default: the options object ({ includeLabels }, unknown keys throw) rides collection bb, renderedBoundingBox and the store's whole-graph scan (no-arg fit/center/getFitViewport read it implicitly), and boundingBoxAt carries the node-relative label box to hypothetical positions (animated-layout fit targets cover labels).

    Terms: node labels are exactstore.nodeLabelBox places the laid (or headless-estimated) dims at the D3 anchor with halign/valign shifts, margins and the text-background padding (pad counts only when a box draws); edge labels are conservativeedgeLabelSlack is a block-covering radius (rotation-safe: width/2 + |margins| + vertical extent + pad + endOffset) grown about both endpoints, sound wherever the anchor lands on the drawn path (a recorded approximation; the exact per-anchor edge tier was not needed — fit may slightly over-fit, never under).

    eles.labelBoundingBox() is the public exact measure (the v4 form of v3's text-metrics surface): node labels at anchors, mid-labels at the drawn (curve-aware) midpoint, end labels via the endpoint radius. Headless dims are estimates (recorded — 16.1's estimator); rendered instances re-fit exact. No golden churn (goldens pin explicit viewports) and zero regressions across the 2116-test suite; the fit semantics are pinned headless in test/label-bb.mjs (10 specs, red first — incl. getFitViewport reading the label-inclusive box), which covers what the planned browser fit spec would have. 131/131 Playwright, typecheck + lint clean.

  • 16.5 Box-select labels + benchmark + true-up (2026-08-01) — boxSelectionIncludesLabels (ctor option + getter/setter, default false — v3's box-select-labels default): refsInBox additionally requires the node's label box inside the band; Node-pinned (label poking out excludes the node only when opted in; runtime toggle). Shaping cost swept (benchmark/labels.mjs, pure Node at 100k wrapped labels): breakLines ~3.8 µs, estimateBlock ~4.6 µs, the full setLabel-with-estimate write ~5.1 µs/label (write-driven, never per frame), and the whole-graph bb scan pays ~0.1 µs/label for its label terms.

    Memo hit-rate pinned in a webgpu spec: 120 same-text wrapped labels shape ≤ 3 times (stats().labelShapeHits/Misses). Final docs true-up (README round-16 section). Round 16 is complete. 2117 Node tests, 132/132 Playwright, typecheck + lint clean.

Risks tracked: golden churn confined to 16.4's one commit; whole-graph scan cost with the label term (two extra reads per labelled slot — benchmarked); long-text glyph counts (no new cap — glyph instances already scale; ellipsis is the bounding tool); edge-label conservative bounds vs autorotated blocks (reuse the D4 chord-slack machinery).

Event vocabulary and the extension contract

Two permanent-API calls made in one sitting: the v4 event names, and how extensions plug in. Both are cheap to build once decided; both gate ecosystem work.

Signed-off design calls:

  1. The curated vocabulary, plus the official pointer family. Adopted with v3 semantics (each firing rule pinned against v3 source in a red spec before implementation):

    • Drag-state (elements): grab, grabon, drag, free, freeon, dragfree, dragfreeon — the -on variants fire only on the directly grabbed element; the plain forms fire on every node the gesture moves (drag companions included); dragfree/dragfreeon only when the node actually moved.
    • Device-normalized: tapstart, tapdrag, tapdragover, tapdragout, tapend (element + core), tapselect/ tapunselect, cxtdragover/cxtdragout.
    • Viewport gestures (core): dragpan, scrollzoom, pinchzoom.
    • Pointer re-emits (element + core): pointerdown, pointermove, pointerup, pointercancel, pointerover, pointerout — the official DOM vocabulary v4's interaction layer already consumes, re-emitted with graph positions and originalEvent.

    Dropped, recorded: the vmouse* aliases (the tap* names are the normalized vocabulary) and the raw mouse/touch re-emits (mousedown/mousemove/mouseup/click, touchstart/...) — pointer* is their one modern spelling; the existing mouseover/mouseout emissions stay. event.preventDefault() stays unported (gesture defaults are gated by options/flags, not handlers; originalEvent keeps the DOM method) — recorded. All new element events bubble through the round-14.5 phase machinery.

  2. Extensions are direct objects — no registry. No cytoscape.use, no string registration, no global state: an extension is an import the app passes in (tree-shakeable, typed). Pass 1 designs the layout contract only; core/collection/ renderer extension points stay out (recorded: mappers + predicates cover the common cases; revisit on demand).

    • Shape: a layout impl implements { run(ctx): void | Promise<void>, stop?(): void }. cy.layout({ impl: Fcose, ...options }) (and eles.layout({ impl, ... })) construct and run it through the existing lifecycle — layoutstart/layoutready/layoutstop on the core, promiseOn, stop(), the animate/fit plumbing; { name } keeps addressing builtins.
    • LayoutContext (ctx) is columnar-first: slot-indexed reads (a positions view, node iteration pre-filtered to unlocked leaves per the round-14 rule, CSR adjacency, per-slot degree, the scoped element list for subset layouts, bb/viewport helpers, resolved options) and one bulk write — setPositions(slots, xy) on the round-5 slot path (one dirty span, listener-gated events) — plus the layoutPositions finisher (spacingFactor/transform/animate/fit, v3 plumbing). Handles stay reachable (ctx.eles) at handle cost; the contract makes the columnar path the obvious one.
    • Layout instances stay non-emitters (v4 layout events fire on the core — the round-10 rule, recorded).

Pass split (tests-first per item; docs in-commit):

  • 17.0 Docs-first — landed with the design-sitting commit (0f0ee859), before any round-17 implementation.

  • 17.1 Pointer re-emits + tap family (2026-08-01) — the official vocabulary lands: pointerdown (all buttons, the cxt branch included), pointermove (every move), pointerup/pointercancel, and pointerover/pointerout riding the hover transitions beside mouseover/mouseout; plus the device-normalized tapstart (primary press), tapdrag (moves while a press is active — the raw pointermove covers unpressed motion) and tapend (release of a press, ahead of the tap/selection flow — v3's up → tapend → tap ordering).

    Targets follow the press (the grabbed/cxt element) else the hovered element, background to the core; touch arrives through the same pointer handlers by construction. Pinned by a webgpu mouse-driver spec (hover-over/out, press-drag-release on the node and on the background). 2117 Node tests, 133/133 Playwright, typecheck + lint clean.

  • 17.2 Drag-state family (2026-08-01) — grab/grabon, drag, free/freeon, dragfree/dragfreeon with v3's firing rules: the -on variants fire only on the directly grabbed element; the plain forms fire on it and every selected companion in the drag set; drag fires per movement on all of them; the dragfree pair fires only when the gesture actually moved; a cancelled gesture frees without dragfree.

    Pinned red first in a webgpu mouse-driver spec: exact per-name counts on a two-selected-node drag (companion never gets -on), grab → drag → free ordering, and a moveless press grabbing/freeing without drag events. 2117 Node tests, 134/134 Playwright, typecheck + lint clean.

  • 17.3 Selection + hover-during-drag (2026-08-01) — tapselect/tapunselect fire on the tapped element beside its gesture-driven select/toggle-off (background clears and box selection keep their own events, as v3); tapdragover/ tapdragout and cxtdragover/cxtdragout ride a throttled synchronous node pick while a press is active — nodes only (the exact CPU pick; edges would need the async GPU tile — recorded), state cleared silently when the gesture ends.

    Spec lesson kept in-file: a panning background drag moves the content with the cursor, so nothing is ever crossed — the pin drags across the node under the box gesture (panning disabled) and under a cxt drag. 2117 Node tests, 135/135 Playwright, typecheck + lint clean.

  • 17.4 Viewport gesture events (2026-08-01) — dragpan (each applied background pan step), scrollzoom (each wheel zoom — trackpad pinches arrive as ctrl+wheel and take this path, the round-10 rule) and pinchzoom (each two-finger zoom step), all core-level with the gesture's model position. Pinned in a webgpu spec (wheel, background drag-pan, and a synthetic two-finger pinch — each firing its own name and not the others'). 2117 Node tests, 136/136 Playwright, typecheck + lint clean.

  • 17.5 The layout contract (2026-08-01) — layout/contract.mts: cy.layout({ impl, ...opts }) (and eles.layout) runs a user class (constructed argless) or object implementing { run(ctx), stop?() }no registry, no cytoscape.use, no global state. run may return a promise (the GPU-layout shape); the wrapper exposes promise() and drives the core lifecycle exactly once per run whether the impl uses the discrete finisher (ctx.layoutPositions(fn) — the full v3 plumbing, its layoutstart folded into the wrapper's via an internal flag) or the direct bulk path (ctx.setPositions on the round-5 slot path).

    The LayoutContext is columnar-first: nodeSlots() (scope order, pre-filtered to unlocked leaves — the 14.11 rule), live positions()/endpoints() views, O(1) degreeOf off CSR, edgeSlots(), scope bb + viewport dims, ctx.options carrying custom knobs, with handles reachable at ctx.eles/ctx.nodes. Layout instances stay non-emitters (round-10 rule; events fire on the core with the wrapper as event.layout).

    Tests-first: 10 specs in test/layout-contract.mjs red then green — object + class impls, single-lifecycle finisher, async run, scoping, the leaf/unlocked filter, columnar reads, stop(), malformed rejects, and the random builtin re-expressed through the public contract (the conformance shape external authors can crib). Two error-message pins updated for the new layout dispatch text. 2127 Node tests, typecheck + lint clean.

  • 17.6 Example + true-up (2026-08-01) — debug gained the worked example: SpiralLayout, a plain class run via cy.layout({ impl: SpiralLayout }) with ?layout=spiral (smoke-verified live in scripted Chromium: spiral positions, no page errors). README gained the round-17 section (the curated vocabulary with its recorded drops + the direct-object contract). Round 17 is complete. 2127 Node tests, 136/136 Playwright, typecheck + lint clean.

Risks tracked: name-semantics divergence from v3 (red specs against v3-source readings per event, before implementation); emit volume on drag hot paths (all listener-gated; the 17.2 specs assert the no-listener fast path stays allocation-free); contract surface creep (pass 1 exposes only what random-via-contract and an fcose-shaped consumer demand).

GPU force layout

The last queue pillar: the round-9 "GPU layouts: logged for later" design, built. Signed off 2026-08-01.

Signed-off design calls:

  1. A new GPU-native layout, force — not a cose port (v3's cose stays in v3: its option surface and per-iteration structure are CPU-shaped, and ports arrive later via the round-17 contract). The model: spring attraction along edges toward edgeLength, short-range repulsion via a uniform-grid cutoff (grid rebuilt per iteration by counting sort — the stream-compaction discipline — repulsion gathered over the 3×3 cell neighborhood), a weak centering gravity that keeps disconnected components in frame, velocity integration with alpha cooling, and seeded deterministic initial scatter (id-hash, the haystack precedent).

    Force accumulation is gather-only — no atomics — so a run is deterministic on a given executor (fixed reduction order).

  2. Ownership: GPU-authoritative with readback on settle — the round-9 logged design. During a run the position column is GPU-owned under the existing lease machinery (mirror skips uploads; CPU reads stale per the motion-staleness rule); the sim integrates in its own pre-cull pass so cull/edges/labels read live positions and the graph renders live every frame — the watchable-layout-at-100k showpiece. On convergence (max displacement < ε for K consecutive iterations) or stop(), one readback settles the CPU columns — the sole readback exception in the architecture, per the round-9 call — then derived geometry flushes and layoutstop fires.

  3. The CPU reference is the spec. A complete CPU implementation (same options, same grid/cutoff math) runs headless instances and is what the Node specs pin (seeded runs to fixed coordinates on small graphs, energy decay under cooling, convergence, locked-node pinning). CPU and GPU trajectories are not bit-agreed (recorded — parallel FP reduction order differs): GPU correctness pins invariants instead — no NaN/exploded positions, displacement decay, seeded summary statistics (edge length distribution, bb extents) within tolerance of the CPU run.

  4. Demotions and scoping (the 14.11 pattern): compound graphs run the CPU executor (a GPU lease would leave the auto-bounds derivation reading stale positions; leaves simulate, parents derive per flush). Locked nodes pin (skip integration). Subset layouts (eles.layout) simulate the subset only; non-members are inert (recorded). Flat graphs at scale — the perf case — take the GPU path.

  5. Options surface (minimal, consumed identically by both executors): edgeLength (number, or a plain function evaluated once into a per-edge column at start — the algorithms-round rule), repulsion, gravity, decay, iterations (cap), threshold (ε), seed, randomize (fresh seeded scatter vs current positions), animate (true live | false settle-then-draw), fit/padding.

Pass split (tests-first per item; docs in-commit):

  • 18.0 Docs-first — landed with the design-sitting commit (0f0ee859), before any round-18 implementation.

  • 18.1 CPU reference (2026-08-01) — layout/force-sim.mts, pure and slot-indexed: uniform-grid cutoff repulsion (counting-sort rebuild per iteration; stable ascending order inside cells — the deterministic gather order both executors share), springs off CSR-style incident lists, centering gravity, and pure damped gradient integration (F · alpha per step, no velocity state — no ringing, one less GPU buffer, and displacement tracks force so the threshold settle is robust; velocity integration was tried and dropped for exactly the ringing-trips-the-settle failure). Forces gather into a scratch and apply in a second pass (the kernel's two-dispatch structure).

    Model calls made empirically, both recorded: the repulsion cutoff is the mean ideal edge length — repulsion vanishes exactly where a spring rests, so a connected pair's equilibrium is L itself (cutoff 2L left it at 1.7L); and a cutoff model does not promise global untangling — a curled chain is a legitimate local minimum (sfdp-style multilevel is future work). Coincident points separate along a deterministic index-hash direction (no NaNs on degenerate input).

    Tests-first: 8 specs in test/force-sim.mjs — seeded determinism, identical-run reproducibility, spring rest length, repulsion separation, gravity containment, cooling/convergence, pinning, and the path-relaxation invariants. 2135 Node tests, typecheck + lint clean.

  • 18.2 Layout plumbing (2026-08-01) — layout/force.mts: cy.layout({ name: 'force' }) wraps ForceLayoutImpl in the round-17 CustomLayout plumbing — the contract's first production consumer (an external layout would ship identical code).

    Options: edgeLength (number or a plain fn of the edge handle, resolved once — the algorithms rule), the sim params (repulsion/stiffness/gravity/decay/iterations/threshold), seed/randomize (fresh deterministic scatter vs relaxing current positions; pinned nodes keep real coordinates either way), animate (live streaming per frame through the bulk slot path — which, as recorded, emits no per-node position events — vs settle-then-draw), stepsPerFrame, fit/padding. Scoping: leaves only (parents derive); locked nodes pin — they join every force pair but never move; subset scopes simulate the subset only (recorded). stop() settles early through the wrapper.

    Tests-first: 7 specs in test/force-layout.mjs red then green — lifecycle + ring relaxation + fit, seeded determinism end-to-end, fn edge lengths, locked pinning, compound leaves-only, subset scoping, live streaming + stop. 2142 Node tests, typecheck + lint clean.

  • 18.3 GPU kernels (2026-08-01) — render/gpu-force.mts: six dispatches per iteration (clear grid → bin count → serial exclusive scan → scatter → force gather → apply), sim-indexed with apply publishing movable nodes into the slot-indexed mirror position buffer — encoded ahead of the cull pass, so edges/labels follow live; node.position rides the tween-lease ownership (mirror skips its uploads; the frame loop keeps its clock while a run is live).

    Binding-budget lesson re-hit on compute: three shared bind groups totalled 16 storage buffers (the console guard caught it) — each kernel now carries its own group with exactly its buffers, the hot gather packing inputs (CSR as one [starts][entries] buffer; edges at stride 3 with bitcast lengths; the pin flag on bit 31 of the slot map; the alpha window + tick + displacement max sharing one atomic meta buffer) to land the force kernel at exactly 8. WGSL lesson #3: meta is reserved too.

    Alpha annealing pre-computes a 64-iteration window per frame indexed by a device tick (any k iterations per submit, no per-iteration uniform writes); convergence rides an atomicMax over monotonic f32 bits with a 4-byte latest-wins staging poll; readPositions() is the one settle readback (round 9), after which the layout writes the CPU columns through the normal dirty-span path. Recorded narrowing: the scatter's atomic in-cell order means GPU trajectories aren't bit-stable run-to-run — seeded reproducibility is the CPU executor's guarantee.

    Pinned on a real adapter: a provably-long run holds the lease (CPU position() stale mid-run while pixels advance), stop() settles real simulated coordinates, and the ring spreads. 2142 Node tests, 138/138 Playwright, typecheck + lint clean.

  • 18.4 Convergence + readback (2026-08-01) — the batched displacement reduction, latest-wins staging poll, settle readback and lease-release-before-CPU-write ordering all landed with 18.3; this item adds the invariant parity suite: on a seeded ring-with-chords graph, the CPU executor (animate: false) and the GPU executor (animate: true) run the same options and must agree on invariants — zero NaN, every node in frame, mean link length within [0.6×, 1.7×] of each other, bb width within [0.4×, 2.5×] — while trajectories stay deliberately not bit-agreed (recorded).

    The settled bb also pins flushDerived + layoutstop ordering (the box reflects the readback coordinates). 2142 Node tests, 138/138 Playwright, typecheck + lint clean.

  • 18.5 Benchmarks + harness + true-up (2026-08-01) — debug/?layout=force (+ &seed=N) runs the live layout in the harness (smoke-verified twice in scripted Chromium: zero page errors, identical settled extents run-to-run; an earlier error burst traced to racing a mid-write bundle on the static server, not the code). The renderer benchmark gained -- --layout: instead of the pan scenarios, each scene runs a live force to convergence on the gpu side (wall time + fps from renderer stats) with v3's cose as the classic baseline — layout quality differs by design; the numbers compare the interactive experience.

    Numbers recorded 2026-08-01 on real hardware — see "Landed (hardware validation pass)" at the end of this file, which also corrects this item's original "software adapter on this box" assumption. README gained the round-18 section and the round-9 "GPU layouts: logged" design bullet is trued up (since built). Round 18 is complete. 2142 Node tests, 138/138 Playwright, typecheck + lint clean.

Risks tracked: pathological densities collapsing the grid (all nodes in one cell → O(n²) gather; cell-capacity clamp + jittered seeds, recorded); convergence-check cost (batched reduction); readback vs in-flight frames (reuse the pick-ring discipline); executor parameter drift (all constants resolved once, shared by both executors); interaction mid-run (grab during a layout follows the animation rule — grabbing is forbidden while an element's position is leased).

Hardware validation pass — AMD RX 580

The first full benchmark run of the prototype on real hardware: Radeon RX 580 (RADV, amd gcn-4) on an i9-9900K under Linux, headless Chromium with the repo's platform-gated ANGLE-on-Vulkan flags. Corrections first:

  • The 18.5 "software adapter on this box" note was wrong — headless Chromium offers the hardware adapter with the same flags playwright.config.js uses. The trap that produced the earlier conclusion: requestAdapter() returns null on about:blank, so a bare-page probe reads as "no GPU"; the benchmark's own probe runs on its served page and gets the real adapter.

  • The --layout mode was intractable as landed (it had only ever been smoke-tested): cose's per-iteration cost is superlinear — ~4.5 s/iteration at 25k × 50k, ~52 min for a single iteration at 100k × 300k — so the numIter: 300 baseline hung the suite for hours. Fixed in b7ea7068 with nested test-style timeouts (in-page 30 s polite stop reporting a measured floor + 60 s runner-side hard bail that force-closes the wedged page and reports "> 60 s"; --layout-uncapped removes both).

    Two starvation findings recorded in that commit: setTimeout runs minutes late under cose's synchronous iteration blocks, and even a rAF watchdog only runs at paint time (first paint 70 s after run() at 25k with refresh: 1), so the hard bail is the only reliable bound.

Numbers (dpr 2, 1280×800, adaptive render scale pinned to 1; wall times are vsync-bound at 60 Hz, so 16.7 ms is the floor):

  • Pan steady state: v4 holds the vsync floor on every generated scene and view — 25k and 100k flat, curved (bezier pairs), compound (1k parents), images, labels on and off — while v3 canvas runs ~230–4200 ms/frame on the same content (25k fit-all 633 ms → 16.7 ms; 100k fit-all 3693 ms → 16.7 ms). ndex-x-large (465k edges) is the one scene above the floor: 33.4 ms wall (2 vsync frames).
  • Device time (timestamp-query, the unbounded metric): the worst generated-scene pass is 19.6 ms (100k zoomed-in, labels); ndex fit-all ~37 ms is the only GPU-bound case — with the adaptive render scale deliberately pinned off, which production defaults would not do. Labels add +0.2–1 ms per pass; the compound scene's parent stream costs ~nothing (2.0 ms fit-all).
  • Init: v4 246 ms–1.7 s vs v3 2.6–19.2 s per scene (10–20×).
  • Picks under continuous pan: p50 17–19 ms; 4–5 of 25 requests return null. Flagged for a look — resolved by the pick-ring look below: the nulls were background answers, not staging-ring drops (the scenario holds at most one pick in flight, so the 3-slot ring cannot exhaust — the attribution here was wrong), and the drop-on-exhaustion policy itself is gone (a full ring now defers the request a frame instead).
  • Live layout (--layout): v4 force converges in 697 ms (25k), 1472 ms (100k) and 952 ms (ndex) on the GPU executor; the compound scene settles in 15.5 s on the CPU executor (the 14.11 lease rule). v3 cose reports "> 60 s — bailed" on every scene; measured floors from the pre-fix runs: 67 s at 25k, 3169 s at 100k.

The pick-ring look

The hardware pass flagged its pick numbers — 4–5 of 25 hover-while-panning requests returning null, attributed to staging-ring exhaustion — for a look. The look found the attribution wrong, and a latent policy wart behind the phrasing it leaned on:

  • The nulls were background answers, not drops. The benchmark's pick scenario holds at most one pick in flight (a new cy.pick() is only issued once the previous one resolved, with a 120 ms gap), and one logical request consumes at most one ring slot — so the 3-slot staging ring cannot exhaust under that driver. The nulls are genuine background answers: at fit-all the five probe points (0.3–0.7 along the diagonal) mostly sample empty space between hairline edges, and far-zoom decimation additionally makes sub-half-alpha edges unpickable (the recorded deviation).

    The scenario's own comment admitted the ambiguity ("background answer or a dropped request — the API can't tell them apart"); the hardware-pass note picked the wrong branch.

  • Drop-on-exhaustion is gone; a full ring defers instead. The old policy resolved requests null when no staging buffer was free — and the frame had already encoded and submitted the full pick cull + draw pass before encodeCopy threw the copy away. Now the frame checks hasFreeSlot() before encoding anything: a saturated ring skips the pick pass entirely and leaves the request pending (still coalescing latest-wins), and the frame loop's existing hasPending() reschedule retries it — a slot frees as soon as the oldest readback maps, so the extra latency is bounded by in-flight GPU work (~1–2 frames).

    A pick now resolves null only for background, destroy, or device loss — spurious nulls are structurally impossible, which also makes the benchmark's nulls count unambiguous (background only).

  • Saturation is observable: renderer().stats().pickDeferrals counts frames that found the ring full and deferred; the pick scenario reports it per run (N background, M ring-deferred).

  • Confirmed on the hardware-pass box (RX 580, same config): the pick scenario on the four 25k scenes (flat, curved, compound, images) reports 4/4/5/5 background answers and 0 ring-deferred on every scene at p50 16.9–18.1 ms — the same numbers the hardware pass recorded, now with the null counts attributed correctly.

  • Tests: test/modules/picking.mjs unit-tests the ring against a fake device (latest-wins coalescing; exhaustion defers — the request survives the full ring unresolved, acquires the next freed slot, and resolves with a real answer; destroy resolves null), seen red under the drop policy first. A webgpu Playwright spec saturates picks across frames over an edge (pan-jiggled so every request misses the cache) and asserts none resolve null.

Slot-moving compaction

The last open architectural item ("Logged — compaction" above): move live element slots so highWater, column capacity and pass-iteration widths shrink after big removals (the shrink profile) and sustained churn (the free-list keeps tables from growing, but peak-sized scans and dispatches persist). The slot-stable tier (id blob, CSR, dictionaries) has self-compacted since round 11; this round moves the slots themselves.

The three policy calls, decided with the user (2026-08-01):

  1. Ref survival: forwarding + lazy repair. A per-group forwarding table maps a moved element's old (slot, gen) to its new slot; refs repair lazily on access, and a store-wide compaction epoch invalidates cached packed-int membership sets. User-held collections keep working — the interned-singleton invariant and the hold-a-query-result app pattern survive, with the cost paid only on first post-compaction access. (Rejected: handles-survive-collections-stale, everything-stale — both make compaction an app-visible event, which an auto trigger cannot afford.)
  2. Trigger: auto threshold + explicit cy.compact(). Auto on a dead-slot-ratio threshold (the round-11 waste-over-half policy with a floor, metered per group), gated to safe boundaries — never mid-batch, mid-emit, mid-frame-encode, or while a GPU force run or GPU-offloaded tween is live (live tweens settle first via the round-14.11 settleGpuAll precedent, or the check defers to the next safe boundary). cy.compact() is also public for deterministic timing (throws mid-batch; defers with a warn while a force run is live). The shrink profile is exactly where apps won't know to call compact() — auto is the user-serving default.
  3. Draw order: stable — a visual no-op. Compaction preserves the current relative slot order.

The load-bearing consequence: the remap is monotone. Moving live slots down in ascending order (each live slot drops to the lowest free position below it) preserves relative order by construction, which is what makes call 3 free — and it is also what keeps derived curve geometry identical: bundle rank (bundleOffset over the sorted bundle), loop stagger index, and the σ orientation sign all derive from relative slot order (curve-index.mts), so a monotone remap leaves every derived curve param byte-identical and no pair re-derivation is needed. CSR per-node incident order (insertion order) and the cpu-pick z-rule (topmost = highest slot) are likewise unchanged.

A non-monotone remap would silently change z-order and curve geometry; the implementation asserts monotonicity in dev.

Remap inventory (surveyed 2026-08-01; classification per structure): the store tables permute per column (arrays, gen; free clears; highWater shrinks to the live count; capacity shrinks realloc the columns); edge.endpoints is the one column holding cross-group slots (a node remap rewrites it wholesale); the id index fuses the permutation into its compactBlob walk + full probe rehash; CSR rebuilds via the round-11 Adjacency.rebuild; hierarchy permutes parent/parentGen/depth and rebuilds children, nulling the parent draw permutation (renderer re-uploads on identity change); the curve index permutes its per-edge records and rebuilds pairs/loops keys (params untouched — monotone); the three blobs permute their slot-indexed offset tables (pools are offset-space); the data sidecar permutes values/present/ indices in place (bound mapper evaluators close over those buffers by reference — the dict-remap precedent); label sidecar entries/dims/dirty rekey per stream; the misc slot-keyed maps (opacityBase, parentFallback, compoundStyle, resolvedPad) rekey; geoEpoch bumps (edge-bb memo); monotone maxima recompute exactly (a compaction is the natural moment); mapper spans re-emit whole-column.

Renderer: both groups' resized flags are set — the existing paths do the rest (mirror full realloc + re-upload — a capacity change already forces it — pick-cache invalidation, cull meta rewrite); the mapper runtime reconfigures (region layout is capacity-aligned); glyph streams rebuild owner words via markAllLabelsDirty + process (the re-raster path); GPU tween channels re-register their slot buffers after the settle; ChannelWrite.slots/animation-queue packRef keys rebuild.

Items (tests-first, one isolated commit each):

  • 19.1 — store-core compaction. ColumnTable.compact(perm) + GraphStore.compact(group?): the monotone permutation build, column moves with capacity shrink, order-list fusion (compactOrder already drops tombstones), id-index fusion, edge.endpoints rewrite on node moves, CSR rebuild, dirty/resized signaling, and the vacated-tail zeroing (tombstoned flags). Node specs: state identity pre/post (ids, data, positions, flags, adjacency, ordering), highWater === count, shrink-profile capacity actually falls, idempotence (compacting a compact store is a no-op).

  • 19.2 — dependent store indexes. Hierarchy, curve index + blobs, data sidecar in-place permute, label sidecar, misc maps, epochs/maxima/spans. Node specs: compound geometry, curve accessors (controlPoints/midpoint/boundingBox) and style readback byte-identical pre/post; blob record integrity; mapped channels re-evaluate correctly after the permute.

  • 19.3 — ref forwarding + lazy repair. The forwarding table (packed (group, oldSlot, oldGen) → newSlot), gen handling for vacated slots so stale refs fail plain validation and route to repair, isCurrent/_eleFromRef repair paths that rewrite the Ref in place (repairing every holder of that object), handle-pool permutation with _refs[0] rewrite (scratch survives), the membership-set epoch, packRef-keyed animation queues, event listener re-keying ('ref:' qualifiers), forwarding-chain composition across consecutive compactions.

    Node specs: held collections and handles keep answering across a compaction (id/data/position/traversals), removed refs stay dead, membership caches invalidate, off() by handle still matches.

  • 19.4 — renderer integration. The resized handshake, mapper reconfigure, glyph rebuild, parent-permutation re-upload, pick invalidation, GPU-tween settle + re-register, force-run gating. Playwright: pixel self-diff pre/post compaction on a styled scene (labels, curves, compounds, images — a visual no-op by assertion), pick correctness post-compaction, a mid-animation compaction settles and completes correctly.

  • 19.5 — triggers, API, meters, benchmarks, docs. The auto threshold (dead-ratio > 1/2 with floor, per group) at safe boundaries, cy.compact() (+ the mid-batch throw and live-run deferral), stats()/store meters for observability, a benchmark/ shrink/churn sweep (peak-then-small scan widths, dispatch counts, memory before/after), and the README section (design decision + deviations note) with the "Logged — compaction" closure.

Recorded limits (pass 1): compaction never runs concurrently with a live GPU force run (defer, not settle — the sim owns positions); cy.compact() inside a batch throws; a compaction mid-animation settles GPU-offloaded tweens to the CPU first (CPU tweens remap and continue); forwarding tables persist until the next compaction and compose, so repair is total for any ref the app ever re-touches.

Landed

All five items of the round-19 plan above landed the same day, each tests-first in its own commit; the three design calls held as decided and one plan deviation is recorded below.

  • 19.1 — store core. ColumnTable.compact(remap, newCount) + GraphStore.compact(): the monotone remap builds from FLAG_ALIVE (live slots move down in ascending order — relative order preserved by construction), columns and the gen array rebuild into right-sized (×2-step) buffers, highWater and capacity drop to the live count, the free-list clears. Generation rule: identity slots keep their gens (refs to the stable prefix stay valid with zero repair); every changed position takes oldGenAt(pos) + 1, strictly greater than any gen ever handed out there, so all stale refs fail plain validation and route to forwarding. edge.endpoints (the one cross-group slot column) rewrites on node moves; the id index fuses the permutation into its meta walk + a full probe rehash; CSR rebuilds via the round-11 path; the order list fuses against the pre-move gen snapshot; resized marks hand the renderer its existing realloc + full re-upload.

  • 19.2 — dependent indexes. Hierarchy (links slot-indexed and slot-valued; parentGen re-stamps against post-move gens; child link order kept; draw permutation regenerates), curve index (styled records permute; node-keyed pair/loop maps rebuild from the rewritten endpoints; derived params byte-identical with no re-derivation — monotone keeps bundle rank/stagger/σ), the three blob offset tables, the data sidecar in place (bound mapper evaluators hold the buffers by reference), label entries/dims/dirty, opacityBase/parentFallback, whole-column mapper span re-emits, and markAllLabelsDirty as the glyph-rebuild feed.

    Plan deviation, recorded: the conservative monotone maxima are not recomputed at compaction — they stay monotone (sound; slack can only be loose), and exact recomputation would need per-kind record decoding for little benefit.

  • 19.3 — ref forwarding + lazy repair. Per-group forwarding chains (packed (slot, gen) → (newSlot, newGen)) that persist and compose; isCurrent() repairs a forwarded ref in place before answering (one gen compare on the fast path; removed elements stay dead). Collection._refs became an epoch-guarded accessor — one chokepoint syncs all ~115 consumers and drops the packed membership cache (materializer sweep unchanged). cy._compact() permutes the interned handle pool (handle identity + scratch survive), repairs and re-keys element-bound listener qualifiers (off() through fresh handles matches), and re-keys animation queues with slot lists re-pointed.

  • 19.4 — renderer. Two real gaps closed: glyph streams clear wholesale on the compaction epoch (owner slots are baked into instances; incremental rebuild could alias a stale run onto a different element's new slot), and mid-flight GPU tweens demote to the CPU (write the reached value, unregister, finish on repaired slots — demoteGpuAll, unlike the reparent path's early-finishing settleGpuAll). A live GPU force run defers compaction (Renderer.forceActive). Everything else rides existing machinery: resized → mirror capacity-aware realloc + pick-cache invalidation; mapper regions rebuild; parent permutation re-uploads.

    Browser specs pin the visual no-op byte-identically (labels + bezier bundle + compound + selection), post-compaction picking, and a mid-flight animation completing at target.

  • 19.5 — triggers + API + sweep. Auto trigger (dead > live count past a 1024-slot floor) at the safe boundaries (completed remove; outermost endBatch), deferring silently while batching or under a force run; public cy.compact() (throws mid-batch, warns + defers under force).

    benchmark/compaction.mjs (200k peak → 10%, i9-9900K), extended into a four-section sweep (wins / costs / forwarding hot path / honesty controls): compact() ~114 ms one-shot, and the auto trigger adds it to a removal whose own cascade + emits cost ~1.8 s (~6% overhead; store-level removal without the trigger is ~0.7 s); held-collection first-touch repair of 20k moved refs ~0.5 ms; CPU pick 2.15 → 0.39 ms miss (~5.5×); cull dispatch width 200k → 20k lanes per group per frame (edges 400k → 0); column memory 37 → 4.6 MiB (nodes), 76 → 0 MiB (edges).

    Forwarding is free on the hot path (isCurrent on a current ref 1.01× with ~180k forward entries present; a stale chase + rewrite ~40 ns once per ref), and the controls confirm order-list scans / whole-graph bounds are ≈parity (1.1–1.2×) — compaction changes exactly what the design said it would.

  • 19.5b — the device side, measured. The renderer bench gained a gpu-only compaction scenario (cut to ~10% live through the store — eles.remove() would auto-compact the peak state it exists to measure — pan at peak slot widths, cy.compact(), pan again) plus a --gpu-only runner flag for gpu-vs-gpu scenarios. On the RX 580: wall time holds the vsync floor on both sides (a 10%-live scene is already fast), while the unbounded GPU pass isolates the dead-lane overhead — 10k live nodes panned over 100k + 300k peak lanes cost 2.2 ms/frame of device time, 0.5 ms once compacted (4.4×; ndex 1.4 → 0.9 ms); in-browser compact() is a ~57–62 ms one-shot at those scales.

Verification: 28 store-level + 9 ref-level + 5 trigger Node specs (all seen red first), the full Node suite (2175), and the webgpu + visual Playwright projects (143 specs — goldens and live v3 parity untouched). With this round the "Follow-up hooks" list in src/README.md holds no open architecture items.

Interaction options and touch parity

With the architecture queue closed (round 19), round 20 takes the largest remaining "needs a call" cluster: gap item 8 — the interaction tuning options and the touch gestures v4 still lacks. Everything here is app-facing parity work; the option names and prop semantics are permanent API, so the calls are made deliberately up front (the round-17 discipline).

Signed-off design calls:

  1. The option quartet is core-level, with getter/setters. v3 buries wheelSensitivity, desktopTapThreshold and touchTapThreshold in renderer options and hardcodes tapholdDuration = 500; v4 has no renderer-option surface for interaction (the renderer block is GPU tuning), so all four are constructor options with multiClickDebounceTime-style getter/setters — readable and settable at runtime, validated (throw on non-finite/negative; wheelSensitivity must be > 0), live-read by the pointer layer (no re-init).

    Defaults are v3's: wheelSensitivity: 1 (a multiplier on the wheel-zoom exponent — v4's base rate is unchanged), desktopTapThreshold: 4, touchTapThreshold: 8 (css px of movement before a press stops being a tap; v4 previously used 4 for all pointer types), tapholdDuration: 500 ms (v4 makes v3's constant configurable — the one deliberate surface addition, logged in the gap list). v3's console warning on a custom wheelSensitivity is kept verbatim (the hardware-variance advice is as true under WebGPU; emitted once per instance, from the setter or ctor).

  2. events is a style prop compiled to a flag bit. v3's events: 'yes' | 'no' ports to both element groups (default 'yes'), constants or case mappers (CPU-evaluated — a flag write, like every non-paint channel).

    The engine maintains a new store-managed FLAG_NO_EVENTS bit; every pointer path excludes flagged elements by reading the one bit: the CPU node pick (grab/tap targeting, hover, tapdragover), the GPU edge pick tile (the cull kernels gain a pickMode Frame field and drop flagged edges in pick mode only — scene draws are untouched: events: no elements still render), and the box-selection gesture (v3's getAllInBox runs over the interactive set, so events: no elements are not box-selectable; the gesture filters, while cy.elementsInBox() stays a pure geometric query — a recorded scope note). interactive() becomes visible() && events !== 'no'.

    An events flag change invalidates the pick-tile cache (it changes pick answers, not pixels).

  3. text-events is node-only in v4. v3's default is 'no' (labels are pointer-transparent), which v4 already matches; the port makes 'yes' mean the node's label box is part of the node for picking — the CPU pick tests the exact laid label block at its D3 anchor (the round-16 dims; node labels never rotate, so the test is an AABB in model space) after the shape test misses. Constants or case mappers, FLAG_TEXT_EVENTS. Edge labels stay unpickable whatever the prop says (edges pick through the GPU tile, which draws edge geometry only; the label quads are a different stream — a recorded deviation, consistent with the round-10 "labels are not pickable" rule).

    The label bb term already rides boundingBox({ includeLabels }), so no bounds work.

  4. Touch gestures port v3's rules verbatim. Two-finger cxt: a second finger landing within 200 css px of the first starts the cxt gesture — cxttapstart on the node under finger 1 (else finger 2, else the core; the synchronous CPU pick), cxtdrag (+ cxtdragover/cxtdragout) while the pair moves, cancelling into a pinch when the finger distance grows past 1.5× or 150 px (cxttapend fires, then the pinch machinery takes over), cxttapend + cxttap (when never dragged) on release. A two-finger press farther than 200 px apart pinches immediately (v3's threshold).

    Three-finger box: with boxSelectionEnabled, three fingers select — the box spans the start centroid to the moving centroid (v3's (f1+f2+f3)/3 corners), boxstart on the first move, applied through the existing box flow (boxend / box / boxselect + the round-16.5 label containment option) when the third finger lifts; a gesture that boxed never degrades to a pinch (v3's didSelect latch). Both gestures ride the existing pointer-event handlers (v4 has no touch-event path by design).

  5. Closed or deferred without building: pixelRatio turned out to be already landed (ctor option, 'auto' | number, plumbed to the renderer's dpr — this round adds the missing spec + docs and records it); a box-selection overlap mode is not v3 surface (v3 selects by containment) and is deferred as a demand-gated hook — the logged shape is a boxSelectionMode: 'contain' | 'overlap' core option whose overlap test is bb-intersect for nodes and segment/route-vs-rect for edges (the cull pass already owns that math).

Pass split (tests-first per item; docs in-commit):

  • 20.0 Docs-first (2026-08-01) — this plan section; gap item 8 marked scoped.

  • 20.1 The option quartet (2026-08-01) — wheelSensitivity, desktopTapThreshold, touchTapThreshold, tapholdDuration: ctor options + validated getter/setters on the core (throws on non-finite/negative; wheelSensitivity must be > 0 and keeps v3's once-per-instance warning on non-default values, from ctor or setter), read live by the pointer layer — the wheel exponent gains the multiplier (base rate unchanged), press-move thresholds resolve per event by pointer type (touch 8 / desktop 4 — v4 previously used 4 for both), and the taphold timer takes the configured duration.

    Tests-first: 4 Node specs (test/interaction-options.mjs, red then green) for the option surface incl. the warn-once rule, and a webgpu Playwright spec pinning behavior — sensitivity 2 doubles the zoom log-ratio of an identical wheel tick; a 6 px desktop press-move drags at threshold 4 and taps (position unmoved, tap fired) at threshold 10; a 350 ms hold fires no taphold at duration 5000 and fires it at 150. 2179 Node tests, typecheck + lint clean.

  • 20.2 events (2026-08-01) — the prop lands exactly as called: an enum channel on both groups (constants or case mappers) whose write() maintains FLAG_NO_EVENTS; the CPU node pick scans past flagged slots (grab/hover/tap fall through to what's beneath), the Frame uniform grew a pickMode field (18 floats, one struct for every pass; scene/export leave it 0) and both edge cull kernels drop flagged edges in pick mode only; the box gesture filters to interactive() (which now folds the flag); the flags-column dirty span already invalidates the pick-tile cache (setFlag no-ops on unchanged bits, so restyles don't churn it).

    Tests-first: 6 Node specs (test/events-prop.mjs, red then green — defaults, readback, validation, case-mapper refresh on data writes, the elementsInBox-stays-geometric scope note, CPU-pick pass-through) and a webgpu Playwright spec (a blue events: no node still wins the pixel but hover and a drag pass through to the node beneath; a cy.pick on an events: no edge answers null and flips live after a restyle — the same-cursor pick-cache pin; the box gesture selects and box-events only the interactive elements). 2185 Node tests, 143/143 Playwright, typecheck + lint clean.

  • 20.3 text-events (2026-08-01) — node-only enum channel (constants or case mappers) maintaining FLAG_TEXT_EVENTS; the CPU pick tests the label block box (store.nodeLabelBox, the round-16.4 laid dims at the D3 anchor — now on the ModelView contract) in device px before the body's quick reject, so label hits resolve the node for tap/grab/hover alike; events: 'no' still wins (checked first). Call finalized during the pass (the plan draft waffled between parse-inert and throw): the edges group throws — accepting an inert prop would be a silent no-op, against the unknown-keys-throw rule; edge labels stay unpickable (recorded).

    Also recorded: the label box picks even when the label is LOD-faded (labelFadePx is a readability threshold, not a pick predicate). Tests-first: 5 Node specs (test/text-events.mjs, red then green — default/readback, edges-group throw, case mapper, label-box pick on/off, the events-wins rule) and a webgpu Playwright spec (a click on the label below the node background-taps under the default and selects the node under text-events: 'yes'). 2190 Node tests, typecheck + lint clean.

  • 20.4 Two-finger cxt (2026-08-01) — the v3 split lands in the pointer layer's touch bookkeeping: a second finger closer than 200 css px starts the cxt gesture (cxttapstart on the node under finger 1, else finger 2, else the core — the sync CPU pick), the pair moving emits cxtdrag + cxtdragover/out (via the existing 17.3 drag-hover pick), spreading past 1.5× or 150 px cancels into a pinch (cxttapend, pinch rebased at the current spread — no zoom jump), and either finger lifting ends it (cxttapend + cxttap when never dragged, never on pointercancel) with the leftover finger inert, like a pinch's.

    A pair ≥ 200 px apart pinches immediately, so the two existing pinch specs' fingers moved to 220 px spacing (they'd have started cxt gestures under the new rule — exactly v3's behavior). Recorded deviation: cxtdrag thresholds on finger-1 movement past touchTapThreshold (v4's mouse cxt rule) where v3's touch cxt fires on any move event.

    Pinned in a webgpu Playwright spec (four synthetic-touch scenarios: close-pair tap on the node → exactly cxttapstart/cxttapend/cxttap; parallel background drag → cxtdrag, no cxttap, no pinchzoom; spread → cxttapend then pinchzoom with the zoom actually rising; far pair → pinch only), verified red against the pre-20.4 pointer layer before the implementation was restored. 80/80 webgpu Playwright specs, 2190 Node tests, typecheck + lint clean.

  • 20.5 Three-finger box (2026-08-01) — v3's centroid box on the pointer layer: three fingers (with boxSelectionEnabled) sweep from the start centroid (+1 px seed, v3) to the moving centroid, boxstart on the first move, the themed DOM box drawn live (the overlay/styling shared with the mouse box via a new showBoxRect helper), applied on any box finger's lift — boxend/box/boxselect through elementsInBox (so the 16.5 label option applies) filtered to interactive() (the 20.2 rule), and additive as v3's touch box is (it never clears the prior selection).

    The box preempts a pinch in progress (v3's touchmove branch order) and the didSelect latch keeps leftover fingers inert until all lift. Design call, recorded: a third finger landing on an undragged cxt pair converts it to the box gesture (cxttapend first) — pointer events land fingers sequentially, so v3's simultaneous three-finger landing has no direct v4 equivalent, and without the conversion the gesture would be unreachable over close pairs. An aborted gesture (pointercancel) hides the box and selects nothing.

    Pinned in a webgpu Playwright spec (close-pair + third finger → cxttapstart/cxttapend then boxstart/boxend/boxselect of exactly the swept nodes, zoom + pan byte-unchanged, leftover fingers inert; boxSelectionEnabled off → no box events, nothing selected), verified red against the pre-20.5 pointer layer. 81/81 webgpu Playwright specs, 2190 Node tests, typecheck + lint clean.

  • 20.6 pixelRatio spec + closing docs sweep (2026-08-01) — the webgpu spec confirmed the pre-existing option end to end (pixelRatio: 1 → backing store = css size, 2 → doubled, and cy.pick at css coordinates still resolves the node), so no code was needed. Closing sweep per the standing rule: both docs grepped for the round's vocabulary and staleness markers — fixed the round-10 deferred list (12c/compounds/z-index/GPU layouts/multiline/three-finger entries all stale since their rounds landed), trued up both file headers with rounds 19–20, and recorded pixelRatio + the touch-box close in their sections.

    Round 20 is complete: 2190 Node + 63 module tests, 147 Playwright specs (renderer + visual — goldens untouched), typecheck + lint clean.

Risks tracked: Frame-uniform layout change touches every pass (one struct, asserted by the existing goldens — any misalignment is loudly visual); pick-cache staleness on events writes (spec pins a flag flip between two picks at the same cursor); touch synthesis fidelity in Playwright (the pinch spec's synthetic-pointer precedent; gestures are driven through pointer events, so no Touch APIs needed); threshold semantics drift (the pointer layer must pick the threshold by pointerType per event, not per instance).

The third design sitting — animation trims, display/visibility, charts

Three calls taken with the user (quick answers, follow-up expected on the finer points), scoping rounds 21–23:

  1. v4 animations do not have to match v3, and the queue goes. The per-element animation queue exists to sequence animations — which promises already do better (await a.promise()); it was valuable pre-promises, not now. v4 drops queueing outright (there is no queue: false option because there is no queue), and the v3 step callback stays out (v4 never had it; onRender + promises cover progress observation). The rest of the v3 surface (pause/progress/reverse/apply, style transitions) stays logged open for follow-up — not built, not dropped. Scoped as round 21.
  2. display and visibility both exist — the distinction is useful. Two tiers with different use cases: structural hiding (no space) vs paint-only invisibility (space kept). The motivating cases: bundled beziers — structurally hiding a bundle member should re-fan its siblings, while making it invisible must keep every rank stable (no sibling jump) — and compound nodes — a display-hidden child leaves its parent's auto-bounds, an invisible child still sizes it. Scoped as round 22.
  3. Pie/stripe backgrounds: yes — designed as a charts surface. Ported not as v3's 101 numbered props but as a lean list-valued chart family designed to grow into other chart kinds later (the pie hole is a first instance: donuts fall out of the same surface). Scoped as round 23.

Gap-list updates: item 9 (animation surface) partially resolved by call 1 (queue/step decided; controls + transitions remain the open follow-up — since scoped and landed as round 24, fourth sitting); item 11 (display vs visibility) resolved by call 2; item 3 (pie/stripe) resolved by call 3.

Animation queue removal

Signed-off design calls:

  • No queue, concurrency by channel. The manager keeps a set of concurrently running animations per element (and for the viewport) instead of a queue: starting an animation whose channels are disjoint from every running one's runs it immediately alongside them (position tween + opacity fade compose); starting one that overlaps a running animation's channels stops that older animation in place (its promise resolves, values freeze where they are, any GPU lease settles) and the new one captures from the frozen state — whole-animation eviction, never a half-stopped animation. Sequencing is the caller's job via await a.promise().
  • delay stays (it is part of one animation's timeline, not queueing). play/stop/promise/playing/animated keep their shapes; stop() stops every running animation on the collection.
  • Recorded: this is a deliberate v4 divergence from v3's queue-by-default (user-approved 2026-08-01); v3's queue: false option spelling is rejected (unknown-keys-throw — there is no queue to opt out of).

Pass split (tests-first; docs in-commit):

  • 21.1 (2026-08-01) — the manager rework landed: the per-element queues became per-element running sets (start() replaces enqueue()), eviction compares touchedColumns() (position → node.position; style channels → their columns; a delay() no-op touches nothing and composes with everything) across shared refs and stops the older animation in place via the existing GPU-settling stopOne; the viewport composes pan and zoom as separate channels; tick advances every running animation (dedup across refs) and stop() stops them all — its clearQueue argument is gone from eles.stop/cy.stop (no queue to clear).

    queue/step option spellings throw with pointers at promises/onRender. settle/demote/onCompacted iterate the running sets, so the GPU lease, compaction-demotion and ref-repair paths carry over — pinned by the untouched compaction + tween suites. Tests-first: the old runs-in-sequence spec replaced by 6 concurrency specs (red then green — in-place eviction with frozen values + resolved promise, disjoint-channel composition on one element, stop() stopping all, delay() never evicting, viewport pan/zoom composition with pan-evicts-pan, the queue/step throws). 2195 Node tests, 82/82 webgpu Playwright specs, typecheck + lint clean. Round 21 is complete.

The display/visibility split

Signed-off design calls:

  1. show()/hide() stay the display tier (structural, element state): no draw, no pick, no space — excluded from bb/fit and compound auto-bounds (already true) — and, new, a hidden bezier-styled bundle member leaves its bundle: siblings re-fan (v3's display semantics; v4 previously kept the rank, which is visibility semantics). Same rule for the per-node loop stagger and compound-loop member index. A hidden node needs no bundle work: every member of a pair shares both endpoints, so the whole bundle disappears together — recorded.
  2. visibility is a style prop ('visible' | 'hidden', both groups, default visible, constants or case mappers — the v4 mechanism for per-element variation; there is no element-state setter). Paint-only: an invisible element draws nothing but keeps its space — bb/fit, compound auto-bounds, layouts and bundle ranks all unchanged — and is not pickable, not hoverable, not box-selectable (interactive() rides visible()). Ancestor-gated for nodes (v3: descendants of an invisible parent are invisible); an edge is additionally invisible while either endpoint is (rides the kernels' existing endpoint tests).
  3. Mechanism: one derived bit, one WGSL constant. The style engine maintains FLAG_SELF_INVISIBLE; the store derives FLAG_DRAWN (= effective shown AND no invisibility on self or, for nodes, any ancestor) in the same subtree walk that maintains effective FLAG_VISIBLE. The WGSL SHOWN constant redefines from ALIVE|VISIBLE to ALIVE|DRAWN, so every cull kernel, vertex shader, depth prepass and glyph/ghost/layer stream honors visibility with zero per-kernel edits and zero new bindings; CPU picking tests DRAWN; bb/fit/box-geometry scans keep testing VISIBLE (space semantics — invisible elements stay in).
  4. Getter semantics (v3's): visible() = drawn (edges fold endpoints); hidden() its negation; takesUpSpace() = the display tier alone (shown, whatever the visibility — it may now differ from visible()); interactive() = visible() && the 20.2 events rule. Readback: style('visibility') from the flag.

Pass split (tests-first; docs in-commit):

  • 22.1 Store + prop (2026-08-01) — FLAG_SELF_INVISIBLE + FLAG_DRAWN landed with the derivation folded into refreshEffectiveVisibility (one subtree walk maintains both bits; a drawn-only change skips the geo/auto-bounds invalidation — invisible elements keep their space), setInvisibility as the style write's entry, isDrawn(ref) folding edge endpoints, the visibility prop (parse/readback/case mappers, both groups) and the getter updates (visible() = drawn + endpoint fold — v3's edge rule, now implemented; hidden() its negation; takesUpSpace() = the display tier). Two pre-existing space gaps closed en route: the whole-graph fit scan (store.boundingBox) and the collection boundingBox() never filtered display-hidden elements — both now skip unshown elements (and edges with unshown endpoints), v3's rule, while deliberately including invisible ones. 7 Node specs (test/visibility-prop.mjs, red then green) + the cpu-pick hidden-node spec moved onto the real hide path.
  • 22.2 Renderer (2026-08-01) — the WGSL SHOWN constant flipped to ALIVE|DRAWN (one line — every cull kernel, the depth prepass and all glyph/ghost/layer streams honor visibility with zero new bindings; scene pixels for fully-visible graphs are untouched, pinned by the unchanged goldens) and the CPU pick masks the same way. Playwright: an invisible node paints nothing (body + label + incident edge via the endpoint test) while fit() still frames it; a tap where it sits background-taps; a restyle to visible returns the pixels; and a display-tier hide() then shrinks the fit box (the 22.1-closed gap, pinned).
  • 22.3 Bundle re-fan (2026-08-01) — CurveHost.edgeShown (reads FLAG_VISIBLE — the display tier by construction), onEdgeShownChanged marking the pair/loop on hide/show (wired from setVisibility; no-op for straight-only graphs until the pair index exists), and derivation filters: hidden members leave the bundle array, the compound-relation index and the loop stagger, and their own params freeze until a show() re-derives. Visibility flips never touch the curve index, so invisible members keep every rank by construction. 2 Node specs (red then green): hiding a 3-bundle's middle re-fans the outer pair (and show() restores byte-exact params); an invisible middle keeps the 3-bundle stagger byte-identical against a reference instance. 2204 Node tests, 148/148 Playwright (goldens untouched), typecheck + lint clean. Round 22 is complete.

Node charts: pie and stripes

v3's 51 + 50 numbered props (pie-1-background-color ... stripe-16-background-size) return as a lean, list-valued chart family designed to grow more kinds later — the user's call (2026-08-01): definitely port, and shape the surface for future chart types.

Signed-off design calls:

  1. The chart family (node-only): chart (none | pie | stripes — the open enum future kinds extend), chart-values (a number list — a constant array, or the { data: key } passthrough reading a per-element array from the data sidecar, the headline capability: data-driven pies), chart-colors (a constant color list or a named scheme string from the mapper DSL's palette table — 'category10' is the default), chart-size (fraction or 'N%' of the node box, default 100%), chart-hole (0–1 inner cutout — donuts from the same surface, v3's pie-hole analogue), chart-start-angle (pie; v3's pie-start-angle, default 12 o'clock), chart-direction (stripes: horizontal | vertical) and chart-opacity (folds into slice alphas, the B1 pattern).

    Values are absolute fractions of the whole (v3's percent semantics: a sum under 1 leaves unpainted remainder, over 1 clamps at 1) — no normalize option for now, apps can normalize (recorded). Slice count caps at 16 (v3's N; recorded).

  2. Storage: a chart blob record per element ([kind, config, n, then n × (value, packed rgba)]) in a round-11-compacting blob pool behind a packed node.chartRef column — colors resolve at style-write (constants-only props bake per record). chart-values via { data } refreshes on writes of the mapped key like any mapped channel; every other chart prop is constants-only except chart itself and chart-opacity (mapper-capable enums/numbers).

  3. Rendering: in the node FS, SDF-native. A chartRef == 0 early-out keeps unused cost ~zero; pie tests the fragment's local angle against cumulative stops (start at 12 o'clock, clockwise — v3), stripes test the local coordinate; both clip to the node's shape SDF and the chart-sized box, draw over fill/gradient/background-images and under border/outline (v3's order), and AA at slice boundaries analytically. Charts are paint-only: never in bb, never pickable, no cull impact.

  4. Verification: Node specs (parse/readback/blob/refresh), goldens (pie fractions incl. remainder gap + hole + start angle; stripes both directions), and a live v3 parity scene mapping chart pies onto v3's pie-i-* props (and stripes onto stripe-i-*) at matching geometry.

Pass split (tests-first; docs in-commit):

  • 23.1 Props + model (2026-08-01) — the 8-prop surface parses/validates/reads back (chart + chart-opacity mapper-capable; values as arrays or space-separated strings, or the { data: key } passthrough reading per-element arrays with a new 'chart' dep kind + narrow refresh path beside the label one; colors as lists or named schemes with category10 the default, cycling past their length; size/hole as [0,1] fractions or 'N%'; start-angle via the shared angle parser; direction vertical | horizontal; every list/config prop constants-only).

    Records live in a chart blob (round-11-compacting CurveBlob pool, compaction-remapped) behind the packed node.chartRef column — header kind/size/hole/startAngle/direction/opacity/n then n × (value, r+g·256, b+a·256): colors split across small-integer floats (packed u32 bits would risk NaN canonicalization through the f32 pool), alpha-folded with the exact opacity kept in the header for readback. Slices cap at 16 (v3's N) and the running total clamps at 1 (v3's percents; the remainder stays unpainted); invalid sidecar entries skip; a chartless write frees the record (as does removal).

    Tests-first: 10 Node specs (test/charts.mjs, red then green). 2214 Node tests, typecheck + lint clean.

  • 23.2 Render (2026-08-01) — a dedicated ChartPipeline (the node FS sits at its 8-buffer cap, so charts get their own pass — the image pipeline's shape: one quad per charted node off the culled visible lists, leaves after the image pass and parents after theirs, chartless instances collapsing in the VS, the whole pass skipped at chartCount 0).

    The FS clips to the node shape at the border's inner edge (poly blob bound for custom polygons), resolves the fraction coordinate (clockwise from 12 o'clock for pies — v3; the advancing axis for stripes) and walks the record's stops with px-space AA into the neighboring region (wrapping across the start angle on full pies), radial AA at rim + hole, sub-box edge AA for stripes; element opacity multiplies; derivatives hoist above every branch (WGSL uniformity, caught by the device-error guard). The chart blob mirrors beside the image blob.

    Two fixes shaken out by the golden: the chart-refresh fast path re-routes through the full mapped write when the def has mappers (the narrow path wrote the constants record — wrong when chart itself is case-mapped), and the scalar/enum chart props joined the mapper-capable set (the 12b constants-only rule covers lists, not scalars). Pinned by the charts-pie-stripes golden: full pie on the default palette, remainder gap, donut with start angle on a bordered ellipse, both stripe directions, chart-size < 1.

  • 23.3 Parity + close (2026-08-01) — two live v3 parity scenes: pies against the numbered pie-i-* props at 0.000% (pixel-exact — fractions, remainder, hole, start angle) and stripes against stripe-i-* at 0.005%. Two upstream v3 stripe bugs found and recorded (they constrain the parity scene to vertical square-node stripes; the golden pins v4's horizontal + non-square behavior): v3's stripe-direction: horizontal is inert — the canvas draw switch tests a typo'd 'righward' keyword its own style type rejects — and drawStripe swaps W/H in its centering offsets, visible on non-square nodes.

    The planned debug toggle was dropped (the golden + parity scenes cover the visual surface; recorded). 2214 Node tests, 151/151 Playwright, typecheck + lint clean. Round 23 is complete.

The fourth design sitting — style transitions and animation controls

The open half of gap item 9, scoped with the user as round 24. Calls taken:

  1. Transitions are in — the transition-property/-duration/ -delay/-timing-function family returns as sugar over the animation system. The trigger taxonomy is v4-specific (no classes, no bypass): a transition fires whenever an element's resolved channel value changes through a restyle — sheet re-application, mapper re-evaluation on data writes (case clause flips, scale output moves, auto-domain extent shifts) and structural restyles (leaf↔parent flips, structural case conditions). Instant on add (v3's rule — a new element's first style application never tweens from channel defaults).

    Non-triggers, recorded: visibility/show/hide flips (flags, not tweenable channels — fade is spelled with an opacity transition) and descendant effective-opacity folds (they follow an ancestor's tween per tick; no per-descendant transitions). Batched writes capture at the outermost endBatch — one transition per net change.

  2. Interruption: latest wins, uniformly. The round-21 channel-eviction rule applies with no priority tiers: whichever starts later (transition or user animation) captures from the current mid-flight value and stops the older one in place, both directions.

  3. transition-property accepts every prop name from day one; executors are tiered. Number/color channels that are animatable today actually tween — the paint set (opacity, background/border/line colors, with the arrow-alpha fold riding along) on the GPU bulk path, border-width on the CPU path — while discrete channels (enums, strings, lists) snap at the transition's start (CSS's rule, recorded) and the not-yet- animatable geometry numerics (width/height, font-size, padding, edge width) snap too, logged as the geometry-tween follow-up round: their per-tick invalidation cascade (curve re-derivation, compound auto-bounds, label anchors) is the same work the width/height animation follow-up needs, so both land together, once, with benchmarks.

    The API surface never changes when that round lands.

  4. Whole-channel transitions must be one bulk tween record — a slot list + packed from/to buffers (the round-9.4 shape), never per-element Animation objects. This keeps the auto-domain-shift worst case (one write moves the live extent → the whole channel re-derives) in the cost class it already occupies today: the O(n) re-derive plus a constant (one stored- channel read for the from values, one from/to upload, ~zero per frame while running).

  5. The domain performance contract (user's condition: both modes stay supported). Explicit domain — already in the round-7 DSL, no new mapper type — is the documented escape hatch: with a pinned domain a data write re-evaluates written elements only (O(changed), never whole-channel); with 'auto', in-range writes are identically O(changed) and only extent-moving writes pay the O(n) re-derive. Recorded as docs guidance (mapper docs + transition docs): auto is the ergonomic default, pin domain when a stream grows its own extent. No warning machinery.

  6. Controls: pause/resume/reverse land; progress stays a getter (no scrubbing), and v3's apply/applying stay out (promises cover the use case; one name per concept). A paused GPU tween settles its lease (values freeze on the CPU) and re-acquires on resume.

Style transitions and animation controls

Signed-off design calls: the fourth-sitting record above — trigger taxonomy with instant-on-add, uniform latest-wins eviction, all-props-accepted with tiered executors (paint tweens now, discrete

  • geometry snap, geometry-tween round logged), bulk-record whole-channel transitions, the auto-vs-explicit domain contract, and the pause/resume/reverse control set.

Pass split (tests-first; docs in-commit):

  • 24.1 Transition props + CPU path (2026-08-01) — landed as planned, with the trigger detection shaped as one mechanism: the four props split out of each sheet block at compile (per-group TransitionSpec; the parents def merges nodes-then-parents under v3's order precedence; constants-only — mapper values throw; transition-property accepts arrays or space-separated strings and validates every name against the group's read set, so unknown or wrong-group names throw while discrete/geometry names are accepted and snap), and a capture wrap around the one channel funnel (write()): any apply pass under a configured spec (sheet re-application, the mapper refresh paths — case flips, scale moves, auto-extent escalation, structural ::parent refreshes, the leaf↔parent flip restyle — and the batch flush) snapshots the tweenable columns per slot before the write, diffs stored truth after it, restores the old value (the store holds the pre-restyle state until the first post-delay tick — CSS's delay rule, and no target flash), and packs the accumulated diffs into bulk per-column ChannelWrites wrapped in one preset Animation started through the round-21 manager — so latest-wins eviction between transitions and user animations falls out in both directions with zero new eviction code.

    Instant-on-add is a per-slot styled-generation mark (gen + 1; recycled slots fail on their fresh generation; marks refresh on slot compaction), which also makes the batch flush's applyAll net-change-correct with no call-site special-casing. Diffing stored truth gives the fold semantics for free, recorded: channel-opacity folds ride the color they fold into, and an edge-opacity transition carries the pre-folded arrow alphas along as ride-along color writes (only when the opacity itself moved).

    Tweenable set = the animation system's channels (opacity both groups, background/border/line colors, border-width); preset animations derive touchedColumns/ gpuEligible from their writes (all-paint may offload — 24.2's hook; border-width stays CPU).

    Tests-first: 23 Node specs (test/transitions.mjs, red then green) — the full trigger matrix (sheet swap, add, case flip, scale move, auto-extent shift, explicit-domain confinement, batch net-change + batch-add, parent flip, show/hide non-trigger, zero-duration), snap tiers, eviction both directions, delay, edge line-color, the arrow ride, and prop parse/validate/readback. 2237 Node tests, 63 module tests, 151/151 Playwright (goldens untouched — rendered scenes without transitions are pixel-identical under the capture wrap), typecheck + lint clean.

  • 24.2 GPU bulk path + scale proof (2026-08-01) — the offload came almost free from 24.1's preset shape: an all-paint preset reports gpuEligible and the manager registers its ChannelWrites with the existing gpu-tween kernels verbatim, so the only new renderer-side code is a demotion rule: a listed transition prop's mapper eval can not be kernel-owned (the diff reads stored truth on the CPU, which is stale exactly when the kernel owns the channel) — paintInputs demotes every prop in the group's spec (the parents overlay's spec too, under compounds); transitions and mapper kernel eval are mutually exclusive per channel, while the tween itself still runs on-device (different kernels).

    Playwright (both discriminating, in the renderer project): a sheet-swap transition tweens pixels through OKLab while style() reads the pre-restyle value (the motion-staleness rule) and settles on the exact resolved end state; a scale-mapper transition on a data write tweens rather than snapping — the spec fails on the mid-flight green>red strictness if the demotion is removed.

    Scale proof (benchmark/transitions.mjs, headless 200k nodes): the auto-extent shift's whole-channel re-derive is 326 ms off → 594 ms with transitions (1.82× — the diff + restore + bulk spawn is a constant factor, not a new class); the explicit-domain write is 4.2 → 6.8 µs (O(changed) pinned — ~2.6 µs to diff and spawn a one-element tween); a whole-sheet swap is 1.46 → 1.67 s (1.15×); and the spawned 200k-slot tween costs 15 ms per CPU tick — the number the GPU offload deletes (all-paint presets tick on-device at ~zero CPU, the round-9.4 contract).

    The domain-contract browser spec folded into the Node spec + the benchmark's explicit-domain group (recorded). 2237 Node tests, 153/153 Playwright (2 new), typecheck + lint clean.

  • 24.3 Controls (2026-08-01) — pause()/resume()/ reverse() on the Animation handle (element and viewport alike), plus read-only progress() and paused() introspection (progress is a getter only — no scrubbing; apply/applying stay out).

    Timeline semantics: pause freezes elapsed in place (values hold, the promise stays pending, playing() reads false) and resume shifts the start clock by the paused span; reverse swaps every write's from/to halves (and the viewport targets) and remaps elapsed to 1 − t, so the current value is continuous — exactly for point-symmetric easings (linear included; v3's start/end swap carried the same rule) — and reversing inside the delay completes at the captured start state. The controls read a lastNow clock the manager stamps every advance, so they stay deterministic under test-driven ticks.

    GPU lease: pause and reverse settle a GPU-driven animation's exact current value onto the CPU and release the device (applyNow — a settle that does not finish); resume/the next advance re-registers through the normal eligibility path with the shifted clock (pinned: the re-registered start keeps 160 − start = elapsed, and a reversed re-registration uploads the swapped from/to). A paused animation still owns its channels — the round-21 eviction stops it like any running one (pinned).

    Tests-first: 11 Node specs (test/animation-controls.mjs, red then green) — timeline shift, pending promise, stop-on-paused, eviction-of-paused, reverse continuity + delay edge, progress states, both mock-sink lease specs, and the viewport. 2248 Node tests, 63 module tests, 153/153 Playwright, typecheck + lint clean.

  • 24.4 Docs closing sweep (2026-08-01) — README trued up: the top summary carries round 24, the sheet listing carries the transition-* config, the promise-sequencing bullet's "open follow-up" became the landed controls paragraph (24.3 commit), the animation-surface listing carries the handle controls, and the mapper DSL bullet carries the domain performance contract (O(n) auto-extent vs O(changed) explicit — the transitions bullet holds the long form and the measured numbers). PLAN.md: gap-ledger item 9 closed (round 24 landed in full 2026-08-01); the sequencing tail names the geometry-tween round (size-channel transitions + animation, one benchmarked round with the per-tick invalidation cascade) as the successor open follow-up. Round 24 is complete.

Geometry tweens

The follow-up round 24.4 logged: the geometry numerics — node width/height, edge width, font-size, parent padding — become animatable and transition-tweenable, with the per-tick invalidation cascade built once and benchmarked. The API surface does not change (the fourth sitting's call): transition-property already accepts these names and animate({ style }) starts accepting them; what changes is that they tween instead of snapping/throwing.

Code investigation (2026-08-02, precedes this plan) — every consumer of node size / edge width / font-size classified as live-read vs derivation-baked:

  • Node size is read live by nearly everything: node/ghost/ overlay/underlay/image/chart quads and their cull extents, CPU pick, refsInBox, boundingBox, endpoint clipping, haystack offsets, taxi/segment/bezier evaluation (curve derivations are size-free). Exactly two consumers bake it: label anchors (sidecar anchorX/anchorY + the glyph run's baked offsets — today only the style engine's same-pass writeLabel keeps them in sync, and the store's reanchorLabel covers only the parent auto-bounds path) and the compound-loop excursion bound p2 (cull slack only; drawn CMPD geometry is live).

    The CMPD invalidation lives only in materializeParentGeom, so it has a pre-existing hole: a child size change that does not move the parent's box (non-extremal child, or a min-size-pinned parent) never refreshes the bound. Slack meters (nodeHalfMax, borderMax) are monotone grow-only — sound under tweens.

  • Edge width is read live by the quad/strip expansion, arrow sizing and edge cull; three derived channels bake it at style-write, all linear in width: edge.arrowWidths under match-line/percent, edge.casing stroke (width + outline width), edge.overlay/edge.underlay strokes (width + 2·padding).

  • Font-size is baked into the glyph instances in model px (the build is per-label incremental, never whole-stream), and the shaping memo keys on maxWidth/fontSize — a tween would miss (and grow the memo) every tick, GlyphBuffer.set would tombstone-and-append per tick until compaction forces whole-stream re-uploads, and every label write bumps the global geoEpoch, nuking the per-edge exact-bb memo. Edge-label anchorY is fontSize-dependent (-fontSize/2 + marginY); node label anchors are not.

  • Padding already cascades: setCompoundStyle marks the hierarchy geo-stale and the lazy flush re-derives auto-bounds.

Design calls (round 25):

  1. Geometry tweens never offload and are never stale. The round-9.4 tier rule stands: these channels are read by cull, CPU pick and the columnar scans, so every tick is a CPU column write (the mirror uploads dirty spans as usual). Consequence, recorded as a contract point: unlike leased paint/position tweens, a geometry tween is always synchronously readable — width()/bb()/pick mid-tween report the mid-flight value (v3's behaviour). gpuEligible stays false via the existing tier mechanism; the GPU tween kernels never see the new write kinds.

  2. The write vocabulary grows three CPU-only kinds. ChannelWrite gains lane (scalar tween of one component of a multi-lane column — node.size lanes 0/1, edge.arrowWidths lanes, and the ×256 fixed-point stroke lanes of edge.casing/edge.overlay/edge.underlay), padding (writes setCompoundStyle({ padding }) per tick), and fontSize (patches the label sidecar per tick). Store entry points: setLane (with per-column cascade) and setLabelFontSize; both usable by the style engine too.

  3. The size-write cascade closes the label hole at the store; the CMPD bound needs nothing (amended while building 25.1 — the investigation's "pinned-parent hole" did not survive a closer look). setPair('node.size')/setLane re-anchor the label (reanchorLabel hoisted out of the parent-only path; early-outs when unlabelled or center-anchored).

    The planned CMPD invalidateRelation hoist is unnecessary by a containment argument: the excursion bound is a max over both ends' stretches, stretch is monotone in outerHalfW, and auto-bounds derive parents from children's outer halves — so an ancestor's outerHalfW always dominates its descendants' and the max is always the ancestor's, which can only change when the ancestor's own box changes: exactly the event materializeParentGeom already invalidates on. A descendant-size change that leaves the ancestor's box unmoved provably leaves the bound unmoved too.

    (The same argument dissolves the investigation's monotone-safety worry: a stretch change implies a parent-box change implies a re-derive.) Pinned by a spec: a child size tween grows p2 through the parent's own materialization.

  4. Edge width carries its baked derivatives as ride-along lane writes, the arrow-alpha-fold pattern: casing and overlay/underlay strokes ride additively (to = stored + Δwidth, only when the layer/casing is enabled), arrowWidths rides per mode (match-line → toWidth, percent → pct·toWidth, number → no ride), modes answered by the style engine at capture (the captureArrowFold precedent). Transitions get the same rides from stored-truth diffing (the apply pass rewrites the derived channels; the txn records them as lane rides of width).

  5. Parent size is auto-bounds-owned: width/height tweens skip parent slots (capture filters them; apply re-checks FLAG_PARENT per tick so a mid-tween leaf→parent flip drops the slot rather than fighting the derivation). Recorded deviation: animating/transitioning width/height on a compound parent is a no-op — padding/min-size are the parent knobs, and the padding tween is this round's parent-size story. Also recorded: width and height share the node.size channel, so the round-21 eviction treats them as one channel (a running width tween is evicted by a starting height tween).

  6. Padding tweens the declared value in its declared unit (px or %-fraction; the resolution against the children bb happens at the flush, per tick, so relative modes follow live). A unit change between sheets snaps (recorded). Leaves have no padding — capture filters to parent slots. The transition capture wraps the parents' setCompoundStyle apply (its own small capture beside the write() funnel, honouring the styled-generation instant-on-add rule).

  7. Font-size tweens re-break honestly, made affordable by four label-path fixes (each useful beyond tweens): (a) a pure fontSize delta with unchanged breaking (wrap none, the default) scale-patches the stored dims instead of re-running estimateBlock; (b) the shaping-memo key drops maxWidth when wrap is none (kills the spurious per-tick miss + unbounded memo growth); (c) GlyphBuffer.set updates in place when the new run has the same glyph count (no tombstone growth, no forced compactions/whole-stream re-uploads under a steady tween); (d) label writes stop bumping the global geoEpoch (labels get their own epoch; the per-edge exact-bb memo keys on geometry alone).

    Wrapped labels (wrap/ ellipsis with a finite maxWidth) genuinely re-break per tick — correct, priced in the benchmark, and recorded as the expensive configuration. The tween patches fontSize (and the fontSize-dependent anchorY on the three edge streams) across nodes/edges + end-label streams; min-zoomed-font culling follows automatically (the per-glyph threshold is rebuilt with the instances).

  8. Transitions wire through the same channels. TRANSITION_CHANNELS gains nodes width/height (size lanes), padding, font-size and edges width (+ lane rides), font-size; the txn capture learns the lane/fontSize read-restore forms (the delay rule keeps holding pre-restyle values, sidecar included). The round-24 "geometry snaps" specs flip to "geometry tweens" — the API surface is unchanged.

  9. Scale is measured, not assumed. A new benchmark/geometry-tween.mjs sweep prices: the size-tween tick at 2k/20k/200k animated nodes (labelled vs unlabelled — the re-anchor term), the edge-width tick with rides, the padding tick (auto-bounds flush per tick at compound scale), the font-size tick (wrap none vs wrapped — the re-break term), against the round-24 paint-tick baseline (15 ms/200k slots). The glyph-buffer in-place path is pinned by a no-growth/no-compaction assertion under a steady tween.

Pass split (tests-first; docs in-commit; each pass its own commit(s)):

  • 25.1 The lane vocabulary + node width/height (2026-08-02) — landed as planned, with design call 3 amended (above): the lane write kind (ChannelWrite.lane, stride 2, geometry-tier by construction — TWEEN_SHADERS/pipelines narrowed to a GpuWriteKind that excludes it, and the runtime throws if one ever reaches register), the store's cascading setLane (node.size routes through setPair; other float columns write the lane raw + dirty), the label re-anchor hoist into setPair('node.size') (the raw-size-write anchor staleness hole, closed for style writes and tween ticks alike), and STYLE_CHANNELS width/height as node.size lanes 0/1 with parent slots filtered at capture and re-checked per tick (a mid-tween leaf→parent flip hands the slot to auto-bounds).

    No CMPD invalidation was added — the containment argument in call 3, pinned by the p2-growth spec. Tests-first: 12 Node specs (test/geometry-tween.mjs, red then green) — width+ height and width-only tweens, never-stale width()/bb() reads, outerHalf write-through, hanging-label re-anchor mid-tween, child tween drives parent auto-bounds per tick, the CMPD p2-growth pin, width-vs-height channel eviction, reverse continuity, spring clamp at the 0 floor, pause/resume. 2260 Node tests, 63 module tests, typecheck + lint clean.

  • 25.2 Edge width + rides (2026-08-02) — landed as planned: STYLE_CHANNELS.width gains the edge.width column (plain scalar, geometry tier) and the capture carries the three style-write-baked derivatives as ride-along lane writes (captureEdgeWidthRides, the captureArrowFold pattern): the casing/overlay/underlay strokes additively from stored truth (to = stored + Δwidth, per-slot gated on the layer record being enabled — mapper-resolved paddings/outline widths need no engine round trip), and edge.arrowWidths by mode via the new constants-only StyleEngine.arrowWidthModes() ('match-line' → target width, percent → pct × target, numbers stay). setLane encodes the layer records' ×256 fixed-point stroke lane. Tests: 4 specs (red then green) — live width() reads, match-line + percent rides with stored-truth readback, the additive casing ride, ride-only-when-enabled (a disabled underlay's record never moves). 2264 Node tests, 63 module tests, typecheck + lint clean.

  • 25.3 Size transitions (2026-08-02) — landed: TRANSITION_CHANNELS gains nodes width/height (node.size lane channels) and edges width with its baked derivatives as lane rides (casing/overlay/underlay stroke lanes ×256 fixed-point, both edge.arrowWidths lanes — stored-truth diffing catches them because the apply pass rewrites them in the same funnel; rides move only when the width moved). The txn machinery learned the lane kind end to end: descriptors (TxnChannelDesc, rides as full descriptors now), lane read/restore (restore runs the full size cascade, so the label the apply pass baked at the target re-anchors back to the held size), entries keyed column:lane (arrowWidths carries two), buildChannelWrite takes the lane through to the write. Parent slots never record a node.size transition (the auto-bounds rule, checked per slot in the diff). The round-24 "geometry snaps" spec flipped to "geometry tweens"; 5 new specs: sheet-swap both lanes with live bb, per-tick label re-anchor under a transition (held size restores the anchor too), edge-width rides (match-line + casing) with held pre-restyle values, a mapped width transition on a data write (scale move), and the parent-slot never-records pin (parent follows through auto-bounds only). 2269 Node tests, 63 module tests, typecheck

    • lint clean.
  • 25.4 Padding (2026-08-02) — landed: the padding write kind targets the node.padding pseudo-column (TweenColumn = ColumnId + the pseudo target; padding is a compound style input, not a stored column) and writes through a new updateCompoundStyle — a partial merge over the current record, split from setCompoundStyle because a { padding } tick must not reset the unit/min sizes while sheet writes keep their reset-what-you-omit semantics. Parents-only mirrors the size rule (leaves filtered at capture, re-checked per tick); the declared value tweens in its declared unit (px, or the %-fraction — the auto-bounds flush resolves relative modes live).

    The transition capture wraps the parents' compound write beside the channel funnel (applyCompoundStyle): styled marks read before the channel pass marks fresh slots (instant-on-add holds), diff + held-value restore as usual, and a px↔% unit flip snaps (recorded). The GPU-kind narrowing extends to padding (GpuWriteKind excludes both geometry kinds; the runtime guard throws). 5 specs (red then green, minus the unit-flip snap which pinned the status quo): auto-bounds per tick via paddedWidth(), leaf no-op that still completes, %-fraction tween, the parents-sheet transition with held value, unit-flip snap. 2274 Node tests, 63 module tests, typecheck + lint clean.

  • 25.5 Font-size (2026-08-02) — landed: the fontSize write kind (pseudo-columns node.fontSize/edge.fontSize) and GraphStore.setLabelFontSize — the per-tick sidecar patch, no engine round trip: an edge write drives all three streams and re-derives the fontSize-baked edge anchorY (−fs/2 + marginY); node anchors are size-derived and untouched. Unlabelled elements filter at capture (animation) and snap via the −1 sentinel (transition diff — a label added by a restyle has nothing to tween from).

    The four label-path fixes shipped with it: the wrap-none scale-patch keeps dims exact (the round-16 wrap spec updated to pin the new contract — scaling a laid block is exact; a text change still re-estimates), the memo key drops maxWidth under wrap none, GlyphBuffer.set rewrites same-count replacements in place (pinned by a 50-tick no-growth/no-tombstone spec with a single coalesced span), and label writes stop bumping geoEpoch (its only consumer is the edge curve-bb memo — no label terms).

    Tests: 3 animation specs (node dims/readback, edge anchorY + end-stream ride, unlabelled filter), 2 transition specs (held value tween, label-added snap), the glyph-buffer in-place spec, and the amended wrap-dims spec. 2280 Node tests, 63 module tests, typecheck + lint clean.

  • 25.6 Benchmarks + browser specs (2026-08-02) — landed: benchmark/geometry-tween.mjs (standalone, the transitions.mjs pattern) prices one manager tick per geometry channel at BENCH_N scale. At 200k elements (headless, avg/iter, machine-local — the factors are the story): paint baseline 65 ms; node size 122 ms unlabelled / 136 ms with center-anchored labels (the re-anchor early-out is ~12%) / 510 ms with hanging labels (a sidecar rewrite per tick — the 25.5 dims fast path keeps the estimator out of the loop); edge width over 400k edges 86 ms bare / 130 ms with the full ride set; padding + auto-bounds flush 75 ms over 25k parents × 8 children; font-size 213 ms wrap-none vs 767 ms wrapped (the honest re-break, the recorded expensive configuration). Two webgpu-project Playwright specs: the sheet-swap width transition (pixels move mid-flight; width() reads the mid-flight value — the never-stale contract — and the hanging label's anchorX tracks −w/2 exactly, read atomically in one evaluate), and the edge-width casing ride (a fixed sample point passes white → black casing band → red line; the mid-state is polled, not slept for — suite load shifts the clock). 87/87 webgpu Playwright (2 new), run twice for stability.

  • 25.7 Closing docs sweep (2026-08-02) — swept both docs for the round's vocabulary and staleness markers: the README header carries round 25, the follow-up hooks close the geometry-tween item (the parity remnants stay the open tail), the two-tiers bullet notes the round kept the geometry-stays-CPU rule, the round-16 label-cost line qualifies "never per frame" (per frame exactly under a wrapped font-size tween, recorded), and the gap ledger's two live sequencing references move past the round.

    Full verification: typecheck, 2280 Node tests, 63 module tests, lint, and 173/173 Playwright across the chromium + renderer + visual projects (goldens untouched; the webkit/webgpu-webkit projects could not launch on this box — browserType.launch fails on missing host system libraries, an environment gap needing sudo, not a regression; re-verify on a webkit-capable machine when convenient). Round 25 is complete.

The authoring surface: JSDoc and shipped types

Direction taken 2026-08-02 (user). The v3 code and the v3 documentation stay untouched until v4 actually ships, so every v3 asset remains available for comparison benchmarks and parity work. The near-term documentation task is therefore not a v4 docs site: it is JSDoc on the v4 source — the whole public API, and ideally every class and every function within it — from which docmaker input can later be generated rather than hand-written. Nothing in this round changes runtime behaviour or public API semantics; it is the authoring surface for the docs that come at release.

Code investigation (2026-08-02, precedes this plan):

  • Public-member JSDoc coverage across src is 395/852 (46%) (audit rule: members of exported classes whose names do not start with _). The two files that are the public API are the worst covered: collection.mts 66/204 (32%) and core.mts 33/89 (37%). animation.mts is 33/48 (69%), viewport.mts 11/18, and the built-in layouts are 0/3 each.
  • The comments that exist already drift: Collection.animate() still advertises the pre-round-25 animatable set (no width/height, edge width, padding or font-size). A JSDoc pass is also a true-up pass.
  • The docmaker target shape (v3/documentation/docmaker.json) is { name, descr, formats: [ { descr, args: [ { name, descr } ] } ], md } — a summary sentence, per-overload descriptions and named arguments, grouped into named subsections. Standard JSDoc (@param, @returns, overload blocks) carries all of it.
  • Both public classes already carry // -- <group> -- banner comments whose groupings mirror docmaker's subsections almost 1:1 (24 banners in collection.mts, 14 in core.mts — "graph manipulation", "viewport", "traversal", "events", ...). So section placement needs no new tag: the banners already are the grouping.
  • The ./gpu package export maps "import" only — no types key and no .d.ts — and the seven test:types:* scripts contain zero gpu references, so import cytoscape from 'cytoscape' resolves to untyped JS today. Pointing the existing rolldown.dts.config.mjs at src/index.mts emits a complete 4,508-line / 191 KB declaration bundle in ~300 ms with no errors: the declarations are a config addition, not a project.

Design calls (round 26):

  1. JSDoc is the documentation source of truth for v4. Prose about what a member does lives next to the member, not in a parallel markdown tree. src/README.md keeps its role — scope, design decisions, deviations, the cross-cutting narrative — and PLAN.md keeps the logbook; neither duplicates per-member documentation. The eventual release docs are generated from these comments.
  2. Standard tags only; banners are the sections. @param, @returns, @throws, @example, @see, @defaultValue. No bespoke @section/@docs tag: a generator reads the existing // -- <group> -- banners for placement, so this round's job is to make the banners complete and consistent rather than to invent a vocabulary. Overloads get one doc block per signature, matching docmaker's formats array.
  3. A doc comment states the contract, not the implementation. What it does, what it takes, what it returns, what it throws, and — where v4 deliberately differs — the deviation, in the voice the README already uses ("v3 does X; v4 does Y because Z"). Round references ((19.3), (round 25)) stay: they are how this codebase cites its own history. Existing comments are corrected where they have drifted rather than left beside new ones.
  4. Declarations ship with the docs in them. cytoscape gains a real .d.ts built by the existing pipeline, so the JSDoc written in this round reaches consumers' editors as hover text. This is the payoff that makes the comment pass immediately useful instead of only useful at release.
  5. Coverage is enforced, not aspirational. The audit becomes a checked-in script plus a Node test: the public API tier (the entry point, Core, Collection, the animation handle, the layout contract and the public style/option types) is gated at 100%, and the internal tier is reported with a floor that ratchets up as passes land. Without a gate a 46%-covered surface silently returns to 46%.

Pass split (docs in-commit; each pass its own commit(s)):

  • 26.1 The convention + the core surface (2026-08-02) — landed as planned: scripts/jsdoc-coverage.mjs (the two-tier audit, --verbose for the per-member list), test/jsdoc-coverage.mjs (the completed-files ratchet + the tier floors), the conventions recorded in src/README.md ("Documenting the source"), and core.mts 33/89 → 89/89 plus viewport.mts 11/18 → 18/18. Two drift fixes found by writing the comments: json()'s doc block had become stranded above serialize() (so json() read as undocumented and serialize() carried the wrong prose), and the batching narrative was a bare /* */ note rather than doc comments on startBatch/endBatch/batch.

    Public tier 42.4% → 58.1%; floors set to 58/49. Typecheck, 2286 Node tests (6 new), 63 module tests, lint clean.

  • 26.2 The collection surface (2026-08-02) — landed: collection.mts 66/204 → 204/204, the largest single surface, covering iteration/comparison/set-building, position and dimensions, the visibility/selection/grab flag families, traversal and edge relations, the whole graph-algorithm surface, degree, and the element event methods. Drift fixed while writing: animate()'s block still advertised the pre-round-25 animatable set (no width/height, edge width, padding or font-size) and said nothing about OKLab or the names-only easing rule.

    Contract points that had never been written down anywhere a caller would look now are: position() reads stale under a GPU-owned tween while width()/height() never do (the round-25 geometry-tween rule), boundingBox() includes labels by default with exact node terms and conservative edge terms, degree() is singular where totalDegree() is the collection-wide sum, and filter()'s query/predicate split is what replaced selector strings. Public tier 58.1% → 92.3%; floor raised to 92. Typecheck, 2287 Node tests, 63 module tests, lint clean.

  • 26.3 Animation, layouts, style, entry points (2026-08-02) — landed: animation.mts, style.mts, columnar.mts, layout/contract.mts and all seven layouts (the six built-ins plus ForceLayoutImpl) documented, taking the public API tier to 100% (408/408). A third stranded doc block surfaced — setSheet()'s prose had drifted onto the coreStyle field below it — the same failure mode as 26.1's json(), which is now three instances of one pattern: a block comment separated from its member by a later insertion.

    The audit itself gained three fixes found by running it against real code: interface members were being attributed to the class above them (GpuTweenSink.register counted against Animation), prose inside /* */ blocks could parse as a member declaration (the style-getter narrative's literal rgba(...,0); line), and top-level exported functions were not audited at all — adding them widened the surface by 8 public and 104 internal members. Floors: public 100, internal 58. Typecheck, 2292 Node tests, 63 module tests, lint clean.

  • 26.4 The internal subsystems (2026-08-02) — landed: store/, render/, interact/, algorithms/, layout/force-sim and the remaining root files, taking the internal tier to 100% (553/553) and the whole prototype to full public-member coverage. Run as four parallel passes over disjoint directories. ~1,600 lines of comment across 49 files; the whole change set is documentation apart from one safe declaration reorder (moving imagedNodes/imageCount() above the block comment they had been pushed below) and four inline-comment corrections.

    Emphasis was on the rules a newcomer gets wrong: dispatch ordering and which passes observe which writes (cull.mts), what owns a buffer while a lease is live and when it must be handed back (gpu-force.mts), aliasing warnings on every accessor that hands out internal state (outEdges, childrenOf, ColumnTable.column, ImageRegistry.get), the single-consumer drain-per-frame rule that makes dirty-span widening safe, Adjacency.clearNode clearing only the near side so the caller must cascade edges first, IdMap.idAt being the only place a JS string is materialized, and — a real surprise worth writing down — hierarchicalClustering's addDendrogram option mutating the graph (a node plus two edges per internal dendrogram node) rather than just reading it.

    Drift and stranding, the round's recurring find. Eight stranded doc blocks in total across 26.1–26.4 (json(), setSheet(), the GraphStore class doc, setNodeImages, boundingBox, CurveBlob.free, CurveIndex.invalidateRelation, and collection.animate's neighbours) — always the same mechanism: a later insertion lands between a block comment and the member it documents, so the comment silently re-attaches to the wrong thing and the real member reads as undocumented. Nothing catches this but reading, which is the argument for the coverage gate.

    Eight drifted comments corrected (the tally was corrected from six in the 26.6 sweep — see that pass): boundingBox's node-term list (outline, overlay/underlay padding, ghost offsets, labels and the round-22 space tier had all been added since), setLabelFont's "every labelled node" (group-keyed since round 10 — all four label streams), force-sim's convergence test naming a non-existent alphaMin parameter, curved-arrow-pipeline's endUniforms comment listing two buffers where mid-arrows made it four (round 13 C1; the straight-arrow twin was already right), the glyph-struct comment calling word 13 pad when it has carried the round-13 D4 end-label param since D4, a settle() reference the code had renamed to readPositions(), glyph-atlas.setFont's "no-op when the family is unchanged" (the guard compares family, style and weight), and — the most consequential — renderer.mts's frame-graph header describing the scene pass as "edges then nodes then labels, all indirect, no depth buffer" when there is both a depth target and an early-z node prepass, and the real order is prepass → parents → edges/arrows → ghosts → bodies → image/chart/overlay → labels.

    A newcomer reading that header would have had the frame graph wrong. The audit gained overload handling: 26.5's on/one/off overloads made their implementation signatures read as undocumented members, and an implementation signature is not separately documentable — callers only ever see the overloads. Coverage gate tightened from a file allowlist to "no file has an undocumented public member", now that there is no partial file left. Typecheck, 2285 Node tests, 63 module tests, lint clean.

  • 26.5 Shipped declarations for cytoscape (2026-08-02) — landed: rolldown.dts.config.mjs rolls the prototype's declarations up (the existing pipeline, pointed at src/index.mts), build-dts.mjs gained finalizeGpuDts (the gpu entry is ESM-only — the ./gpu export has no require condition — so no export-assignment reshaping is needed, only the UMD global name), build:types builds both entries, and the ./gpu export gained its types key.

    Two tests: the test:types:surface shape audit (default export, the 37-name type surface with no leaks, the three factory statics — expando properties a declaration bundler is most likely to drop silently — and a floor on the JSDoc blocks reaching the shipped file) and typescript/tests/api.test-d.ts, a compile-only consumer test in the existing test:types project. The comment pass pays off here: 1089 JSDoc blocks reach dist/cytoscape.d.ts, so round 26's prose is hover text in a consumer's editor, not just a source-tree nicety — and the shape audit's block-count floor keeps it that way.

    Writing the consumer test found a real type-surface defect the audit could not: cy.on/one/off declared their middle argument as ElePredicate | EventHandler, and a union parameter defeats contextual typing — so cy.on( 'tap', ele => …, cb ) gave ele an implicit any and did not compile under noImplicitAny. Split into explicit overloads (types only; the implementation signature is unchanged), which also matches design call 2: one doc block per signature is exactly docmaker's formats array.

    Recorded, not fixed: event.target is unknown, because the event object is the shared v3 type and v3 stays untouched until release; a v4-specific event type would be a design call, so consumers narrow it for now. Typecheck, lint, and the full test:types:all chain clean.

  • 26.6 Closing docs sweep (2026-08-02) — swept both documents end to end.

    Fixes: the README header now carries round 26 and states the standing constraint that v3's code and documentation/ stay untouched until v4 ships; the follow-up hooks gained a documentation entry that records what deliberately stays open (the docmaker generator and the release docs — neither built until v4 ships, since documentation/ is v3's until then) plus 26.5's logged event.target call; the directory layout gained the round's seven new files, which belong to no single commit and are exactly what this sweep exists to catch; and AGENTS.md gained the convention itself under rule 8 — a contributor to src/ now reads that v4 documents itself in JSDoc, which tags to use, that the banners are the section grouping, that coverage is gated, and that the shipped declarations must be regenerated when the surface changes.

    The round's own record needed correcting, which is the sweep earning its keep: 26.4's entry said six drifted comments where the true count was eight, having missed glyph-atlas.setFont's guard (family, style and weight) and — the one that mattered — renderer.mts's frame-graph header describing the scene pass as having no depth buffer when there is both a depth target and an early-z prepass, with the pass order wrong too. A newcomer starting from that header would have had the frame graph wrong. dist/cytoscape.d.ts regenerated after the final 26.4 comments: 6,840 lines, 1,091 JSDoc blocks.

    Full verification: typecheck, 2285 Node tests, 63 module tests, lint, the whole test:types:all chain (including the two new gpu audits), and 173/173 Playwright across chromium + webgpu + visual (goldens untouched — the round changes no pixels; the webkit projects still cannot launch on this box, the same host-library environment gap recorded in round 25.7). Round 26 is complete.

A Node-suite flake, observed once and unreproduced

Logged so the breadcrumb is not lost, not because it is understood.

During the round-27 docs verification a single npm run test:js run failed one spec; every other run that day passed. What the output showed: a chai deepStrictEqual on an array of element ids containing at least '1''8'. What is known:

  • Not reproduced in 37 subsequent full-suite runs, nor in 60 runs of test/algorithms-clustering.mjs alone.
  • The obvious suspect was ruled out. fuzzyCMeans is the only clustering spec that passes testMode: true without testCentroids, so it falls through to Math.random() centroid initialization (algorithms/k-clustering.mts — the deterministic branch needs both). Its spec is even named "random init". But 5,000 direct trials of exactly that fixture and options produced 0 mismatches: the data is separated enough that random init always converges to the same partition.
  • So the id array probably came from a different spec, and the run order or a shared global (the suite runs files concurrently) is the more likely direction than any one algorithm.

If it recurs, capture the failing spec name — that is the missing piece. Nothing here is a reason to distrust the round-27 results: the same suite passed 37/37 afterwards, and every round-27 claim is additionally pinned by browser specs and live parity diffs.

It recurred once during round 41's verification (2026-08-04), and the name was lost again — this time avoidably. One npm run test:js reported fail 1; the diagnostic re-ran the suite instead of preserving the failing run's output, and the re-run passed, so the spec name went with it. That is the second time this flake has been seen and the second time nothing was learned from it, which makes the method the finding: when a suite fails once, keep that run's output before doing anything else. A hunt of 20 consecutive full runs immediately afterwards (captured to files, precisely so a hit would be readable) produced 0 failures, and ~28 clean runs surround the single failure.

The timing invites an obvious suspicion — it landed in the round that replaced the emitter — and it is not ruled out, because without the spec name nothing can be. What can be said: the emitter swap was already in the tree, 20 consecutive runs of that same tree passed, and the phenomenon predates the swap by two days and one identical symptom (fail 1, unreproducible). That is evidence for "same flake", not proof. If a third sighting comes, the name settles it either way — which is the whole reason to keep the output.

The parity remnants

The tail the README's follow-up hooks have carried since round 13: compound arrow shapes, per-element numeric text-rotation, the unported shape keywords, border-style/outline-style, and v3's nonlinear arrow-size formula. These are the last visual parity gaps against v3 — everything else in the ledger is either landed, dropped by decided design, or an open design call. No new API is invented here: every item is a v3 property or keyword whose direction was set when its family landed.

Code investigation (2026-08-02, precedes this plan):

  • edge.arrowShapes is a full u32. Source shape at bits 0..7, target at 8..15, the two hollow flags at 16/17, mid-source at 18..20 and mid-target at 21..23 — three bits each, which ids 0..7 fill exactly — and the ×16 arrow scale in the top byte. Any new arrow id silently truncates for mid arrows. But v3's whole arrow vocabulary is 12 shapes, so four bits each is enough for all of them: repacking to four 4-bit ids (0..15), the flags at 16/17 and the scale in the top byte fits in the same word with six bits spare and costs no extra memory.
  • The node shape field has room to spare. borderGeom.y packs borderPosition in bits 0..7 and the shape id in bits 16..19 — four bits, 15 of 16 ids used. Bits 8..15 and 20..31 are free, so widening the shape field to a full byte (16..23) is a mask-and-shift change at five shader sites plus the pack site, and leaves room far beyond this round's twelve keywords.
  • Three of the twelve shape keywords are plain polygons in v3 (right-rhomboid, concave-hexagon, cut-rectangle): point tables that the existing SDF codegen, CPU pick and depth prepass pick up for free. The seven round-* keywords are not — v3 builds explicit per-corner arc geometry, and the naive "offset the polygon SDF by r" fails under anisotropic scaling, which is exactly why the README recorded them as deferred. barrel and the triangle-backcurve arrow both need a quadratic-bezier SDF — one primitive, two consumers.
  • border-style needs a perimeter parameter the node FS does not have. The edge shader dashes for free because it carries u (model px along the edge) as a varying; nodeSD returns a bare signed distance and discards the nearest-feature information a perimeter coordinate would be built from. Closed form exists for circles and rectangles; polygons need the SDF loop to also track the argmin edge and its clamped projection.
  • text-rotation is one bit, not a value. The label sidecar stores rotate: boolean, packed as bit 31 of the glyph's owner word, and the angle is derived live on the GPU from the edge endpoints. A numeric value needs a per-glyph f32, and GLYPH_WORDS is 14 with every word used.

Design calls (round 27):

  1. Repack before adding, both times. 27.1 lands the two packing changes on their own, with no new keywords, so the existing suites and goldens prove the repack is a no-op. Adding ids first would silently truncate — both packings are lossy, not loud, which is the argument for doing this as its own pass. The arrow word keeps its single-u32 footprint (four 4-bit ids); the shape field widens to a byte. Recorded cap: 16 arrow shapes, which is v3's vocabulary plus four.
  2. v3's arrow-size formula, in model space. v4 sizes arrows widthPx * 3 + 2 in device px off the LOD-floored width; v3 uses max( pow( width * 13.37, 0.9 ), 29 ) * scale in model units. Port the formula and evaluate it in model px before scaling by zoom, because v3's 29-unit floor is a model-space floor — evaluating it on the floored device width would make far-zoom arrows grow instead of shrink. The quad extents and the store's arrowScaleMax slack meter must grow with it or arrows clip.
  3. Round corners get a real per-corner arc SDF, not an offset hack. Generated in the codegen beside the polygon tables, with the matching branch in cpu-pick.insideShape, so the two consumers agree by construction as they already do for polygons. This is what unblocks all seven round-* keywords plus bottom-round-rectangle at one line each.
  4. One quadratic-bezier SDF, two consumersbarrel's four corner regions and the triangle-backcurve arrow. Built once in 27.5 and consumed by 27.6, which is why the arrow pass comes after the shape pass rather than with the other arrows.
  5. Compound arrows are SDF unions, min( sdA, sdB ), since coverage is a smoothstep over the distance. Recorded deviation: arrow-fill: hollow on a compound shape falls back to filled — the stroke abs( sd ) is wrong at the seam between the two parts, and v3 does not stroke compounds either. triangle-cross shifts with the edge width, which the FS already has as a varying, so its points are computed per fragment rather than read from a static table.
  6. Numeric text-rotation costs a glyph word. GLYPH_WORDS goes 14 → 16 (15 would break the 8-byte struct alignment) and the angle rides as an f32. Node labels gain a rotation path they have never had, which forces two twins to follow: the glyph cull's rotated-rect AABB must read the stored angle instead of reconstructing the autorotate frame, and cpu-pick.mts must gain an OBB test — it currently asserts that node labels never rotate. Recorded: node boundingBox label terms stay axis-aligned-conservative rather than exact.
  7. border-style gets the exact perimeter parameter, gated. u is computed only in the dashed/dotted branch, so a solid border — the overwhelming default — pays a branch, not the extra work. Closed form for circle/ellipse (angle-parameterized on the ellipse, whose arc length is elliptic; recorded as a deviation for eccentric ellipses) and rectangles; the polygon loop tracks the argmin edge and clamped projection against a per-fragment cumulative perimeter. double is not a dash at all — a second inner band, no parameterization needed. outline-style reuses the same u at the ring radius, whose perimeter is offset and therefore a different length.
  8. Goldens are the proof. Every new keyword joins the existing per-shape golden grid, and each family gets a live v3 parity diff where v3 renders it correctly.

Pass split (tests-first; docs in-commit; each pass its own commit(s)):

  • 27.1 The two repacks (2026-08-02) — landed as planned. edge.arrowShapes now carries four 4-bit ids (source, target, mid-source, mid-target), the two hollow flags at 16/17 and the ×16 scale in the top byte, with six bits spare — the same single u32, so no column grew. The layout lives in contract.mts behind packArrowShapes/unpackArrowShape and named shift constants, which the two arrow shaders interpolate into their WGSL rather than restating: one source of truth for a packing that four readers share.

    packArrowShapes throws on an id that does not fit, so the next person to add an arrow gets an error instead of the silent mid-arrow truncation this pass existed to remove. The node shape field widened from a nibble to a byte (SHAPE_SHIFT/SHAPE_MASK, five shader sites plus the pack site), and setBorderGeom throws past the field width too; its hardcoded shapeId === 14 became SHAPE_POLYGON_CUSTOM.

    Tests-first: the three existing specs that pin the bit layout were rewritten to the new one (red), then the code moved (green), and test/packing.mjs adds 8 specs — every id round-trips in all four arrow positions, the positions stay independent, the flags and scale byte stay clear of the ids, over-wide ids throw, a real mid-arrow restyle survives, and the shape field's margin over the enum is asserted rather than assumed. The pass changes no pixels, and that is the point: 2293 Node tests, 63 module tests, typecheck, lint, 87/87 webgpu and 68/68 visual with the goldens untouched.

    Correction, made while landing 27.2: that browser verification was first run against a stale bundle and re-run afterwards before it meant anything. playwright.config.js sets reuseExistingServer: !CI, so with an http-server already listening on 3333 Playwright attaches to it and the test:playwright:build half of test:playwright:setup never runs — the suite exercises whatever was built last, and a green run proves nothing about the change under test. Re-run against a freshly built bundle (with 27.2 in as well), the 68 pre-existing goldens are still byte-identical, so the repack is the visual no-op claimed — but the first run had not shown it.

    The trap is now recorded in AGENTS.md's testing notes, since its only symptom is a pass you did not earn.

  • 27.2 The three unported shape keywords (2026-08-02) — landed, with the plan's own framing corrected: only two of the three are plain polygons.

    right-rhomboid and concave-hexagon are v3 point tables, so they are entries in POLYGON_POINTS and nothing else — the SDF codegen, the CPU pick and the depth prepass pick them up with no per-shape code, which is the payoff of the round-10 table design. cut-rectangle is not a unit polygon: v3 chamfers by an absolute length (getCutRectangleCornerLength() = 8 model px, or the element's corner-radius), so a unit table would make the chamfer scale with the node, which is exactly what v3 does not do. It gets its own SDF — the box intersected with the diagonal half-plane |x| + |y| <= hw + hh - c, whose max of two exact convex fields is itself exact — plus a matching cpu-pick branch. Its 'auto' resolves to a flat 8 px where round-rectangle's is min(w/4, h/4, 8): one prop, two defaults, as in v3, so the shader gained cornerLengthPx over cornerRadiusPx and every one of the five call sites now passes the shape. Tests-first: 11 Node specs (red then green) covering compile/store/readback per keyword, both v3 point tables verbatim, the fact that cut-rectangle is deliberately absent from POLYGON_POINTS, the explicit-radius path, and that an unported keyword still throws.

    A shapes-27 golden shows all three, with cut-rectangle at three sizes under 'auto' — the 24px node is what makes the golden discriminate between the two auto rules, since at 60px they coincide at 8. 2304 Node tests, 63 module tests, typecheck, lint, 87/87 webgpu and 69/69 visual (68 unchanged goldens + the new one), all against a freshly built bundle.

  • 27.3 v3's nonlinear arrow-size formula (2026-08-02) — landed.

    v4 sized arrows widthPx * 3 + 2 off the LOD-floored device width; v3 uses max( pow( width * 13.37, 0.9 ), 29 ) * scale in model units. The formula now lives in arrowSizePx in both arrow shaders and is evaluated in model space before the zoom scale, because v3's 29-unit floor is a model floor — applying it to a floored device width would make arrows grow as you zoom out. Two things had to be got right that the plan did not anticipate: the exact arrow scale lives in the packed shapes word, which is a fragment-visible binding, so a first attempt that read it in the vertex stage produced pipeline-validation errors on every arrow pipeline; the varying now carries the model width and the fragment stage resolves the size (which is also what 27.6's edge-width-dependent triangle-cross will need). And v3's size is the point-table scale, not the drawn length — its transform scales the ±0.15 / −0.3 table by size directly, so the arrow is 0.3 × size long. v4's old code folded that 0.3 into its own constant, and porting the formula without unfolding it made arrows 3.3× too long. The parity diff is what caught both. A new live v3-vs-v4 arrow-sizing test renders three edge widths spanning the formula's floor (1 and 2, where the 29 floor dominates; 6, where the pow term has taken over). The measured arrow extents now match v3's exactly in all three regimes, and the whole-scene mismatch went 4.459% → 0.013% (16 px of pure anti-aliasing). Recorded: the 0.5% golden tolerance was loose enough that the arrow goldens passed both before and after the change, so the goldens alone would never have caught the old deviation — the parity diff is the load-bearing check for anything claiming to match v3. Eight arrow-scene goldens regenerated (the intended visual change); eight label-only goldens that also drifted were reverted, since their scenes contain no arrows and the drift predates this pass — a sub-tolerance glyph-AA wobble worth noticing but not this pass's to absorb. 2304 Node tests, 63 module tests, typecheck, lint, 87/87 renderer, 70/70 visual.

  • 27.4 The round-corner SDF (2026-08-02) — landed, and with a better primitive than the plan called for. The plan proposed porting v3's per-corner arc construction; the identity that makes it unnecessary is that a polygon with every corner replaced by a tangent arc of radius r is exactly the Minkowski sum of the inward-offset polygon with a disc of radius r.

    So the field is sdPolygon( offset ) - r, with the offset vertices in the standard miter form o = v + r · (n1 + n2) / (1 + n1·n2) — and that is exact under anisotropic scaling, which is the precise reason round 13 deferred the family ("corner-rounding an anisotropically scaled polygon has no clean closed form" — the README's recorded deviation, now closed). Winding is folded in at codegen from the signed area, so the shader does no orientation test, and the seven keywords reuse their sharp counterparts' point tables exactly as v3 registers them (ROUND_POLYGON_SOURCE), so the family costs one shared generated SDF rather than seven tables.

    bottom-round-rectangle rides the round-rectangle field with the radius selected by the sign of p.y. cpu-pick gained the matching insideRoundPolygon — note it is not affine-invariant the way the sharp polygons are, so unlike them it must test in device space. The round family's 'auto' is v3's getRoundPolygonRadius = min(w/10, h/10, 8): a third meaning for corner-radius, after round-rectangle's min(w/4, h/4, 8) and cut-rectangle's flat 8 — all three are v3's, not v4 inventions. The parity diff is the proof: a live v3-vs-v4 scene of all seven keywords plus a deliberately stretched node differs by 58 px (0.048%), pure arc anti-aliasing.

    A control run with v4 drawing the sharp shapes against v3's round ones was checked first, to confirm the test discriminates at all, and the scene uses a generous 14px radius for the same reason — at v3's 'auto' the rounded and sharp outlines differ by only ~180px, which would have made a clean result far less meaningful. A shapes-27-round golden covers the family plus the anisotropic case. 17 Node specs; 2311 Node tests, 63 module tests, typecheck, lint, 87/87 renderer, 72/72 visual.

  • 27.5 barrel (2026-08-02) — landed, and the plan's premise turned out to be wrong in a useful way. It called for an exact quadratic-bezier SDF (a cubic solve) shared with triangle-backcurve. But v3 itself approximates: its barrel hit test samples each corner's curve at t = 0.15/0.5/0.85 and runs a polygon test. So v4 rebuilds the outline per fragment — four bezier corners sampled into BARREL_CURVE_SEGMENTS = 4 segments, the same fidelity v3's own hit test uses — and runs the standard exact-polygon distance loop over the result. Sign and distance are exact for that outline; the only approximation is the outline itself.

    Barrel's offsets are size-relative until they hit v3's absolute caps (height 15, width 100), so like cut-rectangle it is a parameterized shape rather than a unit table, and nodeSD gained a zoomDpr argument to resolve them. cpu-pick gained insideBarrel, built from the same constants. Whether the sampling is good enough was measured, not asserted: v3 draws the real quadraticCurveTo, so the parity diff is the answer — four sizes spanning the capped and uncapped regimes differ by 14 px (0.012%).

    At v3's corner offsets the sampled and exact curves are indistinguishable, so the exact bezier SDF was not built. 27.6 will decide triangle-backcurve on its own evidence rather than inheriting the assumption. This completes v3's node-shape vocabulary. A pre-existing spec that used 'barrel' as its example of an unsupported keyword had to be changed to name something that is not a shape at all — there is no unported v3 node shape left.

  • 27.6 Compound arrow shapes (2026-08-02) — landed, built three different ways. triangle-tee is a union of two generated polygons (min( sdA, sdB ) — coverage is a smoothstep over the distance, so a union needs no stitching); circle-triangle is a polygon plus an analytic disc; triangle-cross's bar tracks the edge width rather than the arrow size, so it is computed per fragment from the model-width varying 27.3 introduced — the reason that varying carries the width instead of the finished size.

    And triangle-backcurve needed no new machinery at all: 27.5 established that sampling a quadratic at codegen is indistinguishable from solving it, so its curve is baked into an ordinary point table and it rides the existing generator. The exact bezier SDF the plan reserved for it was never needed. Two real bugs surfaced from the parity diff, not from the suites. The first measurement came back at 0.141% — passing, but an order of magnitude worse than 27.4's and 27.5's, which is what prompted a per-head breakdown rather than acceptance.

    (a) The arrow quad's extent was hardcoded to the plain triangle's 0.3 reach, so triangle-tee (0.5) and circle-triangle (0.6) drew clipped. ARROW_MAX_BACK is now computed from the tables, so adding a head cannot silently clip it again, and triangle-cross's bar adds the edge width on top. (b) v3 pulls circle-triangle back by its circle radius (the shape's spacing — the only head v3 offsets at all) so the disc meets the node boundary rather than the disc's centre; that shift is baked into the points and the disc centre, so it costs no runtime logic. After both: 44 px (0.037%), in line with the round's other heads.

    Recorded deviation: arrow-fill: hollow on a compound head falls back to filled — the stroke abs( sd ) is wrong at the seam where a union's parts meet, and v3 does not stroke compounds either. This completes v3's arrow vocabulary, and as with 27.5's shapes a pre-existing spec had to stop using a real keyword ('triangle-backcurve') as its example of an unsupported one. 2315 Node tests, 63 module tests, typecheck, lint, 87/87 renderer, 74/74 visual.

  • 27.7 Numeric text-rotation (2026-08-02) — landed. Rotation was one bit — autorotate, edge labels only, the angle derived live on-GPU from the edge's slope. v3 also takes a plain number of radians, on any label. The encoding is the interesting call. The stored value is the angle in radians with NaN meaning autorotate. That works because 'none' and a rotation of 0 radians are the same rendering, so collapsing them costs nothing — and it leaves the whole real line free for numeric values, where an enum id would have collided outright: autorotate's id was 1, and 1 radian is a perfectly ordinary rotation (pinned by a spec).

    GLYPH_WORDS went 14 → 16. 15 would hold the data but breaks the struct's 8-byte alignment, and the alternative — a per-owner storage buffer — was rejected because the edge label pipeline is already at 7 storage buffers against a base limit of 8. Recorded cost: 64 bytes per glyph instead of 56, ~14% on the heaviest stream. Node labels gained a rotation path they never had (the VS now takes one branch for both modes), the glyph cull computes the exact rotated-rect AABB from the stored angle on both the node and edge streams, and cpu-pick gained an OBB test — it previously asserted in a comment that node labels never rotate, which stopped being true here. autorotate stays edge-only and now says so in its error message. The parity test had to be rebuilt to mean anything. The first version — four modest labels at small angles — passed at 0.514%, and then passed at 0.672% with v4 ignoring rotation entirely. A test that cannot fail is not evidence. The scene is now ink-dominated (40px text, ±90°/±45°): 2.3% with rotation honoured against 5.8% for the same control, which fails the bound. The floor is glyph rasterization, not placement — canvas vs SDF — which is why this one bound is 3% where the shape diffs sit near 0.05%. 13 Node specs; 2328 Node tests, 63 module tests, typecheck, lint, 87/87 renderer, 75/75 visual.

  • 27.8 border-style / outline-stylestopped for a scope call (2026-08-02), not for a technical blocker. The technique is settled. double is not a dash at all — a second inner band, no parameterization needed, and it works on every shape. For dashed/dotted the existing dashInsideSd machinery is reusable verbatim; the only missing ingredient is a perimeter coordinate in the node fragment shader, which comes in three tiers of cost:

    • closed form, cheap: circle (exact — θ·r), rectangle and round-rectangle (walk the sides plus corner arcs, ~30 lines);
    • closed form, approximate: ellipse, whose arc length is an elliptic integral — angle-parameterizing it makes dashes unevenly spaced on eccentric ellipses, a recordable deviation;
    • real work: the polygon family (the round-* shapes, barrel and the custom polygon included), where the SDF loop must also track the argmin edge and its clamped projection against a per-fragment cumulative perimeter — roughly doubling the polygon fragment cost wherever a dash is enabled. The 2026-07-28 gap ledger flagged this family as "needs a scope call on which subset earns its shader/channel cost", and that call is still open: shipping dashed/dotted on circle/rect/round-rect only is a genuine v3 deviation (v3 dashes any shape), while covering every shape is the round's largest single piece of shader work for a property with no other consumer. Deciding that unilaterally would be improvising API scope, so it waits. Everything else in round 27 landed.
  • 27.9 Verification (2026-08-02) — the golden grids and parity diffs landed with their own passes rather than in a trailing sweep, which is why each was able to change the code: 27.3's diff caught two wrong ports of v3's arrow formula, 27.6's caught a clipped arrow quad and a missing offset, and 27.7's first version was rebuilt after a control showed it passed with the feature disabled. Five new live parity tests in total (arrow sizing, the round family, barrel, compound arrowheads, text rotation), two new goldens, and three golden grids extended.

    Costs: benchmark/labels.mjs re-run at 100k — breakLines 3.8 µs, estimateBlock 4.6 µs, setLabel build 5.1 µs, the whole-graph bb's label terms 0.11 µs — all matching the round-16 baselines, so 27.7's wider glyph instance costs nothing on the CPU side. Its device-side cost is arithmetic and recorded: 64 bytes per glyph instead of 56. Not measured here: the device-side frame cost of the new shader branches. Correction (2026-08-03): the reason given for that was wrong.

    This record said benchmark:renderer "requires a real adapter and this box has only SwiftShader" — the box has an AMD RX 580 (RADV POLARIS10, alongside an Intel UHD 630), which is the same hardware the 2026-08-01 validation pass benchmarked on, and the benchmark harness does get the hardware adapter. Only the golden project pins SwiftShader, deliberately, and only for the WebGPU adapter. This is the second time that conclusion has been reached and corrected: the 18.5 note claiming a software-only adapter was corrected by the same hardware pass, which traced it to requestAdapter() returning null on about:blank — probe from a served page.

    The measurement was therefore skipped, not blocked. Answered by round 29.5 (2026-08-03): re-run on the RX 580 against the pre-round-27 baseline, every stable device row moved +0.3% to +3.6% — the label rows at the top of that band, consistent with 27.7's wider glyph instance, and the shape and arrow branches invisible. Round 27 cost nothing measurable per frame.

  • 27.10 Closing docs sweep (2026-08-02) — swept both documents for the round's vocabulary. The README header carries round 27; the node-shape section now records the completed vocabulary, the three parameterized shapes, and the fact that one corner-radius prop carries three different 'auto' rules (all v3's); the arrowhead section records the compound heads, how each is built, the hollow-fallback deviation, and v3's sizing formula with its model-space caveat; the label section records numeric text-rotation and its glyph-memory cost; and the border-geometry note now explains why border-style is unported (the missing perimeter coordinate) instead of just asserting it.

    Corrected while sweeping: the shape section still said the round-* family had "no clean closed form" under anisotropic scaling — 27.4 found one, so leaving that note in place would have discouraged exactly the work that closed it. The follow-up hooks now list border-style/outline-style as the single remaining parity item, waiting on a scope call rather than on a technique. Amended after a second pass (2026-08-02): this sweep did the README end to end and stopped there, leaving this file's gap ledger still asserting that the shape keywords, the compound arrow shapes and numeric text-rotation were unbuilt — three things the round had just built — and the "Suggested sequencing" summary still listing them as remaining. Items 4, 5 and 6 of the needs-a-call ledger and the sequencing paragraph are now true up; the directory layout picked up round 27's changes to shape-points.mts and contract.mts; the README's JSDoc-coverage paragraph still described round 26.1's file-allowlist gate rather than the 100%-everywhere rule 26.4 replaced it with; and both documents now record the round's most transferable finding — that a golden answers "did this change?" while only a parity diff answers "is this right?", and that a parity test should be run once with its feature disabled to prove it can fail. The standing process rule above gained "sweep this file too". A read-the-code verification pass over the README's factual claims (13 checked against source, most confirmed) turned up three that were wrong, all now fixed: the curved-edge section recorded a deviation that does not exist — it said v3 staggers an unbundled-bezier without control-point-distances by the unbundled pair group and that v4 does not port it, when v3's edgeIsUnbundled branch assigns the plain step size and its staggered normctrlptDist is dead on that path, so the two agree; the event section said event.preventDefault() "stays unported" with originalEvent keeping the DOM method, when in fact the method is present (v4 emits the shared v3 Event) and silently does nothing because no v4 code reads isDefaultPrevented, while originalEvent is never populated at all — the old wording told a reader to reach for a route that is not there; and "round 26 took both tiers from 46%" conflated a combined figure with per-tier ones (43% public, 55% internal). Two more findings from the second pass, both outside the docs themselves: the debug harness (debug/init.js) carried allowlists that silently dropped any shape outside ellipse/rect/round-rect and any arrowhead but triangle when converting a v3 fixture stylesheet — stale since round 10, and now inverted into a much shorter list of the v3 spellings v4 does not accept. And that shorter list exposed a small inconsistency worth a call rather than a silent patch: the 2026-07-29 triage dropped the no-dash legacy aliases, yet roundrectangle is still accepted while cutrectangle and concavehexagon are not. Recorded in the README next to the shape vocabulary. Round 27 is complete apart from 27.8, which is held for that call.

The no-call remainder

Round 27 is complete apart from 27.8, which is held for a scope call on which shapes border-style/outline-style covers. This round is the opposite kind of work: the items left in the ledger that need no design call at all, because the behaviour is already decided and either implemented-but-unverified or plainly absent. No API is invented here and no scope is widened.

Finding (2026-08-03, precedes this plan) — round 27's shapes have no CPU-pick coverage, and three specs that look like they provide it do not.

Round 27 added three branches to render/cpu-pick.mts: the cut-rectangle chamfer (27.2), insideBarrel (27.5) and insideRoundPolygon (27.4). None of them is exercised by any test. test/cpu-pick.mjs — the harness that actually drives the pick path against the store — stops at round 10's polygon family.

Three specs in test/shapes-27.mjs are named for picking and assert something else entirely:

  • 'picks by its slanted outline, not its bounding box' (right-rhomboid) → asserts boundingBox().w === 100
  • 'picks inside the body and outside the cut corners' (cut-rectangle) → asserts boundingBox().w === 100
  • 'picks inside the body (the rounded field agrees with the sharp one there)' (round-hexagon vs hexagon) → compares two bounding-box widths

All three hold for every shape keyword, because the bounding box is the node box regardless of shape. Each spec's comment describes the pick property it means to check — a miss at the cut corner, a hit at dead centre — and then never calls a pick path. Measured, not assumed: with the shape under test swapped for ellipse (and the round-vs-sharp pair swapped for ellipse-vs-star), the file still passes 20/20. barrel has no pick spec at all.

Why this matters beyond the missing assertions: the shapes' whole correctness argument is that the shader and the CPU replica are dual consumers of one description, agreeing by construction — and only the shader half is pinned, by round 27's live parity diffs. The CPU replica is a separate implementation in a different language, and 27.4 explicitly recorded that insideRoundPolygon is not affine-invariant the way the sharp polygons are, so it must test in device space. That is the single most breakable property in the round, and nothing tests it at any zoom.

This is the same failure mode 27.7 caught in its own parity test — a test that passes with the feature disabled is not evidence — occurring inside round 27's Node suite rather than its browser suite. The generalized rule is now in AGENTS.md: it applies to plain unit specs, not just parity diffs.

Two smaller items, same no-call character:

  • cy.animate({ panBy }) — the viewport animation accepts pan, zoom, fit and center (animation.mts), but not v3's panBy. cy.panBy() itself exists (core.mts). The semantics are unambiguous (target = the pan captured at start, plus the delta), so this is mechanical. Ledger item 12. (Landed as 28.2 — and v3 resolves the delta at creation rather than at start, which is the rule v4 kept.)
  • Ledger drift in item 12 itself — it lists cy.window() as a gap, but that method exists (core.mts, with a "v3 parity" doc comment), and it lists "layout instances as event emitters" as open, when round 17 decided layout instances stay non-emitters (recorded in the README's extension-contract section). Docs only. (Fixed in 28.3, which also narrowed the wire-format entry: cy.json() already exports graph-level data, so only the binary format is in question.)

Explicitly not in this round (each needs a call, and saying so is the point): border-style/outline-style (27.8's scope call); the roundrectangle alias inconsistency; cy.gc() and cytoscape.warnings() (both are "does v4 want this at all", and compaction already covers gc); and graph-level data in the binary wire format — cy.json() already exports it, but serializeElements is elements-only and its output feeds cy.add(), which raises whether adding elements should overwrite the target's data(). Also still open: the device-side frame cost of round 27's new shader branches (27.9).

That one was recorded as blocked on hardware, which was wrong — this box has an AMD RX 580, the same device the 2026-08-01 hardware validation pass benchmarked on, and benchmark:renderer reaches it. It is a measurement nobody has run, not one that cannot be run here; see the correction in the 27.9 entry.

Pass split (tests-first; docs in-commit; each pass its own commit(s)):

  • 28.1 CPU-pick coverage for the round-27 shapes (2026-08-03) — landed, and the controls are the part worth recording: two of the five new specs did not discriminate on their first version, which is the same defect the pass exists to fix, caught this time because the control was run before the commit rather than after. The specs live in two places by design.

    test/shapes-27.mjs gets the keyword-level ones, which run the whole public path — the sheet compiles, the style engine writes borderGeom, pickNodeAt reads the stored words — and each case is chosen to be a hit for rectangle (or, for the round family, for the sharp counterpart), with that control asserted inline in the same spec. cy.pick() itself resolves null on a headless instance, so these call the pick path directly; that is what the three replaced specs had backed away from into boundingBox() assertions.

    test/cpu-pick.mjs gets the branch-level properties, aimed at what is particular to each branch rather than at re-checking that a shape has an inside: cut-rectangle's chamfer holding at a flat 8 px as the node grows 100 → 400 px (a size-relative chamfer would put the boundary 24 px away) and its explicit corner-radius path; barrel's height offset capping at 15 px, shown by the same relative point picking differently at 100 and 600 px tall; and bottom-round-rectangle's asymmetry, whose two assertions fail for rectangle and for round-rectangle respectively.

    The round family's spec is the one that needed rebuilding. Its point is that insideRoundPolygon is not affine-invariant — the radius is a device-px length that must scale with the zoom — and the first version asserted a miss at a point that was already outside the sharp hexagon, so it held under both controls. The rewritten spec picks model (-199, -2) on a 400 px round-hexagon: inside the sharp polygon, outside the rounded one, and at zoom 2 the case that separates the correct 16-device-px radius from the 8 px an unscaled cap would give.

    Controls run, each by patching cpu-pick.mts and re-running: cut-rectangle → plain rectangle (3 specs fail), barrel → plain rectangle (2 fail), round-* → the sharp polygon test (2 fail), and the radius cap left unscaled by zoom (1 fails — the one written for it, and nothing else, which is what a targeted spec should do). 7 new specs; 2335 Node tests, 63 module tests, typecheck, lint. No source changed, so the browser suites are unaffected.

  • 28.2 cy.animate({ panBy }) (2026-08-03) — landed as planned, in _resolveViewportTargets beside fit/center: the delta resolves against the pan at creation, which is v3's own rule (v3/src/define/animation.mts normalizes panBy against cy.pan() when the animation is created, not per tick), so by the time the tween runs it is an ordinary absolute pan target and needs no new channel, no new capture path and no interaction with the round-21 concurrency rules or the round-24.3 controls. One ordering detail was worth getting right: animate() gated on opts.pan before resolving, so a panBy would have slipped past a disabled panningEnabled. It now resolves first and gates on the resolved target, which is a no-op for every existing path. Precedence follows v3's override order — fit beats center beats panBy beats pan — with one deliberate deviation, recorded: passing panBy and pan together throws, where v3 silently preferred panBy. The two spell one channel and guessing is the kind of thing v4 rejects loudly elsewhere (queue, step, unknown query keys). Tests-first: 5 specs in test/viewport-animation.mjs (the delta; creation-time resolution, pinned by panning away before play(); the panningEnabled gate; the throw; and fit winning over panBy), 4 red before the change. 2340 Node tests, 63 module tests, typecheck, lint, JSDoc coverage 100%. AnimateOptions is public surface, so dist/cytoscape.d.ts is regenerated and npm run test:types:surface re-run; dist/cytoscape.d.ts (v3) is untouched.

  • 28.3 Ledger drift + closing docs sweep (2026-08-03) — item 12 is rewritten: cy.window() and (now) panBy move to the landed side, layout-instance emitters move to the decided side (round 17 settled it — it was never a gap), and the wire-format entry is narrowed to what is actually missing, since cy.json() already exports graph-level data. What survives there is three entries that each need a call. The "Suggested sequencing" summary gains a round-28 paragraph, and the standing rule's own warning applies to it as much as ever: it is the second place in this file that outlives the work it describes.

    The README carries round 28 in its header, records panBy in the viewport-targets bullet with the pan/panBy deviation, records the round-27 shapes' CPU-pick twins in the shape section, and its follow-up hooks now list only open design calls. Verification for the round as a whole: 2340 Node tests, 63 module tests, typecheck, lint, JSDoc coverage 100%, test:types:surface with the regenerated declarations. The browser suites were not re-run for 28.1 or 28.3 (tests and docs only); 28.2 touches the viewport animation path, whose coverage is the Node suite. Round 28 is complete.

Test and benchmark hardening

Round 28 closed the ledger's no-call remainder. This round comes from a different question — not "what is unbuilt" but "what is unpinned": a survey of src (49k lines; 121 Node spec files and 14 benchmark suites at the time — 123 and 15 after this round) for behaviour that exists, is documented, and is measured or asserted by nothing.

Survey method and what it ruled out, since the negative results are worth as much as the findings:

  • Module-level coverage is not the gap. Mapping every src module to test files that import it shows ~50 with no direct importer, but almost all of those (the algorithms, the layouts, core/collection themselves) are exercised through the public entry point, which is the right way round. Nothing was added on this basis.
  • The round-27 vacuous-spec defect looks isolated. A scan of all 121 gpu spec files for specs that assert nothing, or whose name promises a behaviour their body never invokes, produced 64 hits and no new real ones — the NO EXPECT hits are helper-wrapped assertions (close() in test/curve-geometry.mjs, the throws() helpers in test/mappers.mjs) and the rest are false matches on substrings (betweennessCentrality contains "tween"). The three specs 28.1 fixed remain the only known instances.
  • The binary wire format is already priced (deserialize ~5 ms, ndex-x-large load 106 → 68 ms — the pass-1 record), so the serialize/bulk-load benchmark this survey first proposed was dropped.

Findings (as surveyed 2026-08-03, before the passes below). Each was true when written and each is what the matching pass then closed — read them as the round's starting state, not its current one:

  1. The alias surface is 83 methods wide and 29 of them are never called by any test. declare each: this['forEach'] is a type declaration; the runtime wiring is a separate Collection.prototype.each = Collection.prototype.forEach line. Deleting a wiring line leaves the typecheck green — the declare keeps asserting the method exists — and breaks the alias at runtime with nothing to catch it. All 83 are consistent today (verified by parsing both sources); the point is that nothing keeps them that way.

    Untested ones include centre, deselect, each's siblings point/points/modelPosition/modelPositions, renderedCss, renderedBoundingbox, jpeg, invalidateSize, the British spellings (allAreNeighbours, degreeCentralityNormalised, closenessCentralityNormalised) and four algorithm aliases.

  2. Four public methods have zero mentions anywhere in the suite: silentPositions, silentShift, delayAnimation and renderedOuterHeight — the last a plain sibling gap, since renderedOuterWidth is tested one line away in test/collection-dimensions.mjs.

  3. The decided-design drops are barely pinned. "No selector strings, anywhere" is v4's most load-bearing API decision, and the only specs asserting it are three in the algorithms files (bfs({ roots: '#a' }) and friends). Nothing asserts that a selector string is rejected by cy.filter/cy.nodes/eles.filter, that cy.$ is absent, that classes are gone, or that z-index is rejected by the sheet. A decision that is not pinned is a decision that regresses back in by accident.

  4. Curved-edge CPU derivation is unpriced. Round 12 benchmarked the GPU frame cost (the renderer bench's curved pan scene) but nothing measures the CPU side: the parallel-edge bundle map, the per-edge control-point derivation, the bundle re-fan show()/ hide() triggers, the curve-aware accessors, and the curve-hull term in bounds and box selection. That work runs on every endpoint move at graph scale, and curve-geometry.mts + curve-index.mts are 2.5k lines of it.

  5. The renderer benchmark has not been run since round 27 — and 27.9's reason for not running it was wrong (see its correction: this box has an RX 580). The last recorded device numbers are from the 2026-08-01 hardware pass, before round 27 added shader branches to the node and arrow paths.

Pass split (tests-first where there is code; docs in-commit):

  • 29.1 The alias surface (2026-08-03) — landed. 91 specs in test/aliases.mjs: 83 identity checks (the alias exists, is a function, and is === its target on the prototype), 6 that reach the aliases through a live instance (a class field or own property could in principle shadow the prototype, which the identity check alone would not see), and 2 that cross-check the table against the sources in both directions. All 83 were already consistent — the pass adds no fix, it adds the thing that notices. Controls: deleting the each wiring line fails 2 specs (the identity check and the instance check), and declaring an alias that the table does not list fails the source cross-check.

    Both were run. 2431 Node tests, typecheck, lint. (Original plan text below.)

    29.1 The alias surface. One spec file walking an explicit table of every alias → target pair, asserting the alias exists, is a function, and is identical (===) to its target on the prototype, plus a meta-check that the two sources declare exactly the tabled set — so adding an alias without listing it fails, and deleting a wiring line fails. The table doubles as the written record of the alias surface.

  • 29.2 The four unmentioned public methods (2026-08-03) — landed, extending the files that already own the surface rather than adding a parallel one: silentPositions, silentShift (both forms) and renderedOuterHeight in test/collection-dimensions.mjs, delayAnimation in test/animation.mjs. The silent specs assert the silence — a position listener counts zero — and then fire the loud sibling in the same spec, so the zero is the method's doing and not a listener that was never wired.

    One finding while writing them: outerHeight() on a 20 px node with a 5 px border is 25, not 30 — v4 keeps v3's outerHalf convention under the default centred border position, so half the band lies outside. The first draft of the spec asserted 30 and failed, which is the spec doing its job on its first run. Controls: making either silent method loud fails its spec, leaving renderedOuterHeight in model units fails two, and giving delayAnimation a real channel makes it evict the concurrent animation and fails the no-channels spec. 8 new specs. (Original plan text below.)

    29.2 The four unmentioned public methods. Behavioural specs, not smoke: silentPositions/silentShift must move nodes without emitting position events (the whole point of "silent"), and delayAnimation must delay without touching any channel.

  • 29.3 Decided drops stay dropped (2026-08-03) — landed, and it turned out to be a fix pass as well as a test pass: writing the specs found three places where a decided-design removal was accepted and then failed somewhere else, or not at all.

    • Event delegation with a selector string (cy.on('tap', 'node', cb)) was wrapped as a predicate without a check, so it registered cleanly and then threw qualifier.fn is not a function inside the emitter, on the next tap — during emit, so it takes the dispatch down with it. The guard now lives in predicateQualifier, the one choke point on/one/off share.
    • A style group written as a function was silently ignored: style: { nodes: ele => ({ ... }) } compiled to nothing and the graph rendered with defaults, no error. A v3 sheet ported wholesale therefore looked like a rendering bug. setSheet now throws, naming mappers and case as the replacement.
    • The collection methods crashed on other._refs — or, in same()'s case, quietly returned false, which reads as working code. A shared assertCollection guard covers all twelve (same, anySame, contains, allAreNeighbors, the four set ops, diff, indexOf, edgesWith, edgesTo). Also improved: a selector string reaching compileQuery reported "Unknown query key '0'" — its own character indices read as keys — and now says what actually went wrong. Every message names the v4 replacement ($id, a query object, a predicate). test/decided-drops.mjs then pins the ledger: selector strings at every entry point, the absent class methods and cy.$, the sheet's rejection of z-index and the 2026-07-29 triage drops, the no-dash shape spellings (with roundrectangle's survival pinned as the recorded inconsistency, so the line has to change when the call is taken), the bypass setter, json(obj), custom easing functions, and queue/step. 16 specs, each citing the ledger entry it pins. Verification: 2453 Node tests, 63 module tests, typecheck, lint, JSDoc 100%, and — because this pass changes source — 87/87 webgpu and 75/75 visual against a freshly built bundle (an http-server was already listening on 3333, which is exactly the stale-bundle trap, so the build was run by hand first). (Original plan text below.)

    29.3 Decided drops stay dropped. A spec file pinning the design ledger's rejections at the API boundary: selector strings on every query entry point, cy.$, classes, z-index in a sheet, and the per-element bypass setter. Each assertion cites the ledger entry it pins.

  • 29.4 A curved-edge CPU benchmark (2026-08-03) — landed as benchmark/curves.mjs, standalone and gpu-only like labels.mjs. Every row runs the same operation on a straight graph of identical shape, so the printed number is the curve premium; the scene is 4 parallel edges per node pair, so an endpoint move re-fans a whole bundle. The headline is that curve derivation is deferred to the first read, and the benchmark had to be corrected twice before it showed that rather than hiding it. First: whichever side was measured first paid the module's JIT warmup, which inflated the curved side's premium (a drag read 2.52× and settled at 1.16× once both sides warm up). Second: two rows came back at ≈1.0×, and rather than report "curves are free" the rows were checked — a bulk positions() write really is free (0.97×: the write defers), but hide()/show() was measuring a flag write, because the bundle re-fan it triggers is deferred like every other derivation. Reading a sibling inside the loop moved it to 3.79×. Numbers at 20k nodes / 40k edges: box selection 3.29× (the exact curve-vs-rect test) — wrong, corrected to ~2.3× by round 33.5: the row passed a box object to cy.elementsInBox, which takes four numbers and answers the empty collection when handed one, so it never ran the test it names — re-fan 3.79× (~5.2 µs per hide/show pair; 2.66–2.98× on re-measurement, this suite being single-shot rather than sampled), controlPoints() 1.57×, drag 1.46×, first read after a bulk move 1.46× against 1.22× warm, build 1.18×, exact whole-graph boundingBox() 1.16×, midpoint() 1.15×, conservative fit() scan 1.05×. Recorded in the README's curved-edge section beside the design it prices. (Original plan text below.)

    29.4 A curved-edge CPU benchmark (benchmark/curves.mjs), standalone and gpu-only like labels.mjs: bundled-bezier build, node-drag re-derivation at bundle scale, the accessors, bounds and box selection over curved edges, and the re-fan triggers — each against the straight-edge baseline, so the number reported is the curve premium, not the ambient cost.

  • 29.5 The renderer benchmark on the RX 580 (2026-08-03) — run, and it answers 27.9: round 27's shader branches cost nothing measurable per frame. Device p50 (timestamp-query, the unbounded metric) against the pre-round-27 baseline of 2026-08-01 19:42, same box, same flags, four generated 25k × 50k scenes × five passes: every stable row moved +0.3% to +3.6%, most under +2%.

    The label rows sit at the top of that band (+1.5–2.0%), which is the expected shape of 27.7's wider glyph instance (GLYPH_WORDS 14 → 16, 64 bytes per glyph instead of 56); the shape and arrow branches are invisible. Wall time is the vsync floor (16.7 ms) on every generated scene and view, as before. One row is not a signal, and saying so is the point. The compound scene's fit-all pair first read −29.9% against the baseline — far larger than any plausible effect of this round — so it was re-measured rather than reported. A repeat run put the same row at 2.11 → 3.00 ms (+42%) with every other row reproducing to ±0.02 ms. The compound fit-all device rows are bimodal run-to-run at the ±40% level; they cannot carry a regression claim, and the −30% "improvement" was noise in the other direction. Recorded in the README's renderer-benchmark section so the next reader does not build on it. (The --layout mode and the ndex and 100k scenes were not re-run: round 27 touched neither layout nor anything scene-size-dependent, and the flat/curved/compound/images 25k set is where its node and arrow shaders live.)

  • 29.6 Closing docs sweep (2026-08-03) — the README header carries round 29; the alias surface is described in the API-scope section, the boundary enforcement in the two design-decision bullets it belongs to, the curve premium beside the curved-edge design it prices, and the renderer bench's noisy rows in the benchmark section. This file's "Suggested sequencing" summary gains the round-29 paragraph — the standing rule names it as one of the two places that drift every round — and 27.9's "not measured here" now points at the answer.

    Logged, not acted on: six benchmark suites (compaction, labels, transitions, geometry-tween, compound, and now curves) are standalone and absent from report.mjs's job table, so they only ever run by hand. That matches how their rounds used them — each is a one-round sweep with its own scale — but it does mean the HTML report understates what exists. Worth a decision when someone next touches the report. Round 29 is complete.

The error contract

Round 29 asked what is unpinned rather than what is unbuilt, and worked four answers (the alias surface, four unmentioned methods, the decided drops, the curve premium). This round continues that axis on the part of the surface v4 talks about most and tests least: what it throws.

"Fail loudly" is a stated v4 policy — an unknown sheet key, an unknown style property, an unknown query key and an unknown boundingBox() option all throw on the reasoning that a typo must not silently do nothing. 29.3 pinned the decided-drop subset of that policy at the API boundary. Nothing has ever measured the rest.

Method, and why the first measurement was wrong. Every throw new in src was mapped against V8 coverage of the Node suite. The first attempt read raw NODE_V8_COVERAGE offsets against the .mts sources and reported 47 dead sites — including arrow-scale must be positive and not a valid font-family, both of which have had throw specs since round 13. tsx transpiles before V8 sees the file, so those offsets belong to the transpiled text and the mapping was fiction.

The measurement that stands runs the suite under --enable-source-maps --experimental-test-coverage --test-reporter=lcov and reads source-mapped DA: line counts; it puts the two known-tested sites back in the covered column, which is the check that makes the rest believable.

Finding: 191 throw sites in src, 34 never executed by the 2453-test Node suite. ~20 are Node-testable, ~14 need a browser. The list repeats defect shapes rounds 28–29 already named:

  • Sibling gaps (29.2's renderedOuterHeight shape). GraphStore.addEdge throws on a nonexistent source and, four lines later, on a nonexistent target; only the target throw has ever fired in a test. renderedSourceEndpoint is tested; renderedTargetEndpoint is called by nothing.
  • Decided-drop enforcement, half-pinned (29.3's own theme, one file over). bfs's options form rejects a selector string and a spec pins it; the positional form (bfs('#a')) rejects it eight lines later and nothing fires that. The breadthfirst layout's roots rejection fires nowhere at all.
  • A README headline, unasserted. "the factory throws synchronously when navigator.gpu is missing" is the first thing the README says about headless mode. index.mts checks options.container != null before touching the DOM, so the throw is reachable from Node — and no spec has ever taken it.
  • Public API never called: cy.stop() (the viewport form). The suite calls ani.stop() and ele.stop() only.
  • Untested public options: the clustering distance metrics squaredEuclidean and max (specs pass euclidean, manhattan and custom functions only).
  • Five cy.png()/jpg() guards — invalid bg, a full export of an empty graph, a zero-sized container, a destroyed renderer, an invalid scale — public contract, browser-testable.
  • Style validation (5 parser paths), the wire format's corrupt-buffer guards, mount()'s two guards, and the contract.mts / table.mts column guards.

Negative results, recorded so they are not re-run.

  • All six standalone benchmark suites still run (compaction, labels, transitions, geometry-tween, compound, curves at BENCH_N=2000, exit 0 apiece). 29.6's open call is about the report's job table, not about bit-rot.
  • The public-member survey is clean after 29.1/29.2. Re-run over 406 members of the public sources, the only zero-mention names left are renderer-interface internals (requestRender, forceActive) and aliases whose targets are tested. That axis is harvested.
  • Function-level coverage is not usable here. FN:/FNDA: records misattribute one-line arrow lambdas — the style prop table's 429 set/default/parseEnum closures report 155 as never-called while their props demonstrably round-trip in specs. Statement-level (DA:) data on multi-line throw bodies is sound; function-level data is not, and no finding here rests on it.

Pass split (tests-first; docs in-commit; each pass its own commit(s)):

  • 30.1 The Node-testable throw sites (2026-08-03) — landed. 20 specs across nine files, each in the file that already owns its surface (29.2's shape, not a parallel error-test file), and the measurement moved 34 never-executed throw sites → 14.

    Every one of the 14 that remain is browser-only (renderer.mts's five export guards, gpu-context, column-mirror, glyph-atlas, gpu-tween, image-decoder) or unreachable by design (the SHAPE_MASK field invariant; the big-endian platform guard). *(Read 15, not 14, from 30.4 onward: renderer.mts has a sixth export guard — exportScale's — which the raw line data reported as covered because it sits in a module-level arrow const. 30.4's calibration found it and moved it into the browser tier, which is why 30.2 pins six guards where this entry counts five.) Every Node-reachable throw in src now runs in the Node suite. What landed, by surface: the style parsers' five guards (the wrap family's shared keyword closure, gradient stop percents on both fills, the image enum shared by five props, the background-width/ -height sign check, and the endpoint point form's per-component regex); addEdge's source guard and its group-awareness; the two column guards of the co-signed contract (columnSpec on an unknown id, a table asked for the other group's column); the wire format's two malformed-input guards; the closenessCentrality/ degreeCentrality root preconditions; Karger-Stein's connectivity guard; the two selector-string rejections 29.3 missed; the headless/rendered boundary's four guards; and GlyphBuffer's stride check. Controls were run for all 20 — each guard neutered in place (throwif( false ) throw), the owning spec re-run, the source restored — and one came back BAD, which is the pass earning its keep. cytoscape({ container: {} }) throws with the factory's own navigator.gpu check deleted, because the renderer attach path 25 lines below carries an identical check with an identical message. The two are not redundant — the early one is a fail-fast before new Core, element ingest and the ctor layout run — so the spec now pins that ordering: it constructs with a container and a payload that would itself throw during ingest, and asserts the container problem is the one reported. With the guard restored it passes; with it deleted the ingest error surfaces instead and the spec fails. Two findings worth keeping from writing them: Karger-Stein's "connected (sub)graph" throw is not a disconnected-graph detector (two internally-connected components reach two meta-nodes without exhausting the edge list and return a result) — what it catches is a contraction that runs dry, which a subgraph scope holding nodes without their edges reaches; and breadthfirst resolves its roots at run(), not at cy.layout(), so the first draft of that spec asserted a throw from the wrong call. No source changed, so the browser suites are unaffected. 2473 Node tests (+20), 63 module tests, typecheck, lint.

  • 30.2 The image-export guards (2026-08-03) — landed: 4 specs in the renderer project covering all six throws of the export path (the plan said five; exportScale's own guard is a sixth, in a module-level helper rather than in computeExportView). These are public contract — bg and scale come straight from the caller, and the other four are states a real app reaches: an empty graph, a display: none container, a figure scaled past the device's texture limit, and a destroyed renderer. Each spec asserts the message, not just the rejection, because four of the six live in one method and a bare rejection would not say which fired.

    Two carry a control in the same spec that separates the guard from "exporting is broken": the empty-graph case pins that the viewport export of the same empty graph still resolves, and the zero-sized-container case pins that the full export does — it measures the graph, not the container, so the guard is specific to the viewport branch. Controls: each of the six neutered in renderer.mts, the bundle rebuilt, the specs re-run — one failure apiece, six for six. The bundle was rebuilt by hand before every run: an http-server was already listening on 3333, which is the stale-bundle trap AGENTS.md describes, and these specs are worthless against a stale bundle. 91/91 webgpu (87 + 4).

  • 30.3 The untested public surface (2026-08-03) — landed, 9 specs in the three files that own the surfaces. cy.stop() (3 specs, test/viewport-animation.mjs): ani.stop() and ele.stop() were tested and the core sibling was called by nothing. Both arms are pinned, since the difference between them is the whole point of the argument — the default freezes the viewport where the tween reached and it stays there, stop( true ) applies the targets — plus the promise resolving and the idle call being a no-op. One drafting note: the first viewport emit can land at t = 0, so the specs wait for actual movement rather than for the event.

    renderedTargetEndpoint (1 spec, test/curve-12c-accessors.mjs): 29.2's renderedOuterHeight shape exactly — the source twin has been tested since 12c. The spec asserts the transform and that the answer is the target end, which is what a copy-paste from the sibling would break. The clustering metrics (5 specs, test/algorithms-clustering.mjs): every existing clustering spec passes euclidean, manhattan or a custom function, so squaredEuclidean and max — public option values — ran nowhere.

    The specs assert the arithmetic through the exported clusteringDistance (p = (0,0), q = (3,4) separates all four metrics: 5, 25, 7, 4) rather than through a clustering run, because a run can land on the same partition under several metrics and would not notice one silently resolving to another; v3's two alternate spellings and the documented silent fallback for an unknown name are pinned beside them, with one end-to-end kMeans spec for the option plumbing.

    Controls: 6 mutations run (stop made a no-op, then made always-jump-to-end; renderedTargetEndpoint pointed at the source end, then at model space; squaredEuclidean given the square root, max made a sum) — each failed the specs written for it. 2482 Node tests (+9), typecheck, lint. No source changed.

  • 30.4 scripts/throw-coverage.mjs (2026-08-03) — landed, mirroring scripts/jsdoc-coverage.mjs: run it bare for the tallies, --verbose for every uncovered site, --lcov <file> to re-read a report instead of re-running the suite. It exports audit() the same way, and it always exits 0 — a coverage floor is a policy call, so the script reports and the decision stays with the maintainer.

    Current reading: 191 sites — 176 run by the Node suite, 13 browser-only, 2 unreachable by design, 0 Node-reachable and never run. The classification lists are the useful part and each entry carries its reason: BROWSER_ONLY (needs a device, a canvas or a pointer — pinned in the renderer project instead), and UNREACHABLE (the big-endian platform guard; the SHAPE_MASK field invariant). A third list exists because the tool measured its own error. Line-level lcov attributes the body of a module-level arrow const to the module-evaluation count, so exportScale's guard in renderer.mts reads as covered in Node — where there is no renderer at all.

    Calibration: of the 14 throw sites under BROWSER_ONLY, exactly two read as covered, and one of those (GlyphBuffer.set) genuinely is (a Node spec drives it with a mock device). So the known error is one site in 191; it is listed in MISATTRIBUTED with the cause, and the script documents that its tally is a lower bound on dead sites rather than an exact count. Both footguns the round hit are recorded in the file header — the transpiled-offset trap that made the first measurement fiction, and the useless function-level records. 5 specs in test/modules/throw-coverage.mjs (the precedent is test/modules/benchmark-report.mjs: a tool's parser gets a fixture, not trust) against hand-written lcov naming real files and real throw lines. They pin the three classifications, the misattribution override, and that a silent report reads as unknown rather than as dead — "the file never loaded" is a different failure from "loaded and never reached". Controls: three mutations of the script (dead swallowing the browser tier, the override dropped, the DA parser broken), each failing its spec. 68 module tests (+5), lint, typecheck, JSDoc coverage still 100%.

  • 30.5 Closing docs sweep (2026-08-03) — the README header carries round 30, a new "Measuring the error contract" section sits beside the benchmarks (what the script reports, that it does not gate, the reading at the close, and both measurement footguns), and the follow-up hooks now name the coverage-floor call alongside the other open ones. This file gains the round-30 paragraph in "Suggested sequencing" and open call 8; the status header, which still ended at round 23, now runs through 30 and says plainly that the ledger's remainder is calls rather than effort.

    The standing rule caught its own warning again. "Suggested sequencing" ended with a sentence calling 27.9's device measurement "open and blocked on neither — just unrun", written during round 29 and left standing by 29.6's own sweep, three paragraphs below 29.5's record of having run it. That is the third consecutive round in which this one summary is the thing that drifted, which is now noted in the paragraph itself.

    AGENTS.md gains two testing notes, both earned this round: a guard nothing has ever triggered is not tested (with the script that says which), and coverage of transpiled sources needs source maps or it lies — with the specific traps (raw NODE_V8_COVERAGE offsets; function-level records on one-line arrows). Verification for the round as a whole: 2482 Node tests, 68 module tests, 91/91 webgpu and 75/75 visual against a freshly built bundle, typecheck, lint, test:types:all, JSDoc coverage 100%, and gpu-throw-coverage at 0 Node-reachable dead sites. Round 30 is complete.

The documented contract

Round 30 made v4's throws fire in the suite. This round asks the next question about the same surface: when they fire, do they say the right thing — and does the shipped documentation admit they exist at all?

Finding 1 (the defect): one error message advises a form v4 rejects. eles.style( name, value ) throws

Per-element style bypass is not supported in the GPU prototype; use the function form of the stylesheet for per-element styling

and its doc comment repeats the advice ("use the fn form of the stylesheet"). The function form was removed in round 8 and, since round 29.3, throws at setSheet with a message naming mappers as the replacement. So a caller who hits the bypass error and follows its instruction hits a second throw, and the doc comment that ships in dist/cytoscape.d.ts tells them to. The replacement text already exists one file over (29.3's message: a case mapper for conditionals, data(key) scales for per-element values). A scan of every other advice-giving message in src found no second instance — the other "use ..." messages name keyword sets and units that are all still accepted.

Finding 2: 13 public members throw without an @throws tag. Round 26 settled that "a doc comment states the contract... what it takes, what it returns, what it throws", and gates presence of a doc comment at 100% — but nothing checks that a member which throws says so. 17 public members throw; 4 document it. The 13 that do not include six of round 20's interaction setters (each throws on invalid input), mount(), style() and numericStyle().

(Corrected by the pass: those figures come from a throwaway scan that counted if( and for( as members. The audit 31.2 actually shipped — which reuses the round-26 scanner — puts it at 16 members, 7 tagged, 9 added. Read the 31.2 record, not this paragraph.) These comments are the shipped .d.ts hover text, so the gap is user-visible.

Finding 3: two events in the curated vocabulary are named by no test. Surveying the round-17 vocabulary against the whole test corpus: every name appears somewhere except mouseout and pointercancel. mouseover is asserted six times in the file whose sibling mouseout is asserted zero — the 29.2/30.3 shape again — and pointercancel is emitted by the pointer layer (17.1) with nothing pinning it.

Negative results from the same survey, recorded so they are not re-run: no style prop is unexercised — all 104 entries of the prop table are named in test/, playwright-tests/, debug/ or benchmark/; and the event vocabulary is otherwise covered, though the first pass of that survey wrongly reported 31 names as untested because the browser specs register them by looping over an array of names rather than by literal call sites.

Pass split (tests-first; docs in-commit; each pass its own commit(s)):

  • 31.1 The message that recommends a removed form (2026-08-03) — landed. The throw now reads "Per-element style bypass is not supported in v4; per-element styling is declarative: use a 'case' mapper for conditionals and 'data(key)' scales for per-element values", matching 29.3's wording for the sibling rejection, and the doc comment says the same (with a parenthesis recording what it used to say, since the old text is what a v3-era app will have been following).

    The spec asserts both halves, which is what makes it more than a string check: the message names a mapper and does not name the function form, and the form it names is then handed to cytoscape and expected not to throw. A message that advises a rejected form is only detectable if the advice is executed. Control: the old advice restored → the spec fails. dist/cytoscape.d.ts is regenerated and committed (the comment is shipped hover text — the whole reason the defect mattered), and test:types:surface re-run: 37 type exports, 3 statics, 1093 doc blocks.

    2483 Node tests, typecheck, lint, JSDoc 100%, and — since this pass changes source — 91/91 webgpu and 75/75 visual against a freshly built bundle.

  • 31.2 @throws where a public member throws (2026-08-03) — landed. The plan said 13 members from a throwaway scan; the audit written for the pass, which reuses the round-26 scanner (class-body tracking, modifiers, overload signatures, comment skipping) rather than a fresh regex, puts it at 16 public members that throw, 7 tagged — so 9 comments gained an @throws, and the surface is now 16/16. The nine: numericStyle, mount, readProp, and the six round-20 interaction setters (selectionType, multiClickDebounceTime, wheelSensitivity, and the three thresholds).

    Each states the condition rather than the fact — wheelSensitivity throws on non-positive where the thresholds allow 0, and mount names its three distinct failures — because "throws on bad input" in a comment is not worth the line. auditThrowTags() joins scripts/jsdoc-coverage.mjs and its tally prints under the coverage report (--verbose lists the offenders). It under-detects deliberately: a member that throws only through a helper it calls is not flagged, because whether that is part of its contract needs a human.

    This one is gated, in test/jsdoc-coverage.mjs, where round 30 deliberately did not gate its throw-coverage measurement — and the difference is the reasoning, not an inconsistency: documentation completeness is already a gated concern here (round 26 made that call and took both tiers to 100%), so keeping @throws complete maintains an existing gate rather than inventing a new kind. It is one describe block to remove.

    Controls: a tag deleted → 1 failing; a new undocumented throwing member added to viewport.mts → 3 failing; the audit's throw detection short-circuited → 1 failing (the non-trivial-count guard, which exists so a regex change that audits nothing cannot read as a pass). Comments only in src/, so the browser suites are unaffected; dist/cytoscape.d.ts is regenerated and committed (1093 doc blocks). 2485 Node tests, 68 module tests, typecheck, lint.

  • 31.3 mouseout and pointercancel (2026-08-03) — landed, 2 specs in the renderer project. mouseout is the plain sibling gap: hover on, assert mouseover:a; move within the node and assert no mouseout (the half that makes it a hover-boundary test rather than a "some event fired" test); move off and assert mouseout:a. pointercancel is driven with synthetic PointerEvents rather than page.mouse, because the handler matches the cancel against the press's pointerId and only a synthetic event lets the spec choose it.

    (capture() already swallows the setPointerCapture throw that inactive synthetic pointers raise, so nothing had to change to make this drivable.) It asserts the recorded 17.2 rule: a cancelled gesture still frees but never reports dragfree — the drag aborted rather than completed — plus no tapend, the node un-grabbed, and, as the precondition that makes the rest mean anything, that the gesture really was mid-drag when cancelled.

    Two of the four controls came back BAD on the first attempt, and the cause was the control, not the spec. free/freeon are emitted from two places — onPointerUp and onPointerCancel — with identical text, so a string replacement patched the pointerup copy and the cancel path kept working. Re-run against the cancel block by line, both fail as they should. Worth recording as a method note: when a control edits by string match, check the string is unique before believing a BAD result.

    Controls, all four: mouseout never emitted → its spec fails; pointercancel never emitted → 1 fails; the cancel path stops freeing → 1 fails; the cancel path also reports dragfree → 1 fails. 93/93 webgpu (91 + 2).

  • 31.4 Closing docs sweep (2026-08-03) — the README header carries round 31, the JSDoc section gains the @throws rule beside the round-26 coverage rule, and both records name the thing worth remembering: this file and the README described the bypass replacement correctly the entire time. The stale advice lived in a runtime message and a JSDoc comment, neither of which a markdown sweep reads — so "the docs are swept" was true and the shipped documentation was still wrong. AGENTS.md says so under the JSDoc note, next to the gate that now catches the silent half. This file gains the round-31 paragraph in "Suggested sequencing".

    No new open call: 31.2's gating question was answered in the pass (documentation completeness is already gated here, so @throws maintains an existing gate rather than adding a kind), and it is one describe block to remove if the maintainer disagrees. Verification for the round: 2485 Node tests, 68 module tests, 93/93 webgpu and 75/75 visual against a freshly built bundle, typecheck, lint, test:types:all with the regenerated dist/cytoscape.d.ts, JSDoc coverage 100% and @throws 16/16, and gpu-throw-coverage still at 0 Node-reachable dead sites. Round 31 is complete.

@param completeness

Round 31 closed the @throws half of round 26's contract sentence ("what it takes, what it returns, what it throws"). This round closes what it takes, and stops there deliberately.

Why @param and not @returns. Round 26 recorded docmaker's per-function shape: { name, descr, formats: [ { descr, args: [ { name, descr } ] } ] }. Arguments have a description field the generator emits; there is no return field at all. So a missing @param is a hole in the release documentation v4 will ship, while a missing @returns is editor hover text only. That is a boundary in the already-decided design rather than an arbitrary cut, which is what makes this round no-call work.

Measured (public tier — the nine PUBLIC_API files, overload-aware so an implementation signature closing a run of documented overloads is not counted): 221 public members take parameters; 143 document them and 78 do not. The convention is established practice at 65%, not an open question — round 26 simply stopped at doc-comment presence. By file: collection.mts 28, animation.mts 18, core.mts 13, viewport.mts 8, style.mts 8, layout/contract.mts 3.

The @returns tail is measured and logged, not built: 63 of 276 value-returning public members lack the tag. It is worth doing, and it is worth doing when someone is generating the docs and can see what reads badly — nothing downstream consumes it today.

(This figure was first published as "133 of 348" — from the same throwaway scan that misreported the @throws count in the round-31 plan, counting if(/for( as members and not skipping the implementation signature that closes a run of overloads. Re-measured 2026-08-03 with the overload-aware scanner the shipped audits use. Third time that scan has produced a wrong number in a plan: use auditParamTags/auditThrowTags as the template, not a fresh regex.)

Pass split (docs in-commit; one commit per file group):

  • 32.1 core.mts + viewport.mts (2026-08-03) — 21 members; 143 → 164 of 221. These are generator output, so each says what the argument means: animate()'s opts names the override order and the panBy/pan throw, setZoom's option form is the keep-this-point-stationary case, stop()'s jumpToEnd is the difference between applying the targets and freezing where the tween reached.

  • 32.2 collection.mts (2026-08-03) — 28 members; 164 → 192. Two wording calls worth recording. The thirteen compound and DAG traversals take the same optional criterion, so they share one sentence rather than thirteen paraphrases — a generator emits them side by side and they should read as one family. And the overloaded readers describe the forms rather than naming a type: data()'s args line lists its four spellings, relativePosition's dim covers read-pair / read-axis / write-pair, and style()'s value is documented as never valid, since it exists only so the setter form throws instead of silently ignoring it.

  • 32.3 animation.mts + style.mts + layout/contract.mts (2026-08-03) — 29 members; 221/221. The clock parameters are where a description earns its line: now is the shared clock in every one of them, which is what makes the CPU settle and the GPU evaluation agree, so each says so instead of "the current time". setPositions documents the packing (xy[i*2] lands on slots[i]) — the one thing a layout author must get right — and refreshMapped says keys is the gate on what re-evaluates, not merely a record of what was written.

  • 32.4 The audit + gate, and the closing sweep (2026-08-03) — auditParamTags() joins auditThrowTags() in scripts/jsdoc-coverage.mjs, overload-aware through the same regexes, public tier only, printed under the coverage report and listed by --verbose. Gated in test/jsdoc-coverage.mjs under 31.2's reasoning: documentation completeness is already a gated concern here, so this maintains an existing gate. Controls: a @param line deleted → 1 failing; an undocumented parameterized member added to viewport.mts → 3 failing; the audit short-circuited so it checks nothing → 1 failing (the non-trivial-count guard).

    Docs: the README's JSDoc section carries the rule and the reason the boundary sits where it does, and its header carries round 32. Verification: 2487 Node tests (the gate's own two specs are the round's only additions — 32.1–32.3 changed comments alone), 68 module tests, typecheck, lint, JSDoc coverage 100%, @throws 16/16, @param 221/221, and the regenerated dist/cytoscape.d.ts (comments only in src/, so the browser suites are unaffected). (First written as 2485: the verification run's tally was read from a grep that missed the line. Caught by the docs sweep below, which is the argument for re-running a tool rather than re-reading a record.) Round 32 is complete.

The benchmark sweep

Rounds 29–32 worked one axis in four passes: what exists and is asserted by nothing (29), what throws and is run by nothing (30), what those throws say when they fire (31), and what the shipped documentation states about what a member takes (32). This round takes the same question to the third measurement axis — what costs what — and the starting answer is that roughly a third of the prototype has no benchmark at all, while the report's job table runs eight jobs out of fourteen suites.

The round's scope, set by the user (2026-08-03): benchmark everything possible — core, elements, layouts, algorithms, the store, the style engine, loading, interaction, the renderer. Not "add a few suites": close the gap between what v4 claims about its performance and what a single command can reproduce.

Code investigation (2026-08-03, precedes this plan)

What exists — 14 Mitata suites, the browser renderer bench, and the report harness (bench-run.mjs, graph.mjs, report.mjs, report-html.mjs, render-stats.mjs):

  • In the job table (report.mjs, quick profile — eight jobs): index.mjs (= core.mjs + collection.mjs), materializers, mutators, scenarios, traversal, algorithms (twice — at 2k, and at 500 for the superlinear ops the 2k run gates off), mappers. --full adds the 20k/200k matrix, with BENCH_OP splitting mutators/scenarios one group per process at 200k.
  • Standalone, by hand only — the six of open call 7: compaction (19.5), labels (16.5), transitions (24.2), geometry-tween (25.6), compound (14.12), curves (29.4).
  • Browserrender-bench.mjs: six scenes (25k and 100k flat, ndex-x-large, 25k curved, 25k compound, 25k images), opt-in behind --renderer, needing built bundles and a real adapter.

What is unmeasured. Fifteen findings, each a surface the docs describe and no suite prices:

  1. Layouts: nothing at all. Six built-ins (grid, preset, circle, concentric, breadthfirst, random) plus force, and not one Node benchmark — the only layout numbers in this file are the pass-1 record's grid figure (200k nodes 270 → 24 ms, from one-off profiling of the perf-round-2 slot path) and the browser bench's --layout force-vs-cose mode, run once on the RX 580. The layoutPositions plumbing (spacingFactor/transform/fit, the animated path) and the round-17 contract's ctx.setPositions are likewise unpriced, and the contract is the surface external authors build on.
  2. The algorithm tail. algorithms.mjs prices 18 rows covering 17 of the 21 algorithms: kMedoids, fuzzyCMeans, affinityPropagation, kargerStein and unnormalized degreeCentrality have no row, and the weighted variants of betweennessCentrality and closenessCentrality (the branch that actually runs a heap) are only exercised unweighted.
  3. The style engine. cy.style(sheet) compile + applyAll, the first apply of elements added inside a batch, the round-4 selection-restyle skip (dependsOnSelection), the round-14.6 parents-overlay partition, and the stored-truth readback getters (style()/renderedStyle()/numericStyle()) are all unpriced on their own. A whole-sheet swap appears in transitions.mjs, but only as a transitions-off-vs-on ratio, and in scenarios.mjs as one step of the refresh trace.
  4. Loading and the wire format. The init figures this file quotes most (definition-form 662 → 236 ms, columnar 80 ms, deserialize ~5 ms, 9.2 MB vs 30 MB) come from one-off profiling of ndex-x-large during the pass-1 follow-ups. There is no suite: toColumnarElements, serializeElements/deserializeElements, cy.serialize() and the three options.elements forms have no re-runnable row. Round 29's survey dropped this as "already priced", which is exactly the assumption this round exists to question — a number nobody can re-run is a record, not a measurement.
  5. CPU picking. pickNodeAt is the pointer layer's hot path and is priced only inside compaction.mjs, as a peak-vs-compacted before/after. Nothing measures it against v3's renderer pick, or across the shape branches round 27 added (the round-* offset polygon, barrel's four sampled beziers, the custom polygon blob walk), or with text-events label boxes in the scan, or at different zooms — and 28.1 recorded insideRoundPolygon as the one shape test that is not affine-invariant, so zoom is a real axis.
  6. Box selection. elementsInBox/refsInBox appears once, as a curve premium row in curves.mjs (3.29× — a figure 33.5 then found to be measuring a degenerate call; really ~2.3×). Its absolute cost, and the comparison against v3's getAllInBox, are unmeasured — as are the round-16.5 label-containment term and the round-20.2 interactive filter.
  7. Bounds and fit. The whole-graph scan is priced only in fragments: label terms in labels.mjs (~0.1 µs/label), the curve premium in curves.mjs (1.05–1.16×), a parity control in compaction.mjs. The pass-1 fast-path figure (ndex 235 → 15 ms) is another one-off. Nothing prices boundingBox/fit/ getFitViewport/boundingBoxAt against v3 across graph sizes.
  8. The data() sidecar. One data set row in mutators.mjs, which its own record calls GC-noisy at 200k. Unmeasured: the per-column kind split (f64 + present mask vs dictionary strings vs the plain-array fallback), dictionary growth and the round-11 refcount/compaction path, removeData, whole-object data() reads, and the DataStore.reader hoisting the scan paths depend on.
  9. Queries beyond flag scans. materializers.mjs covers the (mask, want) flag scans thoroughly. The round-10 A8 data conditions ({ data: { weight: { gt: 0.5 } } } — per-key readers hoisted out of the loop) and the round-14.7 structural terms ({ parent: true }) have no row, though both are the documented replacement for v3 selectors people will benchmark against.
  10. Events. The whole emit surface rests on one figure — ~85 ns/listener call, from round 5's scenario sweep. Unmeasured: cost by qualifier kind (ref-qualified vs predicate vs unqualified core), scaling in listener count, the listener-gated no-op path that most of the write side depends on for its numbers, and — the claim most worth pinning — round 14.5's "the flat no-compounds path stays byte-identical (zero cost)", which no measurement has ever checked.
  11. The animation manager. transitions.mjs and geometry-tween.mjs price ticks, thoroughly. Nothing prices animation start/stop, the round-21 channel-eviction compare (touchedColumns() across shared refs — the per-start cost that replaced the queue), delay, the round-24.3 controls, or the viewport path.
  12. Images and charts. The round-15 registry (url dedup, refcounts, tier assignment, the blob records) and the round-23 chart blob writes have no CPU sweep; chart-values via the { data } passthrough refreshes per data write and is unpriced. On the device side the gen-25k-images scene has never been measured — 15.7 recorded "software adapter on this box", which 2026-08-03 corrected as wrong for the third time.
  13. Store internals. compaction.mjs's churn section proves the round-11 reclaims hold (blob KB, dict entries, CSR shape) but prices none of them: the id-map probe/insert and blob compaction, the Adjacency.rebuild two counting passes, CurveBlob waste reclaim, and DirtyTracker span coalescing are all unpriced, and they are what every bulk path funnels through.
  14. Renderer-bench gaps. Beyond the images scene: --layout has been run once (2026-08-01); the 100k and ndex scenes have not been re-run since round 27 (29.5 deliberately scoped its comparison to the four 25k scenes); no scene exercises the round-22 visibility split, the round-20.2 events pick mode, or a label-heavy wrapped configuration — the one round 25.6 named as the expensive case.
  15. The report understates the suite. Open call 7: six suites are outside the job table, the renderer bench is opt-in, and there is no profile that runs everything. A reader of report.html sees less than half of what has been measured.

Negative results, recorded so they are not re-derived.

  • benchmark/ (v3's own suites) stays untouched. It runs against documentation/-era fixtures and the v3 API, and v3 is frozen until v4 ships. v3 comparisons belong in benchmark/, where graph.mjs already builds one element list for both factories.
  • The anti-hoisting methodology does not need revisiting. core.mjs/collection.mjs rotate operands over a pool of K = 8 so V8 cannot hoist a pure loop-invariant call out of the measured region — the fix that stopped same() mis-reporting by five orders of magnitude. New suites reuse it rather than inventing a harness.
  • Suite health is not the problem. 30.0 re-ran all six standalone suites at BENCH_N=2000, exit 0 apiece. This round is coverage and reporting, not bit-rot.

Design calls (round 33)

  1. A benchmark is either v3-comparative or gpu-only, and says which. A comparative row needs an idiomatic v3 analogue on the other side (the cmp( name, v3Op, gpuOp ) shape the suites already use where the dialects differ); everything else is a gpu-only absolute cost, or a premium against a v4 baseline of the same shape (the 29.4 form). No row fakes a comparison by benchmarking v4 against a v3 call that means something else — the report's speedup overview is only worth reading if every 1× line is real.
  2. Every performance claim in the docs gets a re-runnable source. The round's honesty rule, and the direct analogue of round 30's throw coverage: a figure quoted in src/README.md or this file either becomes a row in a suite, or is marked in place as a historical one-off with the date and machine it came from. The four that matter most are the init figures (finding 4), the grid layout figure (1), the fit fast-path figure (7) and the emit figure (10).
  3. Standalone suites join the report; the report gains a profile that runs everything. report.mjs's job table takes all fourteen suites, with a gpuOnly: true marker for the ones that have no v3 side, so report-html.mjs renders them as absolute costs rather than against a 1× reference line that means nothing for them. --all runs every suite plus the renderer bench. This closes open call 7 with the answer the user's scope implies.
  4. Warmup and deferral discipline is mandatory, and stated per suite. 29.4's two corrections are the standing method: whichever side is measured first pays the module's JIT warmup (a drag read 2.52× and settled at 1.16× once both sides were warm), and v4 defers derivation to the first read, so a row that writes and never reads measures a flag write. Every new suite documents which of the two it had to handle, in its header.
  5. A row that reads ≈1× is checked before it is reported. The generalization of the same pass: parity is a finding only after the row has been shown to be measuring the thing it names. Two of 29.4's rows were not.
  6. Scale points are declared, not implied. Comparative suites run the existing 2k/20k/200k ladder (BENCH_N), superlinear ops gate on it, and any group whose 200k form exceeds the heap with two live instances runs one group per process via BENCH_OP — with the report.mjs op tables kept in step with the group names, which is the coupling that already exists for mutators/scenarios.
  7. Numbers are recorded as factors first. Wall figures are machine-local (the RX 580 / i9-9900K box these rounds have used); the round record states the machine once and reports ratios and per-element costs, the convention 25.6 and 19.5 already follow.
  8. A benchmark-coverage audit ships, and reports only. The third tool in the gpu-jsdoc-coverage / gpu-throw-coverage family: scripts/bench-coverage.mjs maps a maintained manifest of public surfaces to the suites that price them and lists what has nothing. It always exits 0 — a benchmark floor is a policy call, and the mapping is a name-mention scan whose limits are recorded up front (round 31's event survey misreported 31 names because browser specs register them from an array, and the same failure mode applies here). It answers "is this still true?" for the claim this round is making, and nothing stronger.

Pass split (docs in-commit; each pass its own commit(s))

  • 33.0 Docs-first (2026-08-03) — this plan section, open call 7 marked scoped, and the README's Benchmarks section opened with the round's two rules (a row is v3-comparative or gpu-only and says which; every figure has a re-runnable source or is marked a historical one-off). Landed before any suite, and amended in the same commit with the breadth pass 33.9 on the user's restatement of scope.

  • 33.1 Layouts (2026-08-03) — landed as benchmark/layouts.mjs, and two of its first rows were not measuring anything, which is design call 5 earning its place on the first pass that used it. Rows (i9-9900K, N=2000 / 4000 edges, fit: false and a shared explicit boundingBox on both sides — a headless v3 viewport is 1×1 px, so a viewport-sized layout would pack 2000 nodes into a pixel): grid 153×, preset map-form 21×, preset fn-form 32×, circle 9.9×, concentric 20×, breadthfirst 32×, random 6.9×, and an eles.layout() 10% scope 5.6×.

    The slot-path/handle-path split the plan predicted shows up exactly: grid is the outlier because it is the one layout that never materializes a handle. At N=500 the same rows read 84× / 23× / 32× / 9.1× / 20× / 36× / 7.1× / 6.0×, and the force CPU executor runs 20 iterations in 4.81 ms (29.5 ms at 2k) against v3 cose at 10 iterations — 48×, the two capped identically, and gated to N ≤ 500 because cose is superlinear (4.5 s per iteration at 25k on the hardware-pass box).

    The two corrected rows. preset first read 2388×, which is not a layout result: with no positions v4's preset does no work at all (positions are already in the model — its own module comment says so) while v3 still walks every node. It now passes a real positions map, which is both the honest comparison and the real use case (restoring saved positions), plus a second row for the fn form since that one takes handles by contract on both sides.

    And the contract row first compared { impl: BulkLayout } against the built-in grid at 4.5× — not a comparison, since the two place different positions by different maths, so it measured the impl's own body as much as the wrapper (design call 1). It is now an empty impl against the same wrapper doing a full bulk placement.

    The finding that came out of that row. The contract's fixed cost scales with the graph, not the run: 106 µs at 500 nodes, 391 µs at 2000, for an impl that does nothing. LayoutContext's constructor eagerly evaluates cy.elements() and .nodes() to populate the handle-tier ctx.eles/ctx.nodes, so every run interns handles for the whole graph — including for a columnar-first layout that never touches them, which is the case the contract exists to make obvious. Making those two fields lazy getters would delete it. Logged, not fixed: this is a measurement round, and eles is a declared public field of the shipped declarations.

    Also priced: the layoutPositions finisher against the bare bulk write underneath it — 1.68× at 2k (804 → 478 µs), the cost of v3's spacingFactor/transform/fit conveniences. Control: every built-in was run against a 200-node fixture seeded at one shared position and asserted to place 200 moved, 200 distinct positions, so no row is measuring a layout that silently does nothing — the check preset failed.

  • 33.2 The algorithm tail (2026-08-03) — landed in algorithms.mjs, taking it from 18 rows over 17 algorithms to 25 over all 21. At N=500: kMedoids 1.25×, fuzzyCMeans 1.11×, affinityPropagation 1.64×, kargerStein 1.06× — parity, which is the correct reading for these four and the one the plan predicted: they are attribute-space algorithms that v4 deliberately keeps handle-level on both sides (round 10 A4 — feature space, not adjacency walks), so identical maths dominates and a large win would have meant the row was wrong.

    They sit beside the existing dense-matrix rows (floydWarshall 1.2× to v3, markov 1.34×, hierarchical 1.02×) which read the same way for the same reason. The two that are not parity are the ones with a real implementation difference: weighted betweennessCentrality 8.9× and weighted closenessCentrality, which are the branch that actually runs the heap — every centrality row before this round passed no weight, so round 10 A3's decrease-key heap (v3 re-sorts instead) had never been measured. Unnormalized degreeCentrality joins its normalized sibling, the 29.2/30.3 shape arriving in the benchmark suite.

    Iteration counts are capped on both sides (AP 10, kMedoids 10, fcm

    1. so the rows measure the algorithm and not how long each implementation happens to wander; kargerStein is randomized on both sides and neither takes a seed, so what is stable is its cost (the trial count is a function of n) and its result is not compared. One methodology note worth keeping: a one-off probe had kMedoids reading 1.4× slower on v4, which did not survive mitata warming both sides — the 29.4 lesson, reproduced.
  • 33.3 The style engine (2026-08-03) — landed as benchmark/style.mjs, and it found the place where v4 does not beat v3, which no previous round had looked at directly. At N=2000 (2000 nodes / 4000 edges, constants-only sheets alternating so no apply can be skipped as unchanged): a whole-sheet swap is 1.09× — parity — at 14.2 ms for 6000 elements, or ~2.4 µs per element for what is supposed to be a columnar write. Compile alone is 27.7 µs (separated from apply through the public batching semantics: inside a batch cy.style() compiles and validates and defers the apply), so the whole 14 ms is the apply. The first apply of a 256-node band on add() is 1.36× v4's way, and the round-14.6 parents partition costs 1.08× against the same graph without the hierarchy (100 parents over 2000 leaves). The finding: the style getters are 13–21× slower than v3. Corrected by round 34.0 (2026-08-03): 5.8×, not 13–21×. These numbers come from a suite that imports src/ through tsx, and profiling readProp for round 34 found 23% of its samples in __name — esbuild's name-preserving wrapper, an Object.defineProperty per closure creation, injected by tsx and absent from the built bundle. Through build/cytoscape.esm.mjs the same getter is 292 ns against v3's 50 ns. The gap is real and round 34 fixes it; the magnitude below is inflated by the transpiler. The rest of this record stands — the localization (all of it inside readProp, against a 9 ns column read) was measured the same way on both sides.* ele.style( 'background-color' ) is 2.13 µs on v4 against 106 ns on v3; style( 'width' ) 15×, numericStyle 13×, renderedStyle 2.0×, whole-object style() 2.2×. Localized, not just observed: the cost is entirely inside StyleEngine.readProp (1.85 µs measured directly against the ref, so the collection wrapper is not it) while the column read underneath it is 9 ns — a ~200× gap between the read and its data. It is flat across props (background-color, width and label all ~1.85 µs), which rules out the switch walking to a late case and points at the per-call setup: readProp is a ~536-line method with a 145-case switch that allocates four closures before it dispatches. This matters more than a micro-benchmark usually would, because these getters are the documented public read path — the synchronous-reads invariant is what round 8 called load-bearing, and renderedStyle/numericStyle sit on it. Logged, not fixed: a measurement round measures, and hoisting the closures out of readProp is a source change with its own verification. Plan correction, recorded: the selection restyle skip cannot be benchmarked as this plan described it. The round-4 finding compared a sheet with and without a :selected-dependent block, and v4 has no selection-dependent blocks at all — they left with the selector removal and the accent ring is shader-drawn, so there is nothing to turn on and off. What survives of that comparison is the plain select/unselect round-trip, which mutators.mjs has priced since round 4 (~38× at 200k). The suite header says so rather than the row silently not existing. Two rows were corrected before landing (design call 5, twice in two passes): the compound row first read 3.55× faster than flat, which was 4000 edges missing from one side rather than the partition being free; both sides now come from one generator.

  • 33.4 Loading and the wire format (2026-08-03) — landed as benchmark/load.mjs. At N=2000 (6000 elements): definition-form init is 5.47× v3 (153 → 28 ms, construct and dispose on both sides), v4's own three ingest forms are 25.7 ms (definitions) / 17.7 (columnar) / 17.0 (wire), and the def-clone control that the def rows necessarily pay is 628 µs — so the columnar and wire payloads are ~1.5× v4's own definition path, not the headline the pass-1 record's ndex figures suggest at a different scale and fixture.

    Conversion: toColumnarElements 789 µs, serializeElements 967 µs from definitions and 211 µs from columnar, and deserializeElements 4.09 µs — 52–236× cheaper than every other path in the group, which is the wire format's whole point (numeric columns deserialize as zero-copy views) and is now a row rather than a recollection. cy.json() is 1.17× and cy.serialize() 5.5× cheaper than cy.json() on the same graph; a 256-node band add() is 1.39×, and the three forms of the same add are 3.04 / 2.76 / 2.77 ms.

    Two methodology bugs in this suite's own first version, pulling in opposite directions, which is why the number moved from 1.89× to 5.47×: a headless v3 defaults styleEnabled to false, so the v3 side was doing less work than v4 (which always applies its sheet); and v3's default layout is grid, so cytoscape( { elements } ) ran a whole layout inside the measured region. The v3 side is now styleEnabled with an explicit preset layout — the configuration scenarios.mjs and layouts.mjs already use, for exactly these reasons.

    Also recorded in the header: the def-form rows must clone inside the timed region, because a factory consumes its definition objects (v3 adopts position objects by reference and writes through them — measured: positions read back as {0,0} from the second iteration on), which is why the clone gets its own control row. Columnar and wire payloads are re-used as-is, verified by reading positions and data back after repeated loads — and that re-use is the realistic case.

  • 33.5 Pick, box selection and bounds (2026-08-03) — landed as benchmark/spatial.mjs. Plan correction, measured: picking and box selection cannot be compared against v3, because findNearestElement and getAllInBox live on v3's canvas renderer and a headless v3 instance has neither (cy.renderer() is a bare object on which both are undefined). They are gpu-only absolute costs; bounds is the one of the three v3 answers headless, and it stays comparative. Picking. A hit is ~20 µs and a background miss 42 µs at N=2000 (the full descending walk — the hover-over-background case).

    The shape branches are invisible at realistic density: the scan stops at the first candidate whose box contains the point, so exactly one inside-test runs per pick and all seven shapes read within 3% of each other.

    A row per shape would have been seven copies of the walk wearing different labels — so the shape tests get their own fixture (N coincident oversized nodes, the point inside every box and outside every shape, so the walk runs N tests and misses all of them), and there the spread is real: ellipse 88 µs, cut-rectangle 97 µs, custom polygon 212 µs, star 242 µs, barrel 789 µs, round-hexagon 823 µs — 9.6× between the cheapest and the dearest inside-test, with round 27's two computed shapes at the top.

    insideRoundPolygon's cost turns out to be zoom-independent (1.05× between zoom 1 and 2) even though its correctness is not — worth knowing, since 28.1 had to pin it at two zooms for exactly the opposite reason. text-events: yes costs 2.9× on the miss walk (the laid label box joins the scan per candidate). Box selection. elementsInBox is 153 / 214 / 370 µs over 10 / 50 / 100% of the graph, and the round-16.5 label-containment option adds 15%. Bounds (v3-comparative, labels included by default per 16.4): whole-graph boundingBox() 6.2×, cy.fit() 33×, getFitViewport() 35×, one node's boundingBox() 1.6×.

    Turning the label terms off is 1.73× — so the honest default row is the expensive one, which is why both are reported. v4-only rows for reference: boundingBoxAt 1.65 ms whole-graph, labelBoundingBox 465 ns per element. Two rows were void before they were fixed, and one of them was already shipped elsewhere. (a) elementsInBox takes four numbers, not a box object; passed an object it silently answers the empty collection (0 elements against 480 for the same band spelled positionally), so the first version of the box rows measured a degenerate call — and benchmark/curves.mjs has had the same bug since round 29.4, in a number src/README.md publishes.

    Fixed and re-measured in its own commit (33.5b below). (b) the custom-polygon row read 549 ns against 88–842 µs for every other shape, because the box corner is inside that polygon, so the walk stopped at the first node: one test, not N. Each shape now has its own miss point, and the suite asserts the miss at startup — a row that hits prints a warning naming itself, because a shape-test row that stops early is measuring nothing.

  • 33.6 The data sidecar and structured queries (2026-08-03) — landed as benchmark/data.mjs. At N=2000, bulk writes across the whole node set are 18–24× v3 and the storage kind barely moves it: numeric 24×, dictionary string (4 values) 19×, one new dictionary entry per pass 18×, the plain-array object fallback 23×. removeData is 1.7×.

    Reads are parity — one numeric key 1.04×, one dictionary string 1.34× v4's way (the decode is cheaper than v3's object hop) — with one exception recorded as a finding: the whole-object data() read is 6.3× slower on v4 (266 ns against 42 ns), because v4 rebuilds the object from its columns where v3 hands back the object it already stores. That is the columnar trade-off showing up exactly where the design predicts, and it is worth knowing before someone writes data() inside a loop.

    Structured queries against the selector strings they replaced — the comparison a porting v3 user actually makes: data equality 15.6×, a comparison (gt) 11.9×, two keys AND-ed 12.7×, membership (in) 14.9×; the predicate form (both sides materializing handles) is the narrow one. Structural terms are the widest: { parent: true } 49.8× against :parent, { child: false } 48.5× against :orphan, { parent: false } 16.3× against :childless — pure flag scans against v3's per-element pseudo evaluation.

    One row is named for what it does rather than what it was meant to do: a "dictionary churn" row would need per-element distinct strings, which takes a per-element loop that would measure the loop on both sides rather than the column. The row writes one new value across the collection per pass, so the dictionary grows by an entry per pass and not per element, and it says so.

  • 33.7 Events and the animation manager (2026-08-03) — landed as benchmark/events.mjs, and it produced the round's second finding about a documented claim. Emits (a position write on one node, N=2000): with no listeners 26× v3 — that is the listener-gated fast path every bulk-write number in mutators.mjs rests on, now measured at 53 ns against v3's 1.39 µs — one core listener 4.4×, one ref-qualified element listener 4.4×, a delegated listener 4.9× (v3's selector string against v4's predicate — the idiomatic spelling on each side), ten core listeners 2.6×. on() + off() registration is 1.17×.

    The finding: a compound child never gets the no-listener fast path. Round 14.5 says the flat path "stays byte-identical (zero cost)", which is a claim about the path bubbling does not apply to and is not re-measurable without the pre-14.5 code — so this suite measures the other half instead, which nothing had: what the phased walk costs when it does apply. With one core listener, a child two ancestors deep costs 2.35× an orphan's emit (1.29 µs vs 551 ns). With nothing listening at all it costs 6.4× (566 ns vs 89 ns) — so the phase walk runs regardless of whether any phase has a listener, and a compound graph pays it on every position write.

    That is a real optimization opportunity (hoist the whole-chain listener check ahead of the walk) and is logged, not fixed. The animation manager, whose lifecycle had never been priced — only its ticks: v4 is 4.3× slower than v3 to start and stop one element's animation (5.45 µs vs 1.26 µs) and 5× slower on delay(), but 3.7× faster starting and stopping the same animation over a 512-node collection (127 µs vs 469 µs). So the capture-into-ChannelWrites design carries a per-animation constant that amortizes at scale — v3 wins the single-element case, v4 wins the bulk case, and both are worth knowing since a UI does the former and a layout the latter.

    The round-21 eviction compare costs nothing measurable: starting an overlapping animation reads identically to starting a disjoint one (10.32 vs 10.35 µs), so touchedColumns() across shared refs is not a cost worth avoiding. The 24.3 controls are 3.4 µs (pause + resume) and 4.3 µs (reverse).

  • 33.8 Images, charts and store internals (2026-08-03) — landed as benchmark/store.mjs, gpu-only throughout because v3 has no counterpart to any of it. The structures are driven directly rather than through the public API, so a row is the structure's cost. At N=2000: the id index builds in 307 µs (2000 sets), and its single-key ops are has 55 ns / get 62 ns / hashAt 7.6 ns / idAt 9.2 ns — the last of which is the memoized hit, since idAt caches the decoded name per slot; the cold UTF-8 decode is not separable through the surface, and that is the useful fact (an id decodes once per slot, ever).

    A remove + re-set round-trip, which is what drives the round-11 blob reclaim, is 250 ns. CSR adjacency rebuilds 4000 edges in 66 µs (the two counting passes), and its reads are the design in three numbers: outDegree 6.5 ns — the O(1) claim, measured — outEdges 48 ns, connectedEdges 183 ns; an overlay add + remove is 147 ns. The blob pool writes 2000 records in 127 µs, rewrites one in place in 32 ns (the same-length fast path), reads offsetOf in 4.6 ns, and pays 181 ns for a free + rewrite. The dirty tracker, which every column write in the store funnels through, marks in 13.7 ns contiguous / 19.2 ns scattered and drains a 64-mark frame in 742 ns.

    The image registry (round 15's bookkeeping, headless — no decoder): 214 ns to acquire a url already known (the icon-per-type case) and 636 ns for a fresh entry plus its release. Charts: a chart sheet with per-element { data } values costs 1.01× the same sheet without one — the blob record per node is noise beside the apply it rides in, which is the 33.3 finding showing up from the other side. A data write refreshing every node's chart-values is 529 µs.

  • 33.9 The remaining public surface (2026-08-03) — landed as benchmark/surface.mjs: 90 rows, 80 of them v3-comparative, covering the members no dedicated suite touches — the viewport quartet and its compute-without-committing twins, introspection and the gating flags, batching, the iteration/comparison/set-building surface, traversal, degree, the rendered-coordinate accessors, the curve accessors, the compound traversals, the flag families, element data/json/scratch.

    Every op is smoke-tested once before it is benched, and the ones that could not be called are reported by name rather than silently dropped: midpoint/renderedMidpoint and the two endpoint accessors have no headless v3 side (they go through the renderer — the same cause as 33.5's pick), so those rows are gpu-only.

    (This record said "three"; the suite reports four. The fourth is core: zoomRange get [v3], which throws headless in v3 independently of anything v4 does — verified directly in round 36.3, which noticed the discrepancy while adding rows. An off-by-one in the record, not a regression.) Most of the surface is where the earlier rounds said it would be: set ops 4–175×, traversal 1.3–4×, reset() 127× and viewport() 77×, collection() 8.9×, batching ~7×. Four rows go the other way, and they are the pass's value:

    • mutableElements() — v4 251 µs against v3's 120 ns. v4's is elements(), so it materializes the whole graph on every call where v3 answers in constant time.
    • indexOf() — 12.5 µs against 204 ns over a 2000-collection: v4 scans linearly where v3 keeps an index.
    • effectiveOpacity() 34× slower (4.7 µs vs 313 ns) and the takesUpSpace/interactive/transparent trio 11× — all three read through the style engine, which is 33.3's readProp finding arriving from a second direction.
    • json()/jsons() are ~1.2× v3's way, the columnar rebuild-the-object cost 33.6 found on data(). Two rows are named for their mechanism rather than their multiplier, because the multiplier is not about v4: core data() and scratch() read as ~8,600× and ~87,000×, and the cause is that a styled v3 runs a whole-graph style update on any core data or scratch write (1.9 ms here, against 1.1 µs for the same instance unstyled). That is a real cost a v3 app pays, so the rows stay — but a reader should not take 87,000× as a statement about v4's scratch.
  • 33.10 The report: every suite, one command (2026-08-03) — landed, and open call 7 is answered. report.mjs now has three tiers instead of two: quick (the v3-vs-v4 micro and scenario suites at their default scales — deliberately unchanged, because a default profile nobody waits for is worth as little as a report showing half the suite), --all (+ the fifteen standalone sweeps: the eight this round added plus compaction, compound, curves, labels, transitions, geometry-tween and the algorithms' superlinear tier), and --full (+ the 2k/20k/200k matrix). --suite <substr> filters any tier, which is how one sweep gets run and re-rendered on its own.

    The blocker was mechanical and is now gone: curves.mjs and labels.mjs time one shot per row rather than sampling through mitata — deliberately, since their rows mutate or are one-offs — so they had no mitata results to hand over and wrote their own JSON shape (or none), which report.mjs cannot read (it needs job.groups). finishManualRun( suite, groups ) in bench-run.mjs turns one-shot rows into that shape via oneShotStats, the convention the renderer bench already uses for its init/export timings.

    No suite's terminal behaviour changed: without BENCH_JSON it writes nothing, exactly like finishRun. The renderer already handled gpu-only groups (benches not named v3/gpu render as individual labelled rows rather than as dumbbells against a 1× line that would mean nothing for them), so no gpuOnly marker was needed — the plan's proposed flag turned out to be describing something the report already did. Verified end to end: report.mjs --all --suite labels runs the suite and its five rows appear in report.html. test/modules/ gpu-benchmark-report.mjs gains three specs (the manual-run shape, the no-BENCH_JSON no-op, and a single-bench section rendering) — 14 module tests, with the control run: breaking finishManualRun's group mapping fails the spec written for it.

  • 33.11 The renderer bench gaps (2026-08-03) — run on the RX 580 (amd gcn-4, dpr 2, 1280×800, render scale pinned to 1). The images scene is measured at last — round 15.7 recorded "software adapter on this box", which was wrong for the third time in this file's history, and 29.5 scoped its comparison to four scenes without it. It holds the vsync floor (16.7 ms wall) in every pan scenario, labels on and off, where v3 canvas runs 333–760 ms/frame; device time is 3.44 ms fit-all, 4.46 zoomed-in, 1.42 far-zoom, with labels adding ~0.3 ms. Init 294 ms against v3's 3510 (12×); png() full export 290 ms against 4666 (16×).

    The 100k and ndex scenes are re-measured post-round-27, closing the scope limit 29.5 left deliberately: 100k device 9.29 ms fit-all / 18.68 zoomed-in / 1.63 far-zoom, and 20.06 ms for the zoomed-in-with-labels pass — against the 2026-08-01 hardware pass's 19.6 ms for that same worst-case row, i.e. +2.3%, inside the +0.3–3.6% band 29.5 measured for round 27's shader branches on the 25k set. ndex fit-all device 36.96 ms (the pass recorded ~37 ms) at 33.4 ms wall — two vsync frames, still the one scene above the floor — with pick p50 0.3 ms off the CPU fast path, init 1648 ms against v3's 17070 (10×), and a full png export 213 ms against 6125 (29×).

    Both scenes' compaction rows reproduce round 19.5b: 100k device 2.21 → 0.53 ms (4.2×; 19.5b recorded 2.2 → 0.5). Two scenes added, the configurations nothing exercised: gen-25k-wrap (round 25.6's expensive label case — wrapped multi-line labels — measured on the device rather than on the CPU tick) and gen-25k-invisible (round 22's paint-only visibility and round 20.2's events transparency, half the nodes each, expressed as case mappers on the v4 side and selector blocks on v3's). A same-session run of the whole 25k family gives them a baseline (device p50, ms):

    25k scene fit-all fit-all + labels zoomed-in + labels
    flat (baseline) 3.40 3.67 4.77
    curved (bezier pairs) 9.89 10.17 4.17
    compound (1k parents) 2.11 2.34 4.50
    images (icon-per-type) 3.44 3.71 4.80
    wrapped labels 3.40 4.55 5.95
    half-invisible / half-inert 1.66 1.96 2.57

    Two results worth keeping. Wrapped labels cost +24% on the labelled passes (3.67 → 4.55 fit-all, 4.77 → 5.95 zoomed-in) — and that scene's unlabelled row is 3.40 ms, identical to the baseline's, which is the control: the delta is the labels and nothing else.

    And half-invisible is 2.05× cheaper than the baseline (1.66 vs 3.40): the round-22 FLAG_DRAWN mask drops invisible elements in the cull, so they cost no vertex or fragment work at all rather than being discarded late — a design claim from that round, unmeasured until now. The image pass costs ~1% over the flat scene at this scale (3.44 vs 3.40). Two notes for whoever re-runs this: the harness printed "build/ bundles are older than src/" throughout, which was a false positive — the only src/ file this round touched is src/README.md, and the check compares mtimes without distinguishing docs from code; and --scene gen-25k is a substring filter, so it selects the whole 25k family, which is how the same-session baseline for the two new scenes was obtained.

  • 33.12 scripts/bench-coverage.mjs + the closing docs sweep (2026-08-03) — the audit landed first (its record is above, under the surface pass it drove), then the sweep. The README's Benchmarks section is now an index: a table of every suite and what it answers (21 rows over the 22 files — core/collection share index.mjs), the three report profiles, the finishManualRun note, the round's five findings, and the audit with its limits. Its follow-up hooks gained a "five measured slow paths" entry so the findings are reachable from the doc a reader starts in.

    AGENTS.md gained two benchmark notes, both earned this round: a row is guilty until it discriminates (with all six of the round's non-measuring rows named, including the elementsInBox signature trap that had been live in curves.mjs since 29.4), and a v3 side needs styleEnabled and an explicit layout, because the two defaults bias in opposite directions. scripts/ picked up the third audit in the repo-structure list, and package.json gained benchmark:all.

    The three named drift sites, checked by name: "Suggested sequencing" gained the round-33 paragraph; the "Needs a call" ledger needed nothing (round 33 closed no design calls — it is measurement work); "Gaps with direction already set" likewise. Open call 7 is struck through with what closed it. The status header at the top of this file was two rounds stale when the round started (it ended at

    1. and now runs through 33. Round 33 is complete.

What the round found (2026-08-03)

The wins were mostly where earlier rounds said they would be — bulk writes 18–24×, structural queries 16–50×, layouts 7–153×, fit 33×, algorithms unchanged where the maths is identical and 8.9× where the data structure differs. The useful output is the other direction.

Five slow paths, each localized, none fixed (a measurement round measures; every one of these is a source change with its own verification, and three of them touch shipped declarations):

  1. StyleEngine.readProp — the style getters run 13–21× v3 as measured through tsx (5.8× through the built bundle — see the round-34.0 correction; 292 ns vs 50 ns) with a 9 ns column read underneath. Flat across props, so it is the per-call setup: a ~536-line method with a 145-case switch that allocates four closures before dispatching. numericStyle, renderedStyle, effectiveOpacity and the takesUpSpace/interactive/ transparent trio all ride it. Biggest surface of the five.
  2. The compound emit path never takes the no-listener fast path — a position write on a node two ancestors deep costs 6.4× an orphan's with nothing listening (566 ns vs 89 ns), because the phase walk runs before anything checks whether a phase has a listener.
  3. LayoutContext materializes the whole graph per run — the layout contract's fixed cost is 391 µs at 2000 nodes for an impl that does nothing, because the constructor eagerly evaluates cy.elements() and .nodes() for the handle tier, including for the columnar-first layouts the contract exists to encourage.
  4. mutableElements() does the same per call — 251 µs against v3's 120 ns.
  5. indexOf() scans where v3 indexes — 12.5 µs against 204 ns over a 2000-element collection.

Six rows were caught measuring nothing, by design call 5, and rewritten: preset (2388×, because v4's preset does no work without a positions map while v3 walks every node), the compound style row (3.55× "faster", because one side was built without edges), the layout-contract row (comparing two different placements), the custom-polygon pick row (1500× faster, because the pick point was inside the shape so the walk stopped at the first node), and two box-selection rows — which turned up the round's one defect in shipped documentation: cy.elementsInBox takes four numbers and silently answers the empty collection when handed a box object, so curves.mjs's box-selection premium had been measuring a degenerate call since round 29.4, and the README published it as 3.29× (really ~2.3×).

Two methodology traps, both now in AGENTS.md: a v3 side needs styleEnabled: true and an explicit layout (unstyled v3 does less work than v4; v3's default layout is grid and runs inside the measured region — fixing both moved 33.4's init comparison from 1.89× to 5.47×), and a benchmark row is guilty until shown to discriminate.

Verification (2026-08-03)

Typecheck, lint, 2487 Node tests and 77 module tests (68 → 77: three report specs and six for the new audit, each with its control run), JSDoc coverage 100%, @throws 16/16, @param 221/221, and gpu-throw-coverage still at 0 Node-reachable dead sites — the three existing audits are unchanged, which matters because this round edited the gated one (auditFile now also returns the members it saw). No src/ code changed: the round's only source edit is src/README.md, so the browser suites are unaffected and were not re-run. The renderer benchmark ran on the RX 580 (33.11).

Risks tracked

  • Benchmark bloat vs signal. Fourteen suites become ~twenty, and a full profile that nobody waits for is worth as little as a report that shows half the suite. Mitigation: the quick profile stays quick (default scales only), --all is the opt-in, and the round records the wall time of each profile so the cost of running it is itself a documented number.
  • v3 comparisons that are not comparisons (design call 1). The most likely offenders are the layout rows — v3's layouts take different options and, for cose, a wholly different quality target — and the loading rows, where v3 has no columnar or wire form. Where no honest comparison exists, the row is gpu-only and says so.
  • Fixture drift. graph.mjs builds one degree-4 grid graph; the new suites need compound, curved, labelled and imaged fixtures. These should extend graph.mjs (the existing shared seam) rather than each suite growing its own generator — the failure mode is six slightly different "20k graphs" whose numbers cannot be compared.
  • Heap ceilings at 200k. Two live instances of a 200k graph already force one-group-per-process for mutators/scenarios; the style, spatial and data suites will hit the same wall, and the BENCH_OP tables in report.mjs are a hand-maintained coupling to group names that silently degrades when a group is renamed.
  • Randomized and deferred work (design calls 4–5). kargerStein, the force executors and anything reading derived geometry are the rows most likely to measure variance or a deferred no-op. Each gets the 29.4 treatment: force the work, then check the row can move.
  • The audit over-claiming (design call 8). A name-mention scan will report a surface as benchmarked because a suite happens to mention it, and will miss one exercised through a wrapper. It reports, it does not gate, and the header says which direction it errs in.

Fixing what the benchmark sweep measured

Round 33 measured the prototype and found five paths slower than v3 or than v4's own design implies, logging each rather than fixing it because a measurement round measures. This round fixes them. It is the first round in a while whose commits change src/, so the browser suites are back in the verification gate.

A correction first, and it changes one of the five. Profiling StyleEngine.readProp before touching it showed 23% of its samples in __name — esbuild's name-preserving wrapper, an Object.defineProperty per closure creation, which tsx injects and which does not exist in the built bundle (grep -c __name build/cytoscape.esm.mjs → 0). Measured through the bundle instead, the same getter is 292 ns, not 2.0 µs, against v3's 50 ns: the real gap is 5.8×, not the 13–21× round 33 published. The finding is real and worth fixing; its magnitude was inflated by the transpiler, in a suite that imports src/ directly.

That is the round-30 lesson ("coverage of transpiled sources needs source maps, or it lies") in a second guise, and it generalizes: for closure-heavy hot paths, benchmarking the tsx sources measures the transpiler. Recorded in AGENTS.md, and the other four findings were re-measured through the bundle before any fix (they hold: they allocate little and are dominated by real work).

The five, re-measured through build/cytoscape.esm.mjs — these are the numbers the round is judged against, at N=2000 nodes / 4000 edges on the i9-9900K:

path v4 before v3 gap
ele.style( 'background-color' ) 292 ns 50 ns 5.8×
phased emit, no listeners 530 ns (flat: 112 ns) 4.7×
layout contract, empty impl 333 µs O(V+E) per run
cy.mutableElements() 121 µs 18 ns O(V+E) per call
eles.indexOf( ele ) 3.63 µs 45 ns 81×

Design calls (round 34):

  1. Behaviour is preserved exactly; these are not semantics changes. Every fix keeps the observable contract — element order, event ordering and phase semantics, the values getters return. Where a fix could change something visible (the elements() memo returns the same object to two callers where it used to return two), that is called out and pinned by a spec.
  2. Each fix is measured before and after, through the bundle, and the round record carries both numbers. A fix that does not move its number is reverted, not shipped with a story.
  3. The order-list scan is the contract for "all elements in order". nodeSlots() currently walks handles; the replacement walks the same insertion-order list scanRefsInto walks, so layouts see identical order — which matters, since grid and circle assign positions by index.
  4. No public semantics change; one public shape change. Making LayoutContext.eles/.nodes lazy turns two readonly fields into getters, which is a .d.ts shape change (property access is unaffected). dist/cytoscape.d.ts is regenerated and test:types:surface re-run. Wrong, as it turned out (34.6): LayoutContext is not in the shipped declarations at all — it appears only inside a doc comment — so the getters change no public shape. What did reach the .d.ts is the store's two new members (structureEpoch, scanSlotsInto), since cy._store is typed; 1093 → 1097 doc blocks.

Pass split (tests-first; docs in-commit; each pass its own commit):

  • 34.0 Docs-first — this plan, the round-33 correction recorded in its own record and in the README, and the AGENTS.md note about benchmarking transpiled sources.

  • 34.1 indexOf is O(1) (2026-08-03) — the lazily-built packed-key membership Set became a Map from key to first index. Set membership only ever asks .has(), which a Map answers identically, so the ten set-op call sites are untouched; indexOf now reads the index straight out of the same cache instead of re-packing every ref in a linear scan. 12.5 µs → 42 ns at N=2000 (measured through the sources on both sides), which is parity with v3's 42 ns — the 81× gap is gone rather than narrowed, because the cache the set ops already build was carrying the answer all along.

    indexOfId is deliberately not changed: it compares each handle's cached _id, which still resolves for a removed element held in a collection, and answering it from the store's id index instead would quietly change that. It was not one of the five. Tests-first: two specs in test/collection-reference.mjs pinning that the two consumers agree whichever builds the cache first (a wrong shared cache shows up as one of them answering differently), and that every element of a 40-element collection reports its own index. 2489 Node tests.

  • 34.2 elements() memoized against a structure epoch (2026-08-03) — GraphStore gained structureEpoch, bumped at the three places an element enters or leaves the insertion-order list (allocSlot, freeSlot, the bulk id path) and on compaction, and the core memoizes the three unfiltered collections against it (elements(), nodes(), edges() with no query — a query argument is never memoized). 121 µs → 18 ns for mutableElements() at N=2000, against v3's 14 ns: parity, from an O(V+E) scan plus a handle intern per element. elements() is 16 ns and nodes() 19 ns on repeat.

    A deliberate visible consequence: two calls with no structural change between them now return the same collection object where they returned two equal ones. Collections are immutable snapshots, so nothing can observe this except identity itself — and a spec pins it rather than leaving it to be discovered. Why a counter and not a count: add one, remove another between two calls leaves the count identical and the set different, so a count-keyed cache would answer the second call with a dead ref and a missing element.

    Six specs in test/core-api.mjs, and the two controls that matter were run: keying the cache on element count instead of the epoch fails the add-one-remove-one spec, and dropping the freeSlot bump fails the add-and-remove spec. Style, position and data writes deliberately do not invalidate — pinned by a spec that also reads the new values back through the cached collection, since a collection holds refs into the columns rather than a copy of them.

  • 34.3 The phased emit takes the no-listener fast path (2026-08-03) — landed, and it corrected the finding on the way in.

    The gate itself: _emitOnEle returns before building the event or walking ancestors when nothing listens for the type. Sound because v4's emitter never bubbles to a parent (bubble defaults false and v4 does not override it), so an emit with no matching listener is observably a no-op. 338 ns → 8 ns for a node two ancestors deep and 159 → 6 ns for an orphan, with a listener present unchanged. Correction to round 33's finding 2, measured: the row that finding used — child.position() at 6.4× an orphan's with no listeners — never reached _emitOnEle at all.

    The position writers already gate on hasListeners( 'position' ) (as do add, remove, data and move), so what that row measured is the compound auto-bounds invalidation: a child's position write marks its ancestor chain geo-stale, which is round 14.3 working as designed, not a defect.

    What is true is the narrower claim this pass fixes: _emitOnEle itself did no listener check, so it cost 338 ns on a compound child before discovering nobody cared. That still matters, because the pointer layer's sixteen call sites are ungatedmouseover/mouseout, pointerover/pointerout, tap, tapselect/tapunselect, the box family — and they fire on hover transitions and pointer moves, which is the latency path. Four specs in test/compound-events.mjs pin the boundary from both sides, with two controls run: making the gate unconditional and gating it on the wrong type each fail 12 of the file's 13 specs.

  • 34.4 The layout contract stops materializing the graph (2026-08-03) — two changes, and the second is the one that mattered. eles/nodes became lazy getters, so a columnar layout that never asks for handles never builds them; and nodeSlots()/ edgeSlots() stopped walking those handles at all, reading slots from the store's insertion-order list (whole-graph scope) or the scope collection's refs (subset scope). GraphStore.scanSlotsInto is the slot-only twin of scanRefsInto — same walk, same (mask, want) test, no Ref allocated — and the per-element filter became one mask: alive, not a parent, not locked.

    391 µs → 1.72 µs for an empty impl at 2000 nodes / 2000 edges (~230×), and a subset scope's is 875 ns. A columnar bulk placement over the whole graph is 57.6 µs, which is now the placement; an impl that does ask for ctx.nodes still pays 122 µs, unchanged and by design — you pay when you ask. The risk here was order, since grid and circle place by index, so a different enumeration order is a different layout. Five specs in test/layout-contract.mjs pin nodeSlots()/edgeSlots() as exactly cy.nodes()/cy.edges() order, the locked/parent exclusions, subset order, and that eles/nodes still answer when an impl does ask.

    Control: enumerating in reversed slot order instead fails 2 of the 43 specs across the contract and layout files. Landed with a repeat of this codebase's most familiar bug: inserting scanSlotsInto above scanRefsInto stranded the latter's doc block, which the round-26 coverage gate caught immediately — the ninth instance of the pattern, and the first one a gate found rather than a reader.

  • 34.5 readProp (2026-08-03) — landed as two fixes, because the planned one turned out to be a no-op in production and design call 2 says a fix that does not move its number is not shipped with a story. (a) The closures, hoisted. The five column readers built inside readProp became module-level helpers taking (store, slot, id). Under tsx this is 1848 ns → 255 ns — because each closure creation also paid esbuild's __name wrapper — but in the bundle it is 292 → 288 ns, which is noise: V8 creates closures cheaply when nothing is decorating them. So this half fixes the harness (every Node test and benchmark runs through tsx) and not the product.

    Kept, and reported as exactly that. (b) normalizeProp, memoized — the fix that moved the production number. Profiling the bundle put 36.4% of readProp in normalizeProp and another 4.5% in its ([A-Z]) regex: every style read was doing a regex replace and a lowercase allocation to turn backgroundColor into background-color, before the 145-case switch it precedes. A Map cache (bounded at 512 entries, since an unknown name is normalized before it is rejected) takes ele.style( 'background-color' ) from 292 ns → 122 ns against v3's 52 ns — the gap goes 5.8× → 2.3×. numericStyle 215 → 84 ns, effectiveOpacity 240 → 92 ns, style( 'width' ) 227 → 89 ns.

    Three specs in test/style-getters.mjs: both spellings answer identically, a restyle is visible through both, and an unknown name still throws — twice, so a cached normalization cannot turn the second call into a silent success. Control: making the memo return the raw name fails 3 of the file's 19 specs. Landed with the tenth instance of the stranded-doc-block pattern (my comment displaced normalizeProp's JSDoc), caught by the gate again.

  • 34.6 Verification + closing sweep (2026-08-03). The five, before and after, through build/cytoscape.esm.mjs at N=2000 (the style row from a dedicated process — a micro-row in a shared one varies ±30% run to run, which is itself worth knowing):

    path before after v3
    ele.style( 'background-color' ) 292 ns 122 ns 52 ns
    _emitOnEle, nothing listening 338 ns 8 ns
    layout contract, empty impl 333 µs 795 ns
    cy.mutableElements() 121 µs 20 ns 18 ns
    eles.indexOf( ele ) 3.63 µs 41 ns 41 ns

    Three of the five are now at parity with v3 or better; the style getter is 2.3× (from 5.8×) and the two v4-only paths are 420× and 42× cheaper than they were.

    Verification: typecheck, lint, 2508 Node tests and 77 module tests, JSDoc 100% with @throws 16/16 and @param 221/221, gpu-throw-coverage at 0 Node-reachable dead sites, the regenerated dist/cytoscape.d.ts (1093 → 1097 doc blocks — the store's two new members) with test:types:surface clean, and — since this round changes src/168/168 browser specs across webgpu and visual against a hand-rebuilt bundle (an http-server was listening on 3333, which is exactly the standing trap, so test:playwright:build was run by hand first).

    Goldens are byte-stable and the parity scenes read their recorded values (parity-charts-pie 0.000%, parity-casing 0.061%, parity-polygon 0.005%): the five fixes change no pixels. Docs swept: the README's Benchmarks section carries the before/after for each path and its follow-up hooks strike the five through; the three benchmark suites whose comments recorded the findings now record the fixes and say they stay as the rows that would notice a regression. The three named drift sites need nothing — round 34 closes no design calls and opens none. Round 34 is complete.

Risks tracked: the elements() memo going stale on a path that mutates the graph without touching the order list (mitigated by bumping at the order list itself, which is the one structure every add and remove passes through); the emit gate skipping an emit that some code depends on for a side effect other than its listeners (mitigated by the bubble: false argument and by the event-order specs); nodeSlots() changing layout order (mitigated by walking the same order list, and pinned by the layout suites' exact-position expectations); and readProp's size making a mechanical edit error-prone (mitigated by typecheck plus the readback specs, which assert values per prop).

The style-read dispatch table

Round 34 left the style getters at 2.3× v3 with "no obvious cause — it is the 145-case switch and the guard lookups that precede it", and logged that as appetite rather than a decision. The maintainer's reaction to that sentence is this round: 145 cases is a code smell; why is there not a direct lookup? Both halves of that turn out to be right, and the second is measurable.

Why there are so many cases — this part is not accidental complexity. readProp answers every readable style property from stored truth, and each property has its own storage: a column, a packing, a fold, a sidecar entry, or a derived record. 150 case labels over 111 groups in the big switch (plus four in the small transition-config switch above it; 153 distinct readable properties in all), median 2 lines each, 49 of them one-liners. It is a dispatch table that happens to be written as control flow — the vocabulary's size, not repeated logic.

(This paragraph first said "153 labels over 97 groups", from a throwaway parse that mis-split labels written several to a line. The figures here are the shipped transformer's, which 35.2's table is built from. Fourth time a hand-rolled scan has produced a wrong count in a plan — the standing advice to reuse the audits' scanner applies to one-off analysis too.)

Why the shape costs something. V8 does not hash a string switch this large. Measured two ways:

  • Synthetically, a generated 145-case string switch costs 48.7 ns at the first case, 552.9 ns at the last, and 336 ns rotating across the range; a Map dispatch to the same readers is 14 ns and position-independent.
  • In the real method, moving border-width's case — body untouched — from position #6 to the tail took it from 56 ns to 90 ns through the built bundle. (Less than the synthetic gap, because the real switch has grouped labels and early-exit branches above it, so V8 manages some of it better; the effect is still ~1.6× on an identical body.)

So a property's cost depends on where it happens to sit in the file — which is exactly the kind of thing that should not be true, and why the round-33/34 measurements (which used background-color, the 4th case) understated the getters for everything else.

Design calls:

  1. The switch becomes a Map from property name to a reader function( engine, ref, store, slot ) => value — built once at module load. Dispatch is one Map.get plus a call, the same for every property. This is the structure the code already is; the round makes it data instead of control flow.
  2. Value-for-value equivalence is the acceptance test, not a sample. 86% of the readable props (132/153) have a spec that passes them to a getter today, which is not enough to refactor 524 lines behind. 35.1 therefore lands a characterization spec first: every property in the table, read on a styled node and a styled edge, asserted against the values the current implementation returns. It is explicitly a refactor guard — it pins what v4 does today, bugs included — and it closes the 21-prop readback gap permanently.
  3. Fall-through groups stay one reader with several keys, so the 19 grouped labels do not become 19 copies.
  4. If the transformation cannot be completed safely, it is abandoned, not half-done. A hybrid (table plus a residual switch) would be worse than either.

Pass split (tests-first; docs in-commit):

  • 35.0 Docs-first — this plan.

  • 35.1 The characterization spec (2026-08-03) — test/style-readback-all.mjs: 153 properties × a styled node and a styled edge, 306 assertions, generated from the implementation as it stood and seen green before 35.2 touched anything. The 117 rows that read undefined are pinned too — they are how a node-only property stays node-only. Controls: making one property read the wrong column fails 1 spec; letting a node-only property leak onto edges fails 1.

  • 35.2 The dispatch table (2026-08-03) — the switch is gone.

    PROP_READERS is a module-scope Map of 111 readers over 150 labels (nine readers deliberately answer several labels), and readProp is now 60 lines: the guards, then a Map.get and a call. A reader takes only the arguments it uses, in the order ( store, slot, ref, engine, prop ). Encapsulation held: the readers need five engine members (defFor ×21, store ×6, defs ×4, labelChannels ×2, readImageProp ×1), all private, so rather than widen the class the engine builds one narrow ReadContext per instance — arrow functions capturing this, with store/defs as accessors because a sheet swap replaces defs wholesale and a snapshot would hand every reader the previous sheet. Three parser bugs were found and fixed before applying anything, by inspecting the generated table rather than by running it: labels written several to a line (case 'a': case 'b':) were silently dropping 12 of the 150; a nested switch inside one reader body confused a depth-based split; and section comments written above a case were being pulled into the previous reader, which is this codebase's stranded-comment pattern in a new costume — they now lead the group they document.

  • 35.3 Measure + sweep (2026-08-03) — the table flattens the cost; it does not lower all of it. Through the built bundle, by the property's old position in the switch:

    property (old position) switch table
    border-width (#6) 56 ns 73 ns
    background-color (#4) 108 ns 110 ns
    text-wrap (#73) 56 ns 52 ns
    text-max-width (#74) 59 ns 48 ns
    taxi-radius (#142) 115 ns 91 ns
    target-distance-from-node (#150) 286 ns 108 ns

    The spread was 56–286 ns (5.1×) and is now 48–110 ns (2.3×): the worst property is 2.6× faster, the earliest few are ~15 ns slower (a Map.get costs what the switch's first comparisons did not), and cost no longer depends on where a property sits in a file. The aggregate is the number that matters, since style() with no argument reads every property of the group: 19.95 → 15.71 µs on a node (1.27×) and 30.87 → 20.92 µs on an edge (1.48×) — edges gain more because edge properties sat at the back.

    Verification: typecheck, lint, 2662 Node tests (2508 + the 154 characterization specs), 77 module tests, JSDoc 100% with @throws 16/16 and @param 221/221, throw coverage 0 dead, test:types:surface clean (1098 doc blocks; the .d.ts gained only the private readCtx line), and 168/168 browser specs against a hand-rebuilt bundle with goldens byte-stable and parity scenes at their recorded values. The same shape exists in the write path and is deliberately left alone. applyProp — the constant-resolution half of the engine — is a 147-case switch of exactly the same kind.

    It is not hot: it runs from resolveConst, which is called three times at construction and once per group per cy.style( sheet ), not per element and not per read. 33.3 measured a whole sheet compile at 27.7 µs, so the switch there costs a handful of dispatches per sheet swap against ~6000 per whole-object read on the other side. Recorded so the next reader does not pattern-match the shape and "fix" the one that never mattered — the read path earned the change because of how often it runs, not because a big switch is wrong on sight. Round 35 is complete.

Risks tracked: a mis-transcribed case silently returning the wrong value (mitigated by 35.1, which is written and seen passing against the old implementation first); this capture inside reader bodies (each becomes an explicit engine parameter); and the megamorphic call site defeating inlining, which is why the round measures rather than assumes.

The completion round

Round 35 closed the last item anyone had logged as worth doing and not done. What is left in this file divides cleanly in two: the twelve entries of "Open calls for the maintainer", which are decisions and stay where they are, and a short tail of work that needs no decision at all — a documentation rule that was measured and deferred on timing, a verification tier that was opened and half-closed, and three measurements this file promised and never recorded. This round is that tail, and it is deliberately the only thing in it: no design call is taken, none is opened, and no public API moves.

Findings (measured 2026-08-04, before the passes below):

  1. The @returns tail is 58 members, not 63. Round 32 measured "63 of 276" with the overload-aware scanner and logged it rather than building it, on the reasoning that docmaker's per-function shape has no return field — so a missing @returns is editor hover text where a missing @param is a hole in the generated docs. That reasoning draws the gate's boundary, not the writing's: the tag is in round 26's standard-tags list, the surface is 100% documented, and @throws and @param are both complete.

    Re-measured against the value-returning public members (a member whose signature carries a return annotation that is not void and not this): 206 of 264 tagged, 58 missingcollection.mts 30, animation.mts 11, layout/contract.mts 7, core.mts 5, viewport.mts 3, style.mts

  2. The browser-only throw tier was opened and half-closed. gpu-throw-coverage classifies 13 sites as needing a device, a canvas or a pointer, and round 30.2 pinned six of them — the png()/jpg() export guards. The other seven have never fired in any suite in either project: gpu-context's two device-acquisition guards, column-mirror's unknown-column guard, glyph-atlas's full atlas, gpu-tween's geometry-kind invariant, and image-decoder's two. Round 30's own record says the browser tier "is pinned in the webgpu Playwright project instead", which is true of the export guards and of nothing else.

  3. Three promised measurements were never recorded. (a) The renderer benchmark's --layout mode has been run once, on 2026-08-01, before rounds 27, 34 and 35. (b) Round 33's own risk register says "the round records the wall time of each profile so the cost of running it is itself a documented number" — no profile wall time is recorded anywhere. (c) Round 35 measured six properties through the bundle and a whole-object style(), but benchmark/style.mjs and surface.mjs — the suites whose rows exist to notice a regression on exactly that path — have not been re-run since the dispatch table landed.

  4. The stranded doc block has happened ten times and the gate catches it only by accident. A later insertion lands between a block comment and the member it documents; the comment silently re-attaches to the wrong member. Rounds 26.1–26.4 found eight by reading, and 34.4 and 34.5 found the ninth and tenth — those two because the strand happened to leave a member reading as undocumented, which is what the coverage gate tests. When the displaced comment lands on another documented member instead, nothing notices: coverage stays 100% and two members carry each other's prose. That is the case round 26.1's json()/serialize() pair actually was.

Design calls (round 36) — all four are about scope, and each one narrows it:

  1. @returns is written, and reported, and not gated. Round 32's boundary is respected exactly as it was drawn: the gate covers what docmaker emits (@param), and @returns gets the gpu-throw-coverage treatment instead — auditReturnTags() prints its tally under the coverage report and --verbose lists the offenders, always exiting 0. Whether it should ratchet is a policy call of exactly the kind open call 8 already holds for test coverage, and this round does not take it.
  2. The stranded-comment check reports too, for the same reason — and because it is heuristic in a way the other three audits are not: it cannot distinguish a deliberately free-standing narrative comment from a displaced doc block. A gate would need that distinction; a report does not.
  3. The seven browser-only throws get specs, which is not open call 8. That call is whether throw coverage becomes a gate. Writing the specs that make a documented guard fire is the work rounds 30.1 and 30.2 already did in both projects, and it needs no decision — a guard nothing has ever triggered is not tested.
  4. A re-measurement is a measurement, and is recorded even when it moves nothing. This file's own history is three corrections of a conclusion reached by not running something (18.5, 27.9, 15.7 — twice the same wrong "no adapter on this box"). Every number this round produces lands in the record with the machine and date, and a row that reproduces its baseline is reported as reproducing it.

Pass split (tests-first where there is code; docs in-commit; each pass its own commit(s)):

  • 36.0 Docs-first (2026-08-04) — this plan section.

  • 36.1 auditReturnTags(), reporting-only (2026-08-04) — landed, written before any tag so the tally came from the shipped overload-aware scanner. That mattered immediately: the throwaway scan used to scope this round said 58 missing; the shipped audit says 63 of 276, which is round 32's figure exactly. Fifth time a hand-rolled scan has produced a wrong count here, and the first time the shipped one has reproduced a prior round's number to the element.

    Two extractor pieces were needed because a return annotation is not on the same line as the member name in general — signatureOf() joins forward until the argument list closes, and returnAnnotation() walks paren depth to the matching close, since ( fn: ( a: X ) => Y ): Z has three parens and only the outer one ends the arguments. One bug found by reading the audit's own output rather than by running it: the first cut joined forward from a field declaration looking for parens, ran into the next method's signature, and reported Animation.lastNow as returning the prose of the doc comment below it. CALL_MEMBER_RE narrows the class-member branch.

    12 fixture specs, and one of the four controls came back BAD — making VOID_RETURN_RE match nothing failed nothing, because two fixtures wrote members as one-liners with the comment inline (/** a */ a(): void {}), a shape the scanner does not match and the sources never use. Vacuous specs, caught by their own control; both rewritten, and the four controls now fail 1, 1, 8 and 1.

  • 36.2 The 63 @returns tags (2026-08-04) — landed in round 32's commit shape (core/viewport, collection, animation/style/contract), taking the surface to 276/276. A description, not a type restatement.

    What they carry that the annotation cannot: the first-element rule and its undefined case (label() answers '' for an unlabelled element and undefined for an empty collection — different facts); the readers that answer the effective value rather than the declared one (effectiveOpacity is what transparent() tests and is not style('opacity'); grabbable reads false for a pannable element while json() reports the raw field); the predicates that are not the negations they look like (inactive is not !active, isChildless is not !isParent, isOrphan is not !isChild); that remove() returns a collection which can be larger than its receiver and whose refs are dead by construction; and that the layout contract's positions()/ endpoints() hand back the store's own columns, so they shift under a held reference.

    Two findings inside the pass. (a) An eleventh stranded doc block, and the first of the invisible kind: a complete arrowBase() block sat above StyleEngine.lineOpacityConst with that member's own comment beneath it, so the coverage gate could not see it — the displaced block landed on another documented member rather than leaving one bare. It was shipping: dist/cytoscape.d.ts carried both blocks stacked, so a consumer hovering lineOpacityConst read a paragraph about arrow colours first. Round 31.1's defect class, live.

    (b) The @param gate had never walked exported functionsauditParamTags descended class bodies only, while this script's own header defines a public member as a class member "plus every top-level exported function". So wire.mts and columnar.mts — whose entire public surface is exported functions — sat outside a gate reporting 221/221, and all three of wire.mts's exported functions had no @param at all. (The 36.2d commit message says "two of the three"; re-checked against the pre-36.2c tree in the 36.8 verification pass, it is three of three.)

    Now 229/229, gated, with a spec that pins the widening rather than the count (wire.mts's tally must be non-zero, which it is only while the branch exists).

  • 36.3 allAre and is (2026-08-04) — landed in surface.mjs, 119 rows. The other three members the audit lists are a constructor and two long-form aliases of benchmarked rows, so a row for them would time the same function under a second name. Both members short-circuit, which is the whole difficulty: the obvious spelling of either measures one test rather than a hundred — 33.5's custom-polygon pick row in a different costume. The criteria force the full walk (allAre matches every element, is matches none) and the row labels say so. Spelled idiomatically per side, which needed a pair() helper beside cmp()/only().

    At N=2000 over a 100-element band: allAre 3.75 → 2.30 µs (1.6×), is 6.01 → 2.28 µs (2.6×). Collection bench coverage 97.5% → 98.5%.

  • 36.4 The seven browser-only throws (2026-08-04) — landed as four specs and three reclassifications, which is the honest split. Specced: no adapter (the README's own headline for the headless/rendered boundary), no webgpu canvas context, no 2d context for glyph rasterization (which surfaces on ready() because the atlas is built during renderer init), and a 404 background image — the one a caller reaches, whose contract is warn-once-and-render-imageless and whose spec asserts HTTP 404 inside the warning so it pins that guard rather than "an image failed somehow".

    Classified UNREACHABLE with reasons: gpu-context:38 is shadowed by construction (_attachFn checks navigator.gpu and then synchronously constructs the Renderer, whose ctor calls init(), whose first statement reads navigator.gpu again — nothing can run between the two); column-mirror:113 is a column spec/group mismatch no public input chooses; gpu-tween:408 says so in its own comment, barred one layer up by the round-25.1 eligibility rule. A tool bug fell out of the classification: browser and unreachable counted the same site twice once three sites were in both, so the tallies summed past the site total (191 reported as 176 + 13 + 5). unreachable now wins, as it already did in the --verbose labels. Reading: 176 run, 10 browser-only, 5 unreachable, 0 Node-reachable and never run. Controls: each guard neutered, the bundle rebuilt by hand (an http-server was on 3333), only that guard's spec re-run — one failure apiece, four for four. 172 browser specs (97 webgpu + 75 visual), goldens byte-stable.

  • 36.5 The three measurements (2026-08-04) — all three taken, on the RX 580 (amd gcn-4, dpr 2, 1280×800, render scale pinned to 1) and the i9-9900K. (a) --layout, run once before (2026-08-01) and not since rounds 27, 34 or 35. v4's force converges in 866 ms (25k×50k), 1594 ms (100k×300k), 759 ms (ndex, 19.6k×465k), 823 ms (curved), 870 ms (images) and — the two round-33.11 scenes that had never been run in layout mode at all — 859 ms (wrapped labels) and 860 ms (half-invisible), while the compound scene settles in 14.8 s on the CPU executor (the 14.11 lease rule). v3 cose reports "> 60 s — bailed" on every scene, as it did in the hardware pass.

    Against that pass's 697 / 1472 / 952 ms and 15.5 s the rows move +24% / +8% / −20% / −5% — in both directions, which is the reading: nothing in rounds 27–35 touched the layout path, and these rows cannot resolve better than about ±25% by construction, since round 18.3 recorded that GPU trajectories are not bit-stable run-to-run (atomic in-cell scatter order), so the iteration count to convergence varies. The two new scenes landing on the flat scene's number is the expected result: a layout does not care about labels or visibility. Whole run 10.1 min.

    Method note, and it changed the numbers: the first attempt was run while this session was also running test:js, tsc and lint, which is CPU contention against a wall-clock convergence measurement. That run was discarded and re-run with nothing else in flight. A benchmark is only as clean as the box it runs on, and this file's own standard is that a number nobody can reproduce is a record rather than a measurement. (b) The report profiles' wall times, which round 33's risk register promised ("the round records the wall time of each profile so the cost of running it is itself a documented number") and no round recorded: quick 7.1 min, --all 17.4 min.

    The runner prints its own total, so this was always one run away. --full is unmeasured — it adds the 2k/20k/200k matrix and is the profile nobody runs casually, which is the point of keeping quick quick. (c) The style getters through the bundle. Rounds 34 and 35 published their headline figures from throwaway harnesses, which contradicts round 33's design call 2. benchmark/style-bundle.mjs is now that source and joins --all; it imports build/cytoscape.esm.mjs and warns when the bundle is older than src/.

    Running it under --import tsx was measured to be identical rather than assumed safe (the __name wrapper is injected when esbuild transpiles a .mts, and this suite is plain JS importing plain-JS bundles), which is what lets it share the report's existing spawn. Round 35's numbers reproduce — 68 ns at the old sixth case, 53 and 50 in the middle, 93 and 110 at the back — and one is refined: the post-table spread is two populations, not one.

    A colour-valued read builds an rgb()/rgba() string, which costs about as much again as the whole dispatch-and-decode: background-color 118 ns and border-color 116 against border-width 64 and width 61 — and those two colours sat at opposite ends of the old switch, so it is not residual positional cost. background-color was the only colour among round 35's six, which is why it topped that table and why the remaining spread read larger than the dispatch actually is.

  • 36.6 The stranded-comment check (2026-08-04) — landed, and it is the round's own finding rather than an item from its plan's reasoning. auditStrandedComments() detects the two shapes that are detectable statically: a /** block whose next non-blank line opens another /** block (only the second documents the member), and a block that trails off the end of a class. It cannot detect the third — a block displaced onto a different, also-documented member — because the comment attaches to something and only a reader knows it is the wrong thing; a spec pins that limit so a clean report is not read as proof.

    Reporting-only for a second reason beyond round 32's boundary: it cannot tell a deliberately free-standing module note from a displaced block.

    Six on the first run, every one a block orphaned above another: AnimationManager's class doc above GpuTweenSink's; the edge.dashPattern column's above the casing column's — which itself sat above edge.gradient, so two columns wore the wrong prose and two had none; LabelEntry's above LabelStream's; _query's above _allOf's, displaced by round 34.2's insertion and invisible to the gate because both are _-prefixed; the ghost-props set's above the font-props set's; and writeImages' above writeChart's. All six moved back. The seventh hit is curved-edge-pipeline.mts's top-of- file block, left alone as a module header — the class it describes has no doc of its own — and it stays visible in the report as the standing count of 1, which is the ambiguity the check reports rather than gates on. 7 fixture specs; three controls failing 2, 1 and 2 — the third came back clean the first time and the control was at fault, its sed never having matched (round 31.3's lesson, repeating).

  • 36.7 The closing docs sweep (2026-08-04) — both documents swept end to end, plus AGENTS.md. The README carries round 36 in its header, the @returns and widened @param rules in the JSDoc section, the finished browser tier and the corrected tallies in "Measuring the error contract", style-bundle.mjs in the suite table with the colour-vs-numeric refinement beside round 35's record, the profile wall times, the re-measured force convergence with its ±25% caveat, and a round-36 entry in the follow-up hooks.

    This file gains the round-36 paragraph in "Suggested sequencing" (one of the three sites the standing rule names), the pass records above, the new files in the directory layout, and an update to open call 8 — the browser tier being finished makes the second part of that call smaller, and round 36 declined to gate its own two new audits, which keeps the report-only family at three against the gated family's three. The "Needs a call" ledger and "Gaps with direction already set" needed nothing: round 36 closes no design call and opens none.

    Two live figures were stale and are trued: the README header's "221/221" (now noted as 229/229 since 36.2) and open call 8's "13 browser-only sites" (10). The rest of the hits are per-round records, which are history and stay as written. AGENTS.md gains three notes, each earned: a doc block can strand onto the wrong member and coverage will not notice (with the shipped instance); an audit's scope is part of its claim, so check what it enumerates before quoting its 100%; and a tool's fixture must be written in the shape the tool actually parses, since round 36.1's own fixtures were silently skipped and two specs passed with the behaviour under test deliberately broken.

    dist/cytoscape.d.ts regenerated (1097 doc blocks) — the six un-stranded blocks move onto their real members there, which is the point of the fix.

Verification (2026-08-04): typecheck, lint, 2663 Node tests, 97 module tests, 172 browser specs (97 webgpu + 75 visual) against a hand-rebuilt bundle with goldens byte-stable and parity scenes at their recorded values, test:types:surface clean, JSDoc coverage 100%/100%, @throws 16/16, @param 229/229, @returns 276/276, stranded blocks 1 (the module header, by judgement), and gpu-throw-coverage at 176 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead. Round 36 is complete.

Risks tracked: a @returns description that restates the type adds noise to the shipped .d.ts rather than information (mitigated by writing what the value means — the units, the undefined case, the first-element rule — which is what the missing ones are missing); auditReturnTags over-detecting on this-returning chainables and setter overloads (excluded explicitly, and the exclusions are specs); the browser throw specs passing against a stale bundle, which is this repo's standing trap and the reason 27.1's first verification proved nothing (mitigated by building by hand before every run); and --layout wedging the suite the way it did before 18.5's nested timeouts existed (they still exist, and --layout-uncapped stays opt-in).

The fifth design sitting — the production-readiness roadmap

Every open call in the ledger, taken with the maintainer in one sitting; the per-item decisions are recorded in "Open calls for the maintainer" near the top of this file, and the rounds below execute them. The sitting's own record, briefly:

  1. border-style/outline-style: full coverage — every shape, the polygon perimeter tier included (round 38).
  2. Strictness resolves at the type layer, not the runtime. The constructor stays runtime-permissive (tsc's excess-property check is the typo guard — v4 does not replicate at runtime what the build checks); event names stay open because custom events (node.emit('foo')) are supported API and cannot be gated.
  3. The v4 Event is built — typed target, populated originalEvent, functional preventDefault() (the maintainer's amendment to the proposal, which had dropped it), no namespaces (round 41).
  4. Packaging: v4 becomes the package. v4's source promotes from src/gpu/ to src/ and becomes the default export of cytoscape@4; the entire v3 file set moves into a self-contained, still-buildable v3/ directory (parity and comparison benchmarks keep working against it), and no v3-specific file remains outside it (rounds 42–44).
  5. Gates: throw coverage and @returns both gate (round 37); stranded blocks and bench coverage stay report-only.
  6. Aliases split: roundrectangle drops, autolockNodes/ autoungrabifyNodes are kept as recorded exceptions (round 37).
  7. The small feature calls: overlap box mode, graph-level data in the wire format, and cy.gc() all build (round 39); core/collection extension points stay demand-gated deferred.
  8. The error policy is the one question deliberately left open. cytoscape.warnings() builds, but v3's mostly-no-throw stance (a throw can crash an app where ignoring is recoverable) against v4's fail-loudly design needs real thought — options like disable-all-warnings and demote-recoverable-errors-to-warnings are on the table. Round 40 is that sitting.
  9. The docs site: the generated v4 site replaces documentation/ at the root at release; v3's docs stay reachable through the existing versioned-docs mechanism (rounds 45–46).

Process note: at the maintainer's instruction this sitting lands as a PLAN.md-only edit — decisions and plans, no implementation. The README true-up that the docs-travel rule would normally pair with it is round 37's docs-first commit.

Governance close-out

Small and first, because the gates protect every round after it. All calls already taken (fifth sitting); nothing here needs design.

  • 37.1 The two new gates (2026-08-04) — landed. Throw coverage is a zero-tolerance gate: gateFailures() turns an audit() result into the build's failures, the CLI exits nonzero on any, and npm run test:throws joins the npm test chain after test:modules (it re-runs the root Node suite under coverage, so it cannot live inside that suite — the one structural difference from the JSDoc gates). @returns ratchets at 276/276 in test/jsdoc-coverage.mjs, beside @throws and @param.

    UNREACHABLE/MISATTRIBUTED are now checked, not just written. The promotion to "maintained allowlist" is a real mechanism rather than a change of tone: an entry that no longer names a throw new line, or whose reason is empty, fails the gate on its own. Zero tolerance is only as good as its escape hatch, and this one is keyed by file:line — round 34 already moved a site out from under its entry by inserting two methods above it, and under a gate that failure mode does not merely lose a site, it grants the exemption to a different throw while reading as a pass.

    Controls (the 31.2 pattern), each staged and asserted to fail: a Node-reachable site with no spec (1 failure, naming it), an allowlist entry pointing at a line with no throw on it, and an entry with a blank reason. Two more hold the gates against the real sources — the allowlist validation runs in the fast module suite against a report that says nothing (every site unknown, so only the exemptions can fail), and the @returns gate was run once with a tag deleted from viewport.mts, failing both its checks (the miss and the 276 floor). The CLI's exit code was checked end to end against a hand-written lcov marking one real site dead: exit 1.

  • 37.2 The alias split (2026-08-04) — landed. roundrectangle throws, joining cutrectangle/concavehexagon, and its line in test/decided-drops.mjs flips from pinning-the-inconsistency to pinning-the-drop. It was accepted in three enums, not one, which the call's wording did not say and the code did: the node shape table, parseLayerShape (overlay-shape/underlay-shape) and TEXT_BG_SHAPES (text-background-shape). Dropping it from shape alone would have moved the inconsistency rather than closed it, so all three go, with a spec per enum.

    A third spec pins a free consequence worth having: the shape error lists the accepted keywords from the table itself, so it stops advertising the dropped spelling to the v3 user who is reading it to find the replacement. debug's v3-fixture sanitizer learns the new spelling too. autolockNodes/autoungrabifyNodes stay wired and pinned, and the alias table's comment changes from "an open call, recorded beside the roundrectangle one" to the reason they are kept. Both documents' legacy-alias lines now carry the two-name exception, so code and ledger agree for the first time since 2026-07-29.

    One line deleted from style.mts broke three specs elsewhere, and fixing it properly was worth the detour: test/modules/throw-coverage.mjs's fixture named real throw sites by hardcoded line number (874, 1031, 1074), so deleting the roundrectangle row shifted every one of them by one. The fixture now resolves its lines from the sources by anchor text. It is the round-34 lesson arriving in a third place — after the UNREACHABLE allowlist it broke then, and the gate 37.1 built to catch that — and the same fix applies: a file:line written down is a claim that nothing above it will ever move.

  • 37.3 Constructor options, closed at the type layer (2026-08-04) — landed. The options type needed no tightening: it carries no index signature, so TypeScript's excess-property check already rejects every case. Four @ts-expect-error directives in typescript/tests/api.test-d.ts pin it — motionBlur, hideEdgesOnViewport, a plain typo, and one through the named CytoscapeOptions type — and the control ran: swapping one for a valid key (zoom) makes the directive unused and fails the build, so the check discriminates rather than passing vacuously.

    The runtime half is pinned too, which the plan did not ask for and the decision needs: three Node specs assert that the dropped canvas-era options are ignored, that a typo round-trips through options(), and — the contrast the whole decision rests on — that the names v4 interprets still throw. The ctor's and the factory's JSDoc record the asymmetry, including its boundary: excess-property checking applies to object literals, so options assembled into a variable first widen and pass. The item turned up a third instance of round 36's audit-scope failure, and closing it was in scope because this round is the one that gates these audits.

    Writing the factory's doc comment meant reading it, which showed cytoscape had no @param — and yet @param reported 229/229.

    src/index.mts has been listed in PUBLIC_API since round 26 and contributed zero members to every audit, because the exported-function pattern round 36 added matches export function and export const f = but not export default function. So the package's entry point — the most public member in the tree — sat outside coverage, @param, @returns and @throws while its file read as audited and complete, and all three of its tags were in fact missing. Widened, written, and pinned by a spec that fails if the file ever again contributes nothing: 17/17, 230/230, 277/277. Round 32 walked class bodies only; round 36 widened to exported functions; this widens again. Same lesson each time — an audit's scope is part of its claim.

  • 37.4 Event-name openness, documented (2026-08-04) — landed, and it corrected this file. Core#on, Core#emit and Collection#on now state the contract: any name registers, custom events are supported API (which is why no name can be gated), and a name v4 never emits registers cleanly and then silently never fires — so port event names from the vocabulary rather than by trying them. No denylist, no runtime change. Three specs pin it, because a documented contract nothing asserts is one that comes back by accident, and writing them is what turned up the correction.

    Namespaces do not behave as this file recorded. Contradiction 11 said cy.on('tap.ns', h) "never fires, not for tap and not for tap.ns either", and the README said the shared emitter keeps namespace parsing "only for v3". Measured (and true until round 41.2 removed the machinery): v4 imported v3's emitter, so namespaces parsed and worked in full v3 semanticson('tap.ns') listens for tap qualified by .ns, emit('tap.ns') runs both it and any plain tap listener, emit('tap.other') runs only the plain one, off('tap.ns') removes it.

    The narrower true statement is that v4 never emits a qualified name, so a namespaced listener sees application emits and never a library event (a data write reaches a 'data' listener and not a 'data.ns' one). Both documents corrected; the spec asserts each row of the table above, so round 41 — which removes the machinery — will have to change a test that describes what the machinery actually did. The original claim was probably taken from v4's own events only, which is the sense in which it read true; it is a good example of why "measured 2026-08-03" in a record still deserves re-measuring when a round leans on it.

  • 37.5 Closing docs sweep (2026-08-04) — landed, carrying the README true-up the fifth sitting deferred. The README gains a round-37 paragraph and the sitting's own summary in its header; the follow-up hooks' round-28 entry, which listed every open call as open, now records each decision and the round that executes it; the JSDoc section carries the two new gates, the widened audit and the corrected tallies; the events section and the Events: no namespaces paragraph carry the namespace correction; a new decided-design bullet states the type-layer-versus-runtime strictness split; and the legacy-alias line carries its two-name exception.

    This file gains the round-37 paragraph in "Suggested sequencing" (one of the three sites the standing rule names), the pass records above, the four closed items in "Open calls for the maintainer", the namespace correction in contradiction 11 and in round 41's plan, and the alias closure in the decided-drops ledger. "Needs a call" and "Gaps with direction already set" were checked by name: the sitting had already annotated the one live entry (border-style/outline-style → round 38) and the rest are history.

    AGENTS.md gains the two gates in the places that asserted the opposite — "it reports, it does not gate" and "@returnsreported rather than gated" were both false the moment 37.1 landed — and its audit-scope note gains round 37.3's third instance.

Verification (2026-08-04): typecheck, lint, 2675 Node tests, 102 module tests, 172 browser specs (97 webgpu + 75 visual) against a hand-rebuilt bundle with goldens byte-stable and parity scenes at their recorded values, test:types and test:types:surface clean (37 type exports, 3 statics, 1097 doc blocks), JSDoc coverage 100%/100%, @throws 17/17, @param 230/230, @returns 277/277, stranded blocks 1 (the module header, by judgement), and the now-gating gpu-throw-coverage green at 176 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead.

The two new gates were each run once in the failing direction, which is the only way to know a gate is a gate: a deleted @returns fails both of its checks, a staged dead throw site exits 1, a stale allowlist entry and a reasonless one each fail, and a valid key in place of an @ts-expect-error fails the typecheck as an unused directive. Round 37 is complete.

Risks tracked: npm test now runs the root Node suite twice (once plainly, once under coverage for the throw gate), which is the cost of measuring a suite's coverage from outside it — worth watching if CI wall time becomes a complaint, and cheap to split into a separate job. The throw gate's zero tolerance sits on top of allowlists whose staleness the gate now checks but whose judgement it cannot: an entry that is honestly wrong ("no caller can reach this" when one can) reads exactly like one that is right, which is why each carries prose rather than a flag.

And roundrectangle is the round's one behaviour change — a v3 stylesheet using it now throws where it silently worked, which is the intended failure but is the sort of thing a migration guide has to carry (round 47).

border-style and outline-style, full coverage

The last unported v3 style pair, at the scope the sitting chose: every shape. The technique has been settled since 27.8; this round builds all three tiers.

  • The perimeter coordinate, per tier: closed-form for circle/rectangle/round-rectangle (walk the sides + corner arcs); angle-parameterized ellipse — arc length is elliptic, so dashes space unevenly on eccentric ellipses (recorded deviation); the polygon family (sharp polygons, the round-* family, barrel, cut-rectangle, the custom polygon) via the SDF loop also tracking the argmin edge and its clamped projection against a per-fragment cumulative perimeter — roughly 2× polygon fragment cost where a dash is enabled, accepted; solid borders pay a branch only (the u-computed-only-when-dashed gate).

  • double — described here as a second inner band, which 2026-08-04's scoping found is not what v3 does: v3 re-strokes at borderWidth / 3 under destination-out, erasing a middle stripe from the fill and everything under the node rather than filling it. See the three sub-calls added to open-call 1 — all three taken at the sixth sitting (2026-08-06): the erase ports (with double-bordered nodes excluded from the opaque depth prepass, the gradient-fill precedent), border-dash-pattern/-offset port, and border-cap/-join drop with the deviation recorded. What remains for docs-first is text-border-style and the write-up.

    outline-style reuses the perimeter at the ring radius (offset perimeter, different arc length) and needs no props — v3 hardcodes [4, 2] and [1, 1] there. text-border-style stays out unless the same machinery makes it free (call at docs-first).

  • Both props enum channels with the standard parse/mapper/ stored-truth-readback plumbing; ghost bodies carry their border style like everything else.

  • Verification is the round-27 discipline: goldens per tier plus live v3 parity diffs per tier, each run once with the feature disabled to prove it can fail; dash-phase parity checked explicitly (v3 launches patterns at a defined origin per shape — read v3 source before asserting). A benchmark/ row prices the dashed-polygon fragment premium on the renderer bench (device time, dashed vs solid on the same scene).

Landed (2026-08-08)

The full-coverage scope, delivered, with the plan's own deviation budget mostly unspent — measurement kept killing the approximations it had authorized. Every tier, all three sitting sub-calls, and the docs-first call the plan reserved (text-border-style stays out; the label-box border is a different pipeline and nothing here makes it free).

  • 38.1 The style plumbing. Four props on the nodes group: border-style / outline-style (enums, case-mappable), border-dash-pattern (normalized to two on/off pairs exactly like the edge twin; v3's default [4, 2]; constants-only) and border-dash-offset (mappable). The enums ride borderGeom.y bits 8..11 — a word the FS already binds — because the node pipeline sits at exactly 8 fragment-visible storage buffers in both layouts; the pattern/offset are two new contract columns (node.borderDash/node.borderDashMeta) bound vertex-only and handed to the FS as flat varyings, 57.1b's slot trick in reverse. The ghost pipeline's vertex stage lands at exactly 8 with them. Stored-truth readback, validation (the negative-entry throw shared with the edge twin, its message generalized), and 9 Node specs.
  • 38.2 The perimeter coordinate. perimeterCoord per shape tier, with u = 0 and direction matching v3's canvas path per shape (read from v3's drawing-shapes source, not assumed): the rectangle 4-gon from the top-left corner down the left side; round-rectangle / bottom-round-rectangle arcTo paths from the top middle, clockwise; the cut-rectangle octagon; generated per-polygon walks in the same vertex tables as the SDFs; the custom-polygon blob walk; barrel's sampled corner curves in v3's order (which differs from the SD function's own). The plan's angle-parameterized ellipse died by measurement: its 5.0% parity mismatch exceeded the 3.6% a SOLID border scores, so the recorded-deviation scene could not discriminate — round 27's measuring-nothing case, caught by running the control. What shipped is exact elliptic arc length (composite Simpson, 48 intervals, dash-gated) plus a two-step Newton refinement to the nearest-point parameter, because the radial estimate shears ±2 px of phase across a ±2.5 px band — found when v3's 2-px-period dots rendered anti-aligned, quantified in Python, worst case after the fix 0.003 px. Round-* shapes walk their source polygon (recorded approximation, measured inside the 0.54% polygon tier).
  • 38.3 The three styles. dashed = pattern + offset; dotted = v3's hardcoded [1, 1], pattern ignored (a parity scene declares a wild [15, 15] pattern on every dotted node to prove the ignoring); double = v3's erase as alpha-0 stripe fragments (the sitting's call), with double-bordered nodes excluded from the opaque depth prepass on the gradient-fill precedent. Outline dashes evaluate at the ring's own radius, reproduce v3's anisotropic expandPolygon pad for the polygon family and the padded corner radius for round-rectangles. Two recorded deviations: outline dash phase on polygon-family shapes (v3 miters outline corners where v4's ring rounds them — geometrically different paths; the parity scene pins the ellipse family, the golden covers the rest), and outline-style: double draws solid (v3's own double-outline erase strokes at border width / 3 — a v3 bug, lineWidth 0 when borderless — so there is no sane behaviour to match). The stripe difference under edges is in the migration guide's re-check table.
  • 38.4 Verification. Five live parity scenes, all at zoom 2 after the first cut at zoom 1 could not discriminate (the AA fringe smears 2 px gaps — round 56's close-up lesson arriving for borders): closed-form 0.178% / polygons 0.538% / exact-arc ellipses 0.793% / dotted + double 1.190% / ellipse-family outlines 0.791%, bounds 0.8–2%, and the five feature-off controls at 4.17 / 4.03 / 3.61 / 10.86 / 3.61% — every scene fails with its feature turned off. A 40-cell golden spans every tier × style plus the outline rows parity excludes, with one ghosted dashed cell as fsGhost's only pixel coverage — two shader controls run, and the ghost-only one proves that cell is load-bearing. The renderer bench gained a solid/dashed border scene pair: the accepted ~2× dashed-fragment premium is unmeasurable at scene level (3.41 vs 3.41 ms device fit-all, 4.48 vs 4.52 ms zoomed-in, RX 580, 25k hexagons). Full suite: 2087 + 283 + 24 Node specs, throw gate 183/10/5/0 (it fired twice during the round — both allowlist keys moved by edits above them, the 37.1 failure mode caught both times), 226 browser specs with every pre-existing golden byte-stable — the solid border path is instruction-identical by construction, and the exact goldens are what proves it. MIGRATING.md (rows + a re-check entry), CHANGELOG.md and the docs updated in-commit.

The decided feature tail

Three independent small builds, all decided at the fifth sitting.

  • 39.1 Overlap box selection (2026-08-04) — landed, at the recorded lean. boxSelectionMode: 'contain' | 'overlap' as a ctor option plus a validated getter/setter in the round-20.1 shape; the store's refsInBox takes the mode, and GraphStore.edgeHitsBox is the new exact test. The CPU twin was extracted, not invented: segmentHitsBox in curve-geometry.mts is segmentHitsViewport from render/cull.mts line for line, epsilon included, so the question box selection asks is the one the cull pass has answered per edge per frame since the first cull pass.

    Curved edges take the conservative-then-exact shape: the memoized exact bb rejects, and only a survivor walks the flattened path at the drawn subdivision. The elementsInBox call, taken: the public query stays pure containment and gains no options argument. The mode is an interaction preference, so it should not move a programmatic caller's results, and the four-numbers signature is a known footgun (33.5) that a fifth parameter would deepen.

    Both gesture paths — pointer release and the three-finger touch box — go through one internal _elementsInGestureBox so they cannot drift. boxSelectionIncludesLabels reverses sense with the mode, and the docs say why rather than treating it as a special case: containment is an AND over an element's parts, overlap an OR, so under 'contain' the label must also be inside and under 'overlap' a label crossing the band is enough. That is v3's rule too. Not added to cy.json(), matching boxSelectionIncludesLabels: the export mirrors v3's shape, and both of these are v4 inventions. Recorded shape difference: v3 spells this as a per-element style prop (box-selection, with a third value 'none' that v4's events prop already covers), not a core option. The sitting chose the core-option shape; the difference is worth knowing because the ledger described v3 as merely "also offering overlap". Three specs were vacuous and the controls caught all three. Every overlap spec passed on the first run with the exact flattened walk deliberately removed — the conservative bb reject was doing all the work, so nothing tested the walk. Two "band inside the bb that the path does not reach" specs fix it (one curved, one straight-diagonal), and now removing the curved walk, replacing the straight clip with a bb test, or dropping the label-widening branch each fails exactly one spec. A fourth near-miss: the benchmark's curved row used curve-style: bezier, which bundles multi-edges only (12a), so on a fixture with no parallel pairs it measured straight edges and read identical to the row above it — unbundled-bezier fixes it, and the row now prints how many of its edges are actually curved. Costs (benchmark/spatial.mjs, N=2000/4000 edges, a band over half the graph, 2900–3120 elements caught): overlap is 1.9× containment on straight edges (246 → 470 µs) and 1.9× on curved (968 µs → 1.80 ms), the curved pair dearer on both sides because containment already evaluates curve endpoints there. A webgpu gesture spec runs the same shift-drag under both modes. The round-37.1 gate fired twice, correctly: edits to graph-store.mts and to wire.mts's header comment moved two UNREACHABLE sites out from under their file:line keys, and the build failed naming them rather than silently re-pointing the exemptions. That is the failure mode 37.1 was built for, arriving in the very next round.

  • 39.2 Graph-level data in the wire format (2026-08-04) — landed, at the recorded lean. Format version 4, flag bit F_GRAPH_DATA, section written last so the element payload keeps the byte layout v2/v3 readers expect; older buffers keep loading, and nothing branches on the version number — the presence flags carry it, which is why they can. ColumnarElements gains an optional data, cy.serialize() fills it (copied, not held by reference — the buffer is a snapshot), and deserializeElements reads it back.

    One JSON string, not a column, and the format's own doc block says why: everything else here is per element and scales with the graph, while cy.data() is a single small object of arbitrary values, so columnizing one row would buy a kind-tagged block that says nothing a JSON object does not.

    The asymmetry is the round's real decision and both halves are pinned: options.elements applies graph data (_bulkAdd, where the graph's own data is still empty), cy.add( buffer ) drops it. Each spec was run against the other implementation — apply removed, and apply added to add() — and each failed exactly one spec, so neither is passing by accident. A sixth spec pins the documented escape hatch (cy.data( deserializeElements( buf ).data )), which is what keeps the drop a default rather than a wall, and a seventh pins that a graph with no data() serializes to exactly the byte count it did before this round.

  • 39.3 cy.gc() (2026-08-04) — landed. The explicit alias of compact(): declare + prototype wiring, the alias table's 84th row, and the doc comment saying why the name is kept rather than merely accepted — an upgrading app already types it, and v4 has no separate garbage-collection concept for it to name instead (element bytes go back to the slot free-list at remove(); the slot-stable structures self-compact on their own thresholds since round 11).

    test/decided-drops.mjs had a spec asserting cy.gc === undefined alongside warnings/notify/noNotifications; it splits in three — notify/noNotifications stay absent with their reason, gc flips to pinning the alias, and cytoscape.warnings gets its own spec noting it is absent pending round 40, which is a different kind of absence and was previously filed under the same one. The alias table's own cross-check earned its keep on the way: the declare was first written this[ 'compact' ] with spaced brackets, which the sources-vs-table regex does not match, and the spec failed naming the count rather than the graph failing at runtime later.

  • Each lands tests-first with docs in-commit; 39.1 adds a webgpu gesture spec and a spatial-benchmark row (overlap vs contain cost).

  • 39.4 Closing docs sweep (2026-08-04) — both documents plus AGENTS.md. The README gains a round-39 line in its header and the three closures in the follow-up hooks' round-28 entry; the box- selection, wire-format and compaction sections carry the features themselves. This file gains the round-39 paragraph in "Suggested sequencing", the pass records, and the three items marked closed in "Open calls for the maintainer" (2, the cy.gc() half of 4, and 5) — each with what the item did not say and the round found out. AGENTS.md's benchmark rule gains 39.1's variant: a fixture can be styled into a mode it never enters, so have a row assert the property it is named for.

Verification (2026-08-04): typecheck, lint, 2696 Node tests, 102 module tests, 173 browser specs (98 webgpu + 75 visual) against a hand-rebuilt bundle with goldens byte-stable and parity scenes at their recorded values, test:types clean and test:types:surface at 38 type exports / 3 statics / 1104 doc blocks, JSDoc coverage 100%/100%, @throws 18/18, @param 231/231, @returns 278/278, and the throw gate green at 177 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead over 192 sites.

Every new behaviour was run once in the failing direction: three Node controls on the overlap query, one browser control on the gesture, and two on the wire format's load asymmetry (each half broken in turn). Round 39 is complete.

Risks tracked: overlap box selection is ~1.9× containment and runs on pointer release, so a very large graph pays it once per gesture rather than per frame — but it is a scan, and the fixture here is 2000 nodes; the 200k profile is unmeasured. The wire format's version bump means a v4 buffer read by an older build fails its version check loudly, which is the intended direction but is a compatibility edge a release note has to carry (round 47). And boxSelectionMode is a core option where v3's equivalent is a per-element style prop, so an app wanting per-element box behaviour has no port path — logged here rather than in the round record, since it is the shape the sitting chose.

The error policy and cytoscape.warnings()

The one question the fifth sitting deliberately kept open. v4 fails loudly by decided design — 191 throw sites are public contract, gated since round 37 — while v3 mostly avoided throwing because an exception can crash an app in a case that is recoverable by ignoring it. The maintainer wants real design here, not a quick answer.

Questions the sitting takes (docs-first carries them with a proposal to react to):

  1. A two-tier taxonomycontract errors (programmer mistakes: unknown props/keys/options, invalid arguments, malformed payloads — always throw) vs recoverable runtime conditions (a failed image fetch, a full glyph atlas, a deferred compact — today's warn sites). Which of the 191 sites sits in which tier is the sitting's real work; scripts/throw-coverage.mjs enumerates them, so the review is a pass over a list that already exists.
  2. cytoscape.warnings()'s shape — boolean toggle (warnings(false) silences the warn tier, v3's surface), or an options form (warnings({ demoteErrors: true }) / an errorPolicy ctor option) that demotes recoverable-tier throws to warnings — never the contract tier, or the fail-loudly design dissolves. Global vs per-instance is part of the same call.
  3. What a demoted error does — return value conventions for a call that would have thrown (no-op + warn? the v3 behaviour per site?), and whether the error event carries them.

Implementation follows the sitting inside the same round; the throw-coverage gate's classification lists absorb any re-tiering, and every site whose behaviour changes keeps a spec for both policies.

Taxonomy-first prep (2026-08-08; the autonomous pass the sixth sitting approved)

All 198 throw sites (the count moved 191 → 197 → 198 as rounds 48.3, 55–57 and 38 added guards) classified by reading each site's message and context, so the sitting reacts to a measured list rather than building one live. The headline: the demotion option is small. Demoting recoverable-tier throws would touch about 11 sites, all in the browser tier, in exactly two families — everything else is contract.

Contract tier: ~187 sites. Six clusters, none of which any sane policy demotes, because each is a caller holding the API wrong or a payload that cannot be honoured:

  • Style validation, the largest block by far (~95 sites in style.mts, easing.mts, style-schemes.mts, animation.mts): unknown properties and keywords, values out of range, mappers where constants are required, the decided-drop errors whose messages carry the replacement (queue, the style function form, pie-N-*…). These messages are load-bearing documentation — several are pinned by specs asserting the guidance, not just the throw.
  • The selector-replacement errors (matcher.mts, events.mts, algo-shared.mts): a string where v4 takes a query object or predicate. Round 47's migration guide leans on these firing.
  • Algorithm preconditions (~10): a missing root, k > n, Karger-Stein on a disconnected graph. v3 errors here too.
  • Structural/data integrity (~25): duplicate ids, edges to nonexistent endpoints, immutable data fields, remove-order and batch invariants, move() targets.
  • Malformed payloads (~20 in wire.mts, columnar.mts, data-store.mts, id-map.mts): truncated buffers, out-of-range indices, corrupt dictionaries — round 48.3's fuzz guards. Demoting a corruption error would hand the app a silently wrong graph, the exact failure mode the guards exist to prevent.
  • Internal invariants (~8, five of them in the gate's UNREACHABLE list): packing overflows, column/group mismatches — loud-fail by design, unreachable by construction.

Recoverable-runtime tier: ~11 sites, two families.

  1. GPU acquisition (5): index.mts ×2 and gpu-context.mts ×3 — WebGPU missing, no adapter, no canvas context. The canonical environment condition… with a caveat the sitting has to weigh: v4 has no fallback renderer, so a demoted acquisition failure is a permanently blank container with a console line. (The WebGL fallback logged at the sixth sitting as a future direction is the thing that would make this demotion meaningful.)
  2. Image export (5–6): destroyed renderer, empty graph, zero-sized container, texture-limit overflow (renderer.mts ×4), export from headless (core.mts:1942), and arguably the glyph-atlas 2d-context failure. These are runtime states, not static misuse — an app exporting during a resize race hits the zero-size guard through no bug of its own. A demoted export resolving null with a warning is a coherent contract.

(The image-decoder HTTP throw is internal — the registry already catches it into the existing warn path — and the big-endian wire guard is UNREACHABLE.)

The warn tier as built: 14 console.warn sites (deferred compaction ×2, image tier/layer caps ×3, glyph-atlas full, negative Bellman-Ford cycle, breadthfirst fallbacks, hierarchy and curve-index recoveries, columnar/style notes) — this is what warnings(false) silences on day one, no re-tiering needed.

What this hands the sitting: with the demotion list this small and half of it undermined by the missing fallback renderer, the measured recommendation is cytoscape.warnings(boolean) (global) + a per-instance ctor override as the v3-parity surface, and deferring the errorPolicy demotion option until the WebGL fallback exists to give family 1 a story — family 2 alone (≤6 sites) may not justify the policy machinery. The sitting decides; the list above is the evidence.

The sitting (2026-08-09, seventh design sitting) — closed with no new surface

The maintainer read the classification and took a smaller call than even the measured recommendation: errors and warnings stay exactly as they are. With GPU acquisition and image export the only recoverable families — and the acquisition family meaningless to demote while v4 has no fallback renderer — the demotion machinery is not worth its surface, and neither is the boolean toggle: the 14 console.warn sites stay plain warns with no cytoscape.warnings() over them.

So round 40 ships nothing: no warnings(), no errorPolicy, no re-tiering. Its lasting outputs are the taxonomy above (now the recorded rationale for the fail-loudly contract standing whole) and the closure of ledger item 4's second half — cy.gc() returned, warnings() did not. The three questions the sitting was to take resolve as: (1) the taxonomy stands as classified, all 198 sites contract-or-warn as built; (2) no shape, because no function; (3) moot. If a WebGL fallback renderer ever lands (the direction logged at the sixth sitting), the acquisition family's demotion question may be reopened by whoever builds it — that is a new call, not a residue of this one.

The v4 Event and emitter

Prerequisite for round 42: v4's one remaining shared-module dependency on v3 is src/emitter.mts (and the shared Event object with it).

Landed 2026-08-04, except one item that turned into a call. Two of this plan's premises were wrong, and both were wrong in the same way — they stated a fact about the code that nobody had measured:

  • "v4's one remaining shared-module dependency" is five: after the emitter and event object were severed, src still imports src/math.mjs, src/types.mjs, src/util/colors.mjs, src/util/position.mjs and src/util/sort.mjs. They are a different kind of dependency — generic utilities, no v3 model or renderer types in their signatures — so the restructure may keep them shared rather than duplicate them, but that is round 42's call and it now has the list. test/modules/import-graph.mjs is the audit, with the five as a maintained allowlist on 37.1's terms: a new edge fails, and so does an entry nothing imports any more.

  • preventDefault() could not be enumerated from v3 — see open call

    1. v3 never reads isDefaultPrevented either, so there is nothing to port and the list is a v4 contract to design. The DOM half of the item landed (below); the gesture half is logged.
  • 41.1 The v4 Event (2026-08-04) — src/event.mts. Typed target (the core or a one-element collection, so a handler narrows with a type guard instead of a cast — 26.5's logged item closes and the compile-only consumer test lost its as), originalEvent, layout, the derived renderedPosition, and no namespace field.

  • 41.2 The v4 emitter (2026-08-04) — src/emitter.mts, the same qualified-listener model with v3's namespace parsing, bubble/ parent recursion, manualCallback and function-in-qualifier-position shorthand all gone (v4 used none of them). Kept deliberately, because behaviour depends on each: the listener snapshot at emit time, the copy-on-off-during-emit, one removing before the callback runs, and a handler returning false meaning stopPropagation(). One recorded difference from v3, and it is a fix: v3 snapshots the listener list once per emit() call, so in emit( 'a b' ) a handler for a that calls off( 'b' ) does not stop b firing. v4 snapshots per event.

    The swap is behaviour-neutral by measurement: the whole Node suite passed unchanged except one spec — the namespace one round 37.4 had just written to pin v3's semantics, which is the single behaviour this round intends to change. It is rewritten to assert the new contract (a dotted name is one literal type), so the removal is pinned rather than merely done.

  • 41.3 The import-graph audit (2026-08-04) — above; five specs, controlled by adding an outward import and watching it fail.

  • 41.4 originalEvent, populated (2026-08-04) — the pointer layer attaches the DOM event it is handling to everything it emits. Set in the one listen() wrapper rather than at ~25 emit sites, so the two cannot drift; cleared in a finally, which is what keeps it honest — an emit from a timer (taphold, onetap) has no DOM event behind it and reports none rather than the last one seen. A webgpu spec asserts the DOM event's own coordinates reach the handler (so it is this event, not a retained earlier one) and that preventDefault() sets defaultPrevented on it; the control pins domEvent to null and the spec fails.

  • 41.5 Functional preventDefault() for gesture defaults — not built; the enumeration is open call 12. Direction set at the sixth sitting (2026-08-06): explicit gesture toggles come first and no default may be preventDefault-only — see item 12; the enumeration lands at this item's own docs-first stage.

  • 41.6 Docs + declarations (2026-08-04) — Event, EventProps, EventTarget and EventHandler are exported from the entry point, so a consumer can type a handler; dist/ cytoscape.d.ts regenerated (42 type exports, 1147 doc blocks). Both documents carry the removal, including the two JSDoc paragraphs round 37.4 had just written to describe the old behaviour — a reminder that a docs fix has a shelf life when the code is about to move under it.

Verification (2026-08-04): typecheck, lint, 2696 Node tests, 107 module tests, 174 browser specs (99 webgpu + 75 visual) against a hand-rebuilt bundle with goldens byte-stable and parity scenes at their recorded values, test:types clean, test:types:surface at 42 type exports / 3 statics / 1147 doc blocks, JSDoc 100%/100%, @throws 18/18, @param 231/231, @returns 278/278, and the throw gate green at 0 Node-reachable dead over 192 sites.

Controls: the import audit run with an outward import added (fails), the originalEvent spec run with domEvent pinned to null (fails), and the emitter swap itself measured against the whole suite — which is the strongest of the three, since a behaviour-neutral claim is exactly what a 2696-test suite can check. Round 41 is complete except 41.5, which is open call 12.

Risks tracked: the emitter is new code on the hottest shared path in the library, and "the suite passes" is evidence rather than proof — the snapshot/one/off-during-emit semantics are the fiddly part and are carried by existing specs rather than new ones written for them. The per-event listener snapshot is a deliberate divergence from v3 in an edge case nobody has asked about, so it could equally be a surprise. And originalEvent now retains a DOM event for the life of any event object a handler keeps — harmless for the pointer events it comes from, but worth remembering if an app stores events.

  • A v4 Event class: typed target (core/collection), cy, positions; no namespace fields or parsing anywhere; originalEvent populated by the pointer layer (the DOM event reaches handlers at last); preventDefault() functional — docs-first enumerates the preventable gesture defaults from v3-source reading (candidates: tap-selection/-clear, grab initiation, box start, cxt menu suppression is already unconditional) and each preventable default gains a spec proving both directions.
  • A v4 emitter replacing the src/emitter.mts import — the same ref/predicate-qualified listener model the core already uses, with the namespace machinery gone. Note what round 37.4 measured: it is live, not unexercised — v4 emits only unqualified names, but a hand-emitted 'tap.ns' runs the qualified listener and the plain one exactly as in v3, and off('tap.ns') removes it. So this is a behaviour removal with existing specs to update (in test/decided-drops.mjs), not a dead-code deletion. An audit pass confirms no other src import reaches outside src (the restructure's precondition, asserted by a spec that walks the import graph).
  • Ships in dist/cytoscape.d.ts: event.target: unknown resolved, the compile-only consumer test extended (handlers narrow no more). 26.5's logged item closes.
  • Bubbling, phase order, stopPropagation, and the round-14.5 specs carry over byte-for-byte — the event semantics do not change, only the object and its module.

The great restructure

The packaging decision, executed: v4 becomes the package.

  • v3/ is created as a self-contained subproject: v3's src/, its tests, documentation/, the top-level v3 benchmark suites, the v3 debug pages, the v3 rolldown/dts configs, and the stale hand-written root index.d.ts — everything v3-specific — with its own scripts so it builds and tests like a separate project. The parity harness (playwright-page/parity.html) and comparison benchmarks build v3's UMD bundle from v3/; nothing v3-specific remains outside the directory.
  • src/gpu/* promotes to src/ (the .mjs-specifier convention keeps import edits mechanical); test/gpu-*, test/modules/*, benchmark/gpu/, playwright-*, scripts/gpu-*, debug/webgpu/, the rolldown/tsconfig/audit configs all re-point. Whether the gpu- file-name prefixes stay (history) or drop (tidiness) is a docs-first call — the audits' file lists change either way.
  • Root package.json becomes cytoscape@4.0.0-unstable (round 49 settles the prerelease spelling): v4 is main/module/types and exports["."]; the ./gpu subpath stays as a deprecated alias through the prerelease line and is removed at 4.0 (confirm at docs-first). CI workflows re-point.
  • Behaviour-neutral by assertion: the full verification gate runs green before and after — Node, module, all browser projects with goldens byte-stable, types, the five audits, a benchmark smoke — because a restructure that changes pixels or numbers has done something else.

Landed 2026-08-04. The plan held; the three calls it left open were taken with the maintainer at the round's docs-first stage, and one of them was not on the plan's list at all.

  • 42.1 The three calls.

    • The gpu-/webgpu- prefixes drop. test/gpu-*.mjstest/*.mjs, test/modules/gpu-*test/modules/*, benchmark/gpu/benchmark/, scripts/gpu-*scripts/*, debug/webgpu/debug/, playwright-tests/webgpu.spec.jsrenderer.spec.js, webgpu-visual.spec.jsvisual.spec.js, and the Playwright projects with them (renderer, renderer-webkit, visual). Inside src/ the gpu- names stay — gpu-context, gpu-types, render/gpu-force, render/gpu-tween, render/gpu-timer — because there the prefix names the device half against a CPU counterpart (layout/force-sim.mts is the contrast), which is a live distinction rather than a v3-era label.

      (42.6 found one of those five wrong: gpu-types.mts holds the public option surface and is not a device module at all — it is public-types.mts now. See 42.6 below.)

    • The five shared utility modules duplicate rather than stay shared — the call 41.3's allowlist was logged waiting for. v4 owns lean copies, v3 keeps its originals, and nothing under src/ imports outside it. src/math.mts carries the seven functions v4 actually calls, copied verbatim, rather than v3's 1500-line file: not tidiness but scope, since the audits walk everything in src/ and the wholesale copy would have added a thousand undocumented lines to the internal tier. util/colors.mts needed one edit — is.arrayArray.isArray — which is what let its v3 is.mjswindow.mjs/event.mjs tail be dropped.

    • The v4 identity rename, which the plan did not anticipate and which follows from exports["."]: bundles are build/cytoscape.*, the declaration is dist/cytoscape.d.ts, the UMD global is cytoscape, and the default export is cytoscape( options ). The two runtime error messages and the JSDoc that named cytoscapeGpu were rewritten with it, on round 31's rule that a message advising a form that no longer exists is a defect a consumer sees. The Gpu* type names were left for a separate call, being exported surface — logged as open call 13, taken the same day, and executed as 42.6 below.

  • 42.2 v3/ as a subproject. v3/src, v3/test, v3/benchmark, v3/debug, v3/documentation, v3/playwright-{tests,page}, v3/typescript, v3/scripts, v3/dist, the stale hand-written v3/index.d.ts, its rolldown/dts/tsconfig configs and its own package.json. cd v3 && npm run build works off the root node_modules (npm puts every ancestor's .bin on PATH), and cd v3 && npm test runs its 698 Node + 37 module + 18 chromium specs. v3's Playwright serves port 3334, not 3333 — with both on 3333, reuseExistingServer could silently attach one project's specs to the other project's server, which is the stale-bundle footgun in AGENTS.md wearing a worse hat.

  • 42.3 Root package.json is v4's alonecytoscape@4.0.0-unstable, v4 as main/module/types and exports["."], ./gpu retained as a deprecated alias resolving to the same files, unpkg/jsdelivr at the v4 min bundle, and the v3-only devDependencies (handlebars, marked, highlight.js, gh-pages, benchmark, lodash, heap, …) moved to v3/package.json. tests.yml gains a ci-v3 job.

  • 42.4 The pieces that needed more than a move.

    • playwright-page/parity.html loads both UMD bundles, which now both export the global cytoscape, so v3's is captured under its own name before v4's script overwrites it. It reads v3's bundle from v3/build/.
    • The parity specs fail rather than skip when that bundle is missing, naming cd v3 && npm run build:umd. A golden asks "did this change?" and only parity asks "is this right?" (round 27), so a parity suite that quietly stops running is worth less than one that is absent. Controlled: with the bundle moved away the spec fails naming the command.
    • test/modules/import-graph.mjs is rewritten, not re-pointed. Its allowlist is empty and the invariant absolute. That inverts its own control: while outward edges were expected, "none found" was evidence the scanner had broken; now it is the passing answer, so the controls count internal edges (≥ 100) and source files (≥ 80) instead. A new spec pins that nothing reaches into v3/.
    • build-dts.mjs, rolldown.config.mjs, rolldown.dts.config.mjs, playwright.config.js and the tsconfigs each split in two.
    • The three v3 release workflows are marked unadapted, not half-repointed: they deploy v3's docs site from documentation/, and a workflow that publishes the v4 package while deploying v3's docs would read as adapted while being wrong. Round 50 owns them. They also make the one unavoidable exception to "nothing v3-specific outside v3/" — GitHub reads workflows only from the repo root — which is recorded rather than papered over.
  • 42.6 The Gpu* type names (2026-08-04) — open call 13, raised by this round and closed by it once the maintainer took the call. Every exported type drops the prefix: GpuCoreCore, GpuCollectionCollection, GpuEventEvent, GpuStylesheetStylesheet, CytoscapeGpuOptionsCytoscapeOptions, and so on through all 42 exports. No deprecated aliases, unlike exports["./gpu"]: that alias exists because v3's users already type the name, while nobody has yet written GpuCore against a published build. Six names keep the prefixGpuContext, GpuTimer, GpuForceRuntime, GpuTweenRuntime, GpuTweenSink, GpuWriteKind — on 42.1's rule: each names the device half against a CPU counterpart (GpuWriteKind is literally Exclude<WriteKind, 'lane' | 'padding' | 'fontSize'>, the write kinds the kernels support), and none is exported. Three things the rename turned up that a blind sed would have got wrong:

    • Two collisions with names v4 already had. GpuForceLayoutOptions (the public option shape) and ForceLayoutOptions (a module-local interface in layout/force.mts) would have become the same name; the internal one is now ForceRunOptions. GpuWriteKind would have collided with animation.mts's WriteKind, which is what identified it as a device-half name rather than a prefixed one — the collision was the evidence, not the obstacle.

    • Two collisions with the DOM. Event and EventTarget are globals. Taken anyway, because they are the right names for cytoscape's own event object and its target type, and v3 spells its own event class Event too. It costs nothing inside src/: event.mts already isolated the DOM type behind type NativeEvent = globalThis.Event, and the only module using the bare DOM Event (interact/pointer.mts) does not import ours. A consumer who imports Event from cytoscape shadows the global in that file, which is their choice to make.

    • gpu-types.mts was not a device module and should never have kept its prefix in 42.1: it holds the public option and type surface, not anything about the GPU. Renamed public-types.mts, which is what v3 calls the same role — and which is available precisely because the vendored geometry types took types.mts. Three internal fields went with them (_gpuPhaseRef, _gpuPhaseEle, _gpuBarred_phaseRef, _phaseEle, _barred), and the rename was applied to PLAN.md, src/README.md and AGENTS.md on 42.5's reasoning: a symbol name in a record is a live pointer.

      One historical name is deliberately left: GpuStyleFn, the style-function type round 8 deleted, which never had another name to be renamed to.

  • 42.5 Closing docs sweep — this file, src/README.md (which moved with the source, from src/gpu/README.md) and AGENTS.md. The renames were applied throughout all three, historical round records included: a path in a record is a live pointer, and the round-42 note in the directory layout is what explains why the old spelling is gone. This file gains the round-42 paragraph in "Suggested sequencing" (one of the three sites the standing rule names), the rewritten directory layout with the v3/ tree, and this record.

    AGENTS.md's "Environment & tooling", "Development flow" and "Repository structure" sections were rewritten rather than patched — they described a v3 repo with a prototype in a subdirectory, which is now exactly backwards — and it gains two new rules (below). The root README.md is v4's; v3's moved to v3/README.md. The full v4 docs README rewrite stays round 44's.

Verification (2026-08-04): typecheck, lint, 1998 Node tests and 71 module tests at the root, 698 Node + 37 module tests in v3/ — 2696 and 108, the same totals round 41 recorded for the then-single suite, now split by project (108 vs 107 because the import-graph audit gained a spec). 174 browser specs (99 renderer + 75 visual) against a hand-rebuilt bundle, with goldens byte-stable (no diff in playwright-tests/goldens/) and every parity scene at its recorded value — 0.000% across the board, the stripe pair at 0.005% — which is the strongest single piece of evidence here, since those scenes render through both renderers and would move if either side had shifted.

v3's 18 chromium specs pass. Types clean: 42 type exports, 3 statics, 1147 doc blocks — the counts unchanged across 42.6's rename, which is the point of checking them: 42 exports before and after means the sweep renamed the surface rather than dropping or duplicating part of it. The five audits unmoved — JSDoc 100%/100%, @throws 18/18, @param 231/231, @returns 278/278, stranded blocks 1, and the throw gate at 177 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead over 192 sites. Benchmark smoke: the core/collection sweep runs and still measures v3 against v4.

Both WebKit projects (renderer-webkit here, webkit in v3/) fail to launch on this machine for want of system libraries — browserType.launch, not a spec failure — which is pre-existing and is why round 41's record also counts 174 rather than 273. Recorded rather than silent, per the standing rule. Round 42 is complete.

How behaviour-neutrality was actually established, because a green suite proves the paths resolve and not much else on a change that moves ~1100 files: every file now under v3/ was compared byte-for-byte against its pre-move blob (821 files, 6 differed, all six intended and named above), and every file under src/ against its src/gpu/ original with the diff filtered to the only two changes the round was allowed to make — an import-depth fix or the factory rename. Anything that filter printed was a bug; it printed nothing.

That check is now a rule in AGENTS.md, together with the second thing the round learned: a vendored copy joins the audits. The JSDoc gate failed the moment the five utility modules landed — 19 undocumented exports, internal tier 96.8% — which is the gate working, since a file in src/ is v4's regardless of where it came from. The fix was to document the copies, and it is the argument for copying lean.

Risks tracked: the round touched every path in the repo, and the parts with no automated check are the ones to watch — the debug/ harness and the watch scripts were exercised by hand rather than by a spec, and the three marked release workflows will fail on their next run by design, which is only correct if round 50 actually adapts them.

dist/ still holds nothing but the declaration, so main/module resolve to files a git install does not have — pre-existing, unchanged, and round 44's first item. 42.6's rename is the round's one breaking change to a name a consumer could already have written, and it ships without aliases on the reasoning that the prerelease line has no published consumers; if that reasoning is wrong the cost lands on whoever tracked v4 from git.

The debug harness

Inserted ahead of the release sequence at the maintainer's request, which is why rounds 43–50 renumbered to 44–51. debug/ is v4's only manual harness — the page npm run watch opens, and what anyone forms a first impression from — and it was both broken and misleading.

What scoping found, none of which any test could see, because debug/ had no coverage of any kind:

  1. Four of the seven networks 404'd. debug/networks.js pointed at ../webgl/*.json, which resolved to debug/webgl/ until round 42 moved the v3 tree to v3/. The fetch rejected with no .catch, so the page rendered nothing and said nothing. This is a regression round 42 introduced: its asset check read HTML src/href attributes, and these URLs are fetched from JS.
  2. sanitizeStyle threw the styling away — a 14-property whitelist, every mapData(...) dropped, every [attr = …] block dropped, and it looked for label while all four Cytoscape-desktop exports spell it content. Its comment still justified the drop in terms of "fn styles", removed in round 8. The visible result: every fixture rendered as flat monochrome discs, so the harness read as "v4 can't style" while v4 has ~120 node props and a mapper DSL.
  3. Labels defaulted off, and turning them on replaced the sheet's mapping with data(id) — UUIDs on em-web, numeric SUIDs on the NDEx sets.
  4. No layout, view, toggles, selection, events or add/remove controls.
  • 43.1 The breakage. Fixtures re-pointed at ../v3/debug/webgl/… (69 MB already tracked under v3/; a copy would double the repo's weight, and a v4 harness reading a v3 asset is what benchmark/graph.mjs and playwright-page/parity.html already do). The fetch gained a .catch that writes the failure — and the reason — into the stats overlay. ?layout and ?seed joined paramDefs as live params, so a control change stops silently wiping them. The overlay reads the public cy.renderer() rather than cy._renderer, and reports the three stats it was ignoring (mapperUploadedBytes, mapperDispatches, the label shaping-memo hit rate).

  • 43.2 ndex-x-large, re-slimmed. The previous slim kept id/source/target/position only, which left the biggest fixture unlabellable and unstylable.

    Re-derived from the 250 MB original by debug/slim-ndex.mjs (committed, so the provenance is re-runnable), now carrying node name (gene symbols) and Node_Type ('TF' on 6052 of 19607) and edge Mechanism_of_Action (−1..1). One edge channel, not two, and the reason is worth recording: at 465k edges the key name is the cost, so Likelihood would have added ~11 MB for a width mapper — and width is a geometry channel, CPU-evaluated by design, so it would have shown less for more bytes. Mechanism_of_Action drives a diverging paint mapper, which is what the GPU eval kernel actually demonstrates at this scale. Edge ids are dropped (v4 assigns one), which pays for about half the increase: 28.6 → 34.1 MB.

  • 43.3 Hand-authored styles. sanitizeStyle is deleted, not improved: the maintainer's call was that the interesting thing is what a v4 sheet looks like, not what survives a translation. debug/styles.js carries one native sheet per fixture plus a plain sheet (the pre-round-43 look, kept for when you are debugging the renderer and want nothing to blame but geometry). The headline is em-web: the real enrichmentmap.org style, ported from the web app's network-style.js — whose v3 form is embedded in the fixture, so the two can be read side by side.

    Its node colour is a memoized per-element function over a chroma scale there and a declarative diverging mapper here, which is the clearest single argument for why v4 removed style functions. Two of its properties cannot be reproduced, both by decided design, and both recorded in the file: z-index (dropped 2026-08-01 — draw order is structural) and node:selected restyling (no selector blocks; the accent is shader-drawn).

  • 43.4 Two real compound networks. compound-fixture ports v3's hand-built graph (v3/debug/compound.js) verbatim — three levels of nesting, a self-loop on a parent, parent↔descendant edges, one very long label, awkward on purpose. em-web-clustered materialises EnrichmentMap's own mcode_cluster_id into compound parents: 41 clusters over 354 of 569 nodes, a real compound structure sitting in real data rather than a synthesised one.

  • 43.5 The v3 page's controls, carried over. View (fit / fit-selected / center-selected / reset / zoom / panBy, the animated fit/center/panBy targets v3's page never demos, the rendered-bounding-box overlay, mount and unmount), layout (all seven built-ins plus the spiral extension-contract example, with v3's layoutstart/layoutstop timing readout), the core toggles (v3's seven plus box selection, selectionType, the zoom range, wheel sensitivity, and the v4-only boxSelectionMode and boxSelectionIncludesLabels), v3's data-driven button.toggler pattern (dispatch on the button's own text) extended with select/show/hide/remove, add/remove with its ms readout plus a compact() button, and a query panel where v3 has a selector box — because that is the replacement, and a selector string would throw.

    The events section is the one deliberate departure: v3 pops a 3-second toast that the next event overwrites, so a sequence — the only interesting thing about the drag or tap families — can never be seen. This is a scrolling, filterable, pausable log instead, with the per-frame families off by default and one delegated listener showing the predicate form.

  • 43.6 Production-like defaults. Labels on; the checkbox is now a plain on/off over whatever the sheet declares rather than a replacement for it.

  • 43.7 The active-bg indicator follows the drag. showActiveBg was called from exactly one site — onPointerDown — so the circle was positioned once, in screen space, and nothing moved it again. Fixed v3's way: the press point is stored in model space and re-projected per move, because the graph moves under a background pan and a model-anchored circle therefore stays glued to the point pressed. Worth being precise about what that buys, since the two implementations are observationally equivalent in the common case: during a pan the graph delta equals the cursor delta, so re-placing at the cursor would look identical.

    They differ only when the press stays in pan mode while nothing pans, which takes both userPanningEnabled: false and boxSelectionEnabled: false (with box selection on, a background drag with panning off becomes a box gesture and shows no indicator at all). A spec pins each half; the first was run with the reposition removed and fails, the second passes either way, which is itself the honest reading.

  • 43.8 The harness gets tested. test/modules/debug-harness.mjs loads the harness's browser globals as scripts and asserts the two things a headless process can: every fixture a network names exists at the path the page will ask for, and every sheet compiles against that fixture's real data through the real entry point. It found a bug on its first run — range: 'category10' for 30 MCL clusters throws ("scheme has only 10 entries; 30 needed"), because v4 will not silently recycle a categorical palette — which is exactly the class of drift that made the page flat in the first place.

  • 43.9 Docs, and a sweep that found round 42's leftovers. This record, the renumbering, and a pass over all four documents.

    Two leftovers of the 42.6 rename: auto-generated element ids read 'gpu-' + n (Core#_newId), which ele.id() returns — now 'cy-', and no spec asserted it — and the round-42 note under the directory layout still listed gpu-types among the names that deliberately keep the prefix, which 42.6 had renamed to public-types.mts for exactly the opposite reason. One leftover of the round-42 split: the root's test:build script was copied across whole, but TEST_BUILD is read by v3/src/test.mjs and by nothing in v4 — so the script silently re-ran the ordinary Node suite while AGENTS.md claimed it "exercises the built bundle".

    Removed, and the claim replaced with what actually does (the Playwright projects and benchmark/style-bundle.mjs). And a method worth keeping: round 42's sweep worked from a hand-written substitution list, so it fixed every spelling it thought of and missed the ones it did not — typescript/tests/gpu.test-d.ts and test/modules/gpu-import-graph.mjs in src/README.md, three pointers at v3 sources that had moved under v3/, and a line-wrapped dist/cytoscape-gpu.d.ts. Extracting every rooted path from the markdown and testing it with existsSync found all of them at once. That check is now a rule in AGENTS.md; it is the only one that catches a spelling nobody anticipated.

Verification (2026-08-04): typecheck, lint, 1998 Node tests, 90 module tests (71 + 19 new), the throw gate at 177 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead over 192 sites, JSDoc 100%/100% with @throws 18/18, @param 231/231 and @returns 278/278, the declaration at 42 type exports / 3 statics / 1148 doc blocks (one more than round 42: the new Core#_newId comment, which ships because TypeScript emits private members into the declaration), and 176 browser specs (101 renderer + 75 visual) with goldens byte-stable and the parity scenes at their recorded values — this round moves no pixel in any scene they cover. The new active-bg spec was run once with the fix removed and fails. And the check that would have caught round 42's breakage: every one of the nine networks was driven in a real browser and asserted to render — em-web at 569 nodes labelled "PKR-mediated signaling", ndex-x-large at 19607 nodes / 464657 edges labelled "TULP3", and so on through the list.

Risks tracked: the harness now reads fixtures from v3/debug/webgl/, so the root module suite depends on the v3 tree being present — true in this repo by construction, but it is a coupling that did not exist before, and it is why the spec checks the path rather than trusting it. The ndex-x-large fixture grew 5.5 MB. And debug/styles.js is hand-authored against a style surface that keeps growing: the module spec proves the sheets compile, not that they still look right, so a property that changes meaning rather than disappearing will not be caught by anything but opening the page.

Packaging and publish hardening

The gaps the 2026-08-04 infrastructure pass found, closed for the v4 entries.

The round's finding is that its own plan overstated what was open. Two of the three bullets below were written as decisions to take, and neither is: the existing release convention already answers the first, and round 42.5 had already done half the third. What was genuinely missing was the enforcement — nothing checked that the manifest, the build and the tarball agreed — and that is what landed.

  • What dist/ ships at release was never an open question. The plan says "decide what is committed at release"; v3 answers it — v3/dist/ tracks all six artifacts (cytoscape.{umd,min}.js, cytoscape.{cjs.js,esm.mjs,esm.min.mjs}, cytoscape.d.ts) and dist/ is not in .gitignore, so the convention is commit-at-release and v4 inherits it unchanged. v4's dist/ holds only the declaration because round 26.5 added that file and no release build has ever run — the artifacts appear when one does (npm run dist), which is round 50's.

    So there was nothing to decide and nothing to fix here; what was missing was a check that the chain rolldown outputs → dist:copy → the manifest → the tarball is consistent, since every link in it is hand-maintained. Measured while checking: npm pack --dry-run shipped 104 files / 2.4 MB — src/ (100), package.json, README.md, LICENSE and dist/cytoscape.d.ts. (106 / 2.5 MB since round 47 added MIGRATING.md and CHANGELOG.md, which ship deliberately. Worth leaving visible rather than silently restating: this figure was true when it was written and false two rounds later, in the same sitting, which is the drift the closing sweep exists for.)

    src/ shipping is deliberate and matches v3 (source-map resolution), so that half of the plan's "decided deliberately" is also already decided.

  • 44.1 The pack-contents spec (2026-08-04) — test/modules/packaging.mjs, run by test:modules and so by npm test. It asks npm itself (npm pack --dry-run --json) rather than re-implementing .npmignore, and pins both directions: the entry points a consumer resolves are present (and src/ is, at > 50 files — the control for every exclusion, since a pack that shipped nothing would satisfy all of them), and no development tree, no repo document (PLAN.md, AGENTS.md, CLAUDE.md, …) and no v3/ file ships.

    .npmignore is a denylist, which is the reason this is worth gating: every directory added to the repo ships by default, so the failure mode is additive and silent. That is also why the .npmignorefiles migration the plan floated is declined and recorded rather than done — the migration's whole argument was "cleaner to keep honest", and a spec that reads the real tarball keeps it honest without a repo-wide convention change (AGENTS.md rule 7).

    The manifest half is the part with teeth: every path main/module/types/unpkg/jsdelivr/exports names must be produced by a build script — proved statically against dist:copy's own argument list and against the real rolldown.config.mjs, imported rather than parsed (this repo has had five plan figures come from throwaway scans; importing the config cannot drift from it). A sixth spec pins dist:copy's list as exactly rolldown's five outputs, both directions. Deliberately not checked: that those files exist right now.

    They do not in a clean checkout, and whether a release build ran before a publish is release-workflow business (round 50), not a property of the source tree. The header says so.

  • 44.2 The exports-map spec (2026-08-04) — same file. A plan correction first: it says "the current test:types:exports checks only the v3 d.ts", which reads as though the root had one to widen. It does not — round 42 moved that script to v3/ with the rest of v3, so the root had no exports coverage at all and this is new work.

    Six specs over every subpath: the map is a conditions object keyed by relative subpaths pointing at relative targets; types is first wherever it appears (TypeScript takes the first matching condition, so a types after import is silently never used); import resolves to .mjs, require to .js, types to .d.ts; the legacy main/module/types fields agree with the . conditions (when they disagree a package behaves differently per bundler, which is the worst shape a packaging bug takes); ./gpu — the deprecated alias v3's users will type — resolves to identical files, since an alias that drifts from its target is worse than no alias; and the CDN fields name a bundle the build produces.

  • Already true, verified rather than rebuilt: the root README.md is v4's (round 42.5) and states the requirements rule where installers read it — headless needs no GPU, a container requires WebGPU, the factory throws synchronously without navigator.gpu — and engines is node: >=24. No browserslist was added: picking a browser set is a support commitment, not a packaging fix, and WebGPU availability is not what a browserslist query answers.

  • Controls, all eight run (the standing rule — a spec is guilty until it discriminates): dist:copy dropping one bundle fails 2 specs (the copy check and the manifest check, since main names it); .npmignore no longer excluding test fails 1; .npmignore excluding dist fails 1; an exports subpath naming an unbuilt bundle fails 1; types moved after import fails 1; main disagreeing with exports['.'].require fails 1; ./gpu drifting from . fails 1; and unpkg naming a file dist:copy does not produce fails 2.

  • Left to round 50, and logged here rather than in a round record so it is not lost: the first release build must actually commit the five bundles, and pre_release_test.sh should run this spec after npm run dist so the existence half is checked where existence is meaningful.

The docs generator

Round 26's deliberately deferred half, now due: the release docs are generated from the JSDoc the gates have kept complete. Nineteen rounds of gating paid for itself here — the generator needed no content written, because @param, @returns and @throws have been complete and gated since rounds 32, 36/37.1 and 31.2. Reading is 362 documented members over 48 sections in 7 namespaces, npm run docs:api.

  • 45.1 The generator (2026-08-04) — scripts/docs-generate.mjs, emitting docmaker's shape ({ name, descr, formats: [ { descr, args: [ { name, descr } ] } ] }) grouped into the // -- section -- banners, with pureAliases carrying v4's 84 declare x: this['y'] aliases the way v3's docmaker carries its own. It extends the existing scanner rather than adding a second one: auditFile now returns each member's doc block and banner beside its name, so generator and audits cannot disagree about what a member is. This repo has had five plan figures come from throwaway scans; a generator with its own regexes would have been the sixth. Three shape calls, each recorded in the file:

    • descr is the first paragraph, formats[i].descr the whole block. docmaker's descr is a summary sentence and round 26's convention puts one first; an overload's full contract then rides its own format rather than being flattened into its sibling's.
    • @returns/@throws/@see are emitted as their own fields, not folded into the description as this plan said they would "ride" it. docmaker has no field for them, but folding is lossy in a way a template cannot undo, and a template ignores a field it does not know.
    • What is published is derived, not listed. An exported function is documented iff src/index.mts hangs it on the factory — which is what separates serializeElements from the type predicates beside it in the same file — read from the assignment lines, so a fourth static cannot be silently undocumented. Deliberately absent: prose sections (hand-written, round 46) and any md field (in v3 that names a markdown file holding a member's long prose; in v4 the long prose is the doc comment, which is round 26's whole point).
  • 45.2 The gate (2026-08-04) — test/docs-generate.mjs, 9 specs, checking the model against dist/cytoscape.d.ts. The choice of witness is the point: generator and audit read the same files with the same scanner, so checking one against the other proves only that a regex is self-consistent. The declaration is a different artifact, produced by a different tool from the same source, and it is what a consumer holds. Both directions fail — a phantom entry sends a reader to a method that does not ship, and a dropped entry reintroduces silently exactly what round 26's coverage gate exists to prevent.

    Note this runs the opposite way to the test:types:docs precedent the plan cites: that one is v3's (v3/test/types-docmaker-surface.mjs), where the docs are hand-written and the types are the source of truth; here the docs are generated and the shipped types are the independent check. The stranded-block precondition is scoped rather than global: round 36 left that audit reporting-only because it cannot tell a deliberately free-standing module note from a displaced block, and the one standing hit is exactly such a note — but inside a published file the ambiguity is gone, since a displaced block there would now ship twice, under the wrong name both times.

    Gated at zero there, still report-only everywhere else. Controls, four, each failing exactly one spec: a documented source member the declaration lacks; a shipped member the model drops; a banner placed so it strands a doc block; and a member emitted twice.

  • 45.3 The parser's fixtures (2026-08-04) — test/modules/docs-generate.mjs, 11 specs on the precedent that a tool's parser gets a fixture rather than trust. They pin paragraph unwrapping (doc comments are hard-wrapped; a reader of the reference must not see the column limit), the em-dash @param split (an argument description routinely contains dashes and colons of its own), tag continuation lines, and the banner normalization. Written in the shape the sources actually use — round 36.1's fixtures were one-liners the scanner never matched, and two specs passed with the behaviour under test deliberately broken. Three controls, each failing its own spec.

What the round found, which is more than it built.

  • src/event.mts was outside the audit's public tier while Event is a named type export and the object handed to every handler a consumer writes. Found because the generator could not place a namespace the tier did not enumerate. This is the fourth instance of one failure: round 32 walked class bodies only, round 36 missed exported functions, round 37.3 missed export default function, and this one missed a file. Widened; coverage stayed 100%/100% because the file was already fully documented in the internal tier.

  • Optional members were invisible to every audit. MEMBER_RE did not allow ? between a member's name and its colon, so target?: EventTarget — and five siblings on Event alone — had never been counted by coverage, @param, @returns or @throws. Fixed; the public tier went 421 → 427, still 100%, so nothing was undocumented behind the gap. It is the same lesson a fifth time, and worth stating in its general form: every widening of this audit so far has found the surface already documented and the count wrong, which is precisely why the count cannot be the thing you trust.

  • The layout-extension contract shipped no types at all. Round 17 made cy.layout({ impl }) the whole extension story and round 34.6 noticed in passing that LayoutContext "is not in the shipped declarations at all — it appears only inside a doc comment", but the consequence was never drawn: CustomLayoutOptions shipped while the two types an external author actually writes against did not, so run( ctx ) typed ctx as any in the one surface the contract exists to make obvious.

    LayoutContext, LayoutImpl and CustomLayout are now exported — a public-surface addition, made deliberately and flagged here rather than buried, on the precedent of round 41.6 exporting the event types for the identical reason. The type-surface audit caught it as an unexpected export, which is that audit doing its job; 42 → 45 type exports, 1148 → 1164 doc blocks.

  • The banners needed completing, which round 26 said would be this round's job ("a generator reads the existing banners for placement, so this round's job is to make the banners complete and consistent"). Animation, LayoutContext, CustomLayout and Event had none or nearly none, so their whole surfaces would have rendered as one undifferentiated section; Core and Collection each had two members above their first banner. Eleven banners added, all comment-only, and one renamed — graph algorithms (slot-native implementations in ./algorithms/) reads as a code comment in a heading, so the implementation note moved to its own line above it.

    The hazard here is this codebase's most repeated defect, and it bit once during the work: a banner placed between a doc block and its member strands the block. Every banner is above its group's doc block, and the gate's precondition is what proves it — one control does exactly this and fails.

Risks tracked: the generator reads the committed dist/cytoscape.d.ts, so the gate also quietly enforces "you regenerated the declaration when you changed the surface" — a feature, but it means a source change that widens the surface fails this gate until build:types is re-run, and the failure names the member rather than the cause. The section titles are the source's banners, which were written as code-navigation aids and read like it in a few places; making them reader-facing is editorial and belongs to round 46, along with everything else about how this model is rendered.

The v4 docs site

  • Prose sections written by hand (the generator covers members, not narrative): introduction, getting started, loading (columnar + the wire format), styling with mappers and the sheet, events + the interaction surface, layouts + the extension contract, animations + transitions, performance. Demos ported to v4.
  • The docmaker template updated for the v4 config; the generated site lands at root documentation/; v3's site (now v3/documentation/) is archived through the existing versioned-docs mechanism (versions.json), so old links keep resolving; the Pages deploy in the release workflows re-points.
  • Install instructions per package manager (added 2026-08-19, with the runtime rounds 98–100): getting started shows the install for npm, pnpm, yarn and bun side by side, plus Deno's npm:cytoscape@^4 specifier and the plain <script> CDN form (the unpkg/jsdelivr fields already point at the min UMD). One snippet per manager, kept adjacent so drift is visible; if round 99.3's JSR memo lands as a publish, the Deno snippet gains the JSR spelling.

The migration guide and CHANGELOG

The guide is the payoff for a discipline this file has kept for forty rounds: the decided-design ledger, the deviations lists and the recorded "why" behind each removal were written down as they were decided, so this round is compilation rather than archaeology.

  • 47.1 MIGRATING.md (2026-08-04) — the v3 → v4 porting reference: the five changes that touch every app (selector strings, classes, the sheet shape, the animation queue, terminally-dead elements), a selector recipe table with a v4 line per v3 form, the style-property diff, the event vocabulary with its silent-failure hazard, a "behaviour to re-check" table for the things that compile and then differ, the constructor options, layouts and extensions, and what is not ported. It ships in the package (44.1's pack spec extended to require it): someone who has just installed v4 and found their selectors throwing should have it locally, not on a website.

  • 47.2 CHANGELOG.md (2026-08-04) — Keep a Changelog shape, starting at the 4.0 line, with Added / Changed / Removed / Not yet implemented / Known deviations. It points at MIGRATING.md for the detail rather than restating it.

  • 47.3 The guide checks itself (2026-08-04) — test/modules/migration-guide.mjs, 8 specs. This is documentation that makes claims about runtime behaviour, in prose, in a file no other test opens; round 31.1 is the cautionary case in the mirror image (the markdown was right and the runtime message was wrong). So every property named in the table's left column must actually be rejected, every replacement offered must actually compile, the counts must add up, and the defaults the guide tells readers to re-check must still be what it says.

    Controls: naming a property v4 still accepts fails 1, a stale plural replacement fails 1, breaking the arithmetic fails 2, and renaming the section fails the whole file loudly.

The property table was measured, not transcribed, and that is the round's method note. The obvious way to write it is to read the ledger and list what one remembers being dropped; instead both libraries were asked. v3's registry was enumerated from a live v3 instance (291 property names, properties plus aliases) and each was offered to v4's sheet in all four groups, classifying on the property-name rejection message specifically — which matters, because v4 says "unsupported" for an invalid keyword too, and a first pass that matched the word alone reported shape, text-justification and source-arrow-fill as dropped.

Reading: 153 accepted (7 only in the core group), 138 rejected, of which 96 are the numbered pie-N-*/stripe-N-* families that became the round-23 chart family, leaving 42 real entries — the table.

Four of those 42 a from-memory guide would likely have missed, and all four are the kind that cost an afternoon:

  • The singular list-property spellings are gone. v3's control-point-distance, segment-distance, segment-weight and segment-radius are aliases of its plural forms; v4 has only the plurals (segment-radii for the last), and no alias.
  • display is rejected, not renamed: the structural tier is show()/hide() and the paint tier is the visibility property, so there is no property called display to port to.
  • position was a v3 style property and is not one in v4.
  • mid-source-arrow-fill/-width and their target twins are rejected where the non-mid versions are accepted — mid arrows are always filled at standard width, a scope note from round 13 C1 that reads as an inconsistency if you meet it without the reason.

(A method note worth keeping for anyone probing v3 from a script: a headless v3 instance leaves live timers behind — round 14.12 recorded this for the compound benchmark — so a probe that enumerates its registry must process.exit() or it hangs forever, printing nothing. Two runs were lost to that before it was recognised.)

Robustness and soak

The tier of testing a release needs and feature rounds never owed. It ran as npm run test:soak — its own script because the leak specs need --expose-gc, and a leak spec that cannot force a collection is a flake generator — and joins the npm test chain. 24 specs across four files, and they found four defects, which is the whole argument for the tier: every one of them was reachable from a public entry point and none was reachable from any test the suite already had.

  • 48.1 Lifecycle leaks (test/soak/lifecycle.mjs, 7 specs) — and the method note is the finding. The obvious gate is a byte bound, and it is the wrong one: 1000 create/destroy cycles of a 400-element graph grow heapUsed by a steady ~2.2 KB per cycle, linear across five 200-cycle blocks — which reads exactly like a leak and is not one. Holding a WeakRef to each destroyed core shows every one collected; what grows is V8's own bookkeeping. So the gate is reachability, which is what the contract actually says, and the byte bound survives only as a wide backstop.

    Also pinned: destroy removes every listener, on/off is symmetric over 2000 handlers, destroy is idempotent, and an app that holds a collection past teardown pins that instance and no other. The first spec is the control — held instances must stay reachable and unheld ones must not — without which every other spec here passes by doing nothing.

  • 48.2 The churn profile, promoted to a gate (test/soak/churn.mjs, 5 specs) — round 11 identified sustained remove-and-re-add at stable size as "the most motivated real-world case, and invisible to a dead-slot-ratio meter", fixed it, and proved it by measuring once. A number in a commit message does not stop a regression. 4000 nodes, 40 rounds of a 400-element band, fresh ids and fresh per-element strings every round — re-adding the same ids would refill the same blob bytes and pass with every reclaim removed. Measured flat: the id blob holds ~42 KiB at round 0 and at round 40, capacity never moves off 64 KiB, highWater never moves off 4400.

  • 48.3 Wire-format fuzzing (test/soak/wire-fuzz.mjs, 5 specs) — seeded LCG mutations in three regions, requiring every outcome to be either a thrown Error or a graph that loads and answers. It found three defects on its first run, all the same shape — a count or index read out of the payload, unvalidated, driving allocation:

    • a dictionary index in a data column (zero-copy, so nothing had looked at it) became 2,566,914,049 against a 3-entry dictionary; ingestColumn's refs[ at - 1 ]++ then inflated a plain array to that length and reduced over it. cytoscape( { elements: buffer } ) went from 0.6 ms to never returning, with no error anywhere.

    • a corrupt flags word made the reader consume position bytes as the packed-id offsets, so the declared total id length became a float bit pattern and setBulk grew its blob to it: 25.9 s before reaching the duplicate-id error it should have raised at once.

    • a corrupt data-block key count did the same one level up. Fixed at the cheapest honest place in each case, which is the part worth keeping: the dictionary guard went into DataStore.ingestColumn because both ingest branches already walk every index, so fused it costs nothing — where the same validation in deserializeElements measured on that function (0.106 → 0.46 ms per 200k indices), against a reader whose headline property is being O(1) per column. The id-blob guard is O(1) (a declared total cannot exceed the blob carrying it) and the count guard is O(1) per count. Suite went 31.5 s → 247 ms.

      Each guard is also pinned deterministically in test/wire.mjs, because the throw-coverage gate measures test/*.mjs only and a guard reachable solely from a fuzzer is one the gate cannot see. Two of the four original failures were the spec's fault, not the library's, and both are recorded in it: a ratio threshold written from the header's intuition failed the tail region for behaving correctly (17/250 loading is right — the data blocks are self-describing, so corruption there is mostly caught), and a for...of over a Collection, which is not iterable.

  • 48.4 Multi-instance isolation (test/soak/isolation.mjs, 7 specs) — id space, writes, style, selection, events, destroy order, compaction, and forty instances at once. It found the round's fourth defect, and the worst-behaved one: a ref is { group, slot, gen } and identity keys on those three packed into an integer — all per instance — so the first node of one graph and the first node of another pack identically.

    Every one of the twelve methods round 29.3 guarded then answered as though they were one element: same() was true, contains() true, indexOf() 0, intersection() returned everything, difference() returned nothing, and union() silently dropped the other graph's elements entirely — two graphs of two nodes united to two, reading back the first graph's data twice.

    Fixed in assertCollection, the guard round 29.3 added to those exact twelve methods for the exact same reason ("they crashed on other._refs — or, in same()'s case, quietly returned false, which reads as working code"). This is that sentence again with a different wrong answer, so it is the same fix: one core-identity comparison per call, on a path that already validates its argument. A behaviour change to public API, recorded as such — these calls returned wrong answers and now throw — and carried in CHANGELOG.md.

  • 48.5 Device loss under load (2026-08-04, the renderer project, 3 specs) — round 10's spec loses the device on an idle instance, which is the easy case: nothing owns a column, nothing is mid-readback, and the rebuild has only the model to replay.

    These lose it while something is in flight, which is where a lease that is never released or a promise that is never settled shows up as a hang rather than an error: mid-animation (a GPU-leased position tween — the promise must settle, the lease must release, and the column must be CPU-owned and writable afterwards), mid-export (the one readback in the architecture, encoded in the frame loop and mapped after — the promise must settle either way), and mid-force-run (the stronger lease: the sim owns the position column for its whole run, and every position must come back finite and writable).

    The control is the point here, and it caught a weak spec: with _debugLoseDevice neutered, the export spec still passed, because it accepts a resolved or a rejected export and with no loss the export simply resolves. It now asserts the loss happened as well as the settling — round 27.7's lesson, in a third costume. With the hook no-opped and the bundle rebuilt, all three fail; restored, all three pass.

  • 48.6 The documented limit edges (2026-08-08, the renderer project, 3 specs) — the tail the record above had left "still to do, and deliberately not claimed": the 256-layer image tier cap, a full glyph atlas, and the export texture cap. Each got the fixture big enough to reach its limit, and each spec pins the contract at the edge rather than the limit alone: the resource just inside still works, the first thing past it degrades the documented way (warn-once, render without the resource — never a crash), and the instance carries on.

    • The image tier cap is two-sided. Phase 1 loads exactly 256 unique data-URI images into one tier and asserts no warning — the 256th image must fit, so a cap firing a layer early fails the spec — then a pixel proves an under-cap image actually renders (the probe node draws its blue image, not its red background). Phase 2 adds three more: exactly one warning (warn-once, not one per overflow), the overflow node draws its background colour where its image would have been, and the under-cap images are undisturbed.
    • A full glyph atlas separates "full" from "broken". ~1500 distinct characters (ASCII through Cyrillic first — ranges a Linux CI font stack has real ink for — with CJK filler, which consumes cells even as .notdef boxes because the cache keys on the character) overfill the 1024² shelf packing. After the warn: a label of novel characters lays out empty and does not re-warn, and a label of cached characters still renders in full (stats().glyphs grows by exactly its glyph count) — which is the behaviour the warning's own text promises.
    • The export cap is exact, and the error's advice works. png( { full: true, maxWidth: limit } ) succeeds with output width exactly device.limits.maxTextureDimension2D; limit + 1 rejects naming the dimensions and the limit. And because round 31 found an error message advising a form the library rejects, the spec follows this message's advice on the same instance: after the over-limit scale rejection, the maxWidth form it recommends must actually resolve.

    Five controls, every one failing where it should: the cap at 255 fails phase 1's no-warn assertion; the cap at 512 fails phase 2's warn assertion (and trips the device-validation afterEach, since layers then write past the texture); the atlas going full silently fails the warn poll; the export guard at >= fails the at-limit export; the guard deleted fails the past-limit message match (the device rejects the texture instead, which is exactly the raw failure the guard exists to pre-empt).

Cross-platform validation

  • The matrix, run and recorded: macOS/Metal (the goldens' cross-platform claim re-verified), Windows/D3D12, WebKit and Firefox WebGPU status with a soft-skip audit (a skip is recorded, never silent), real-device touch (Android Chrome — the round-20 gestures on actual fingers). Per-platform goldens remain the reserve escape hatch if CI disagrees.
  • Standing rule applied: no "blocked, no adapter here" conclusion without probing from a served page — the mistake this file has corrected twice.

Release engineering

  • The release workflows adapted for the 4.x line: version scripts (the prerelease spelling settled here), pre_release_test.sh updated for the v4 artifacts + v4 docs deploy, npm publish (+ provenance) verified against round 44's pack spec, the blog / announcement draft.
  • Cut 4.0.0-alpha.1 — the first published v4.

The release bake → 4.0.0

  • The alpha/beta cycle: external-consumer smoke (a framework-wrapped app, an external layout through the round-17 contract, a real graph app ported using round 47's guide — the guide is tested by the port), an issue-triage window, the final benchmark publication, and the final docs/ledger sweep.
  • Then 4.0.0.

What the debug harness's first real user found

The maintainer opened the page round 43 rebuilt and reported three things. All three were real, none was reachable from any suite, and the last of them turned up a library defect underneath it. Recorded here rather than as a new round because it is round 43's own subject; the risk note above — "a property that changes meaning rather than disappearing will not be caught by anything but opening the page" — is exactly what happened, one day later.

Process note, on the record rather than as silent drift: the four items below landed as four isolated commits (code plus its specs), with one closing docs commit carrying this record, src/README.md and AGENTS.md, instead of a docs slice per commit. The standing rule is docs-in-commit; the exception is taken here because the four share a single narrative — the third item is what found the fourth — and splitting it four ways would have made each piece read as less than it is. The fifth design sitting's PLAN-only edit is the precedent.

  • 43.10 LiveReload had never connected. Reported as livereload-setup.js:13 GET http://127.0.0.1:35729/livereload.js net::ERR_CONNECTION_REFUSED. Measured rather than guessed at: livereload's CLI defaults --bind to localhost, Node resolves that to ::1 on this box, and ss confirms it listens on [::1]:35729 and nothing else — while http-server -o opens the page at 127.0.0.1:3333 (its own rule for a 0.0.0.0 bind), so location.hostname is a literal IPv4 address and no DNS fallback can save it. The two halves have never met, on either project.

    watch:sync now passes -b 0.0.0.0, which is what http-server already does and what the client's location.hostname construction assumes. The client also gained an onerror that names the command to start, because the only symptom until now was a browser console line that does not say what is missing — the round-43 rule (a fetch with no .catch renders nothing and says nothing) applied to a <script>. Verified end to end in a real browser: window.LiveReload is defined with the flag and undefined without it, and the control reproduces the maintainer's exact error text.

    v3's watch:sync has the identical defect and is fixed with it — the same one word, and v3's page is still the thing you compare against (this pass used it).

  • 43.11 Box selection's "[Violation] Forced reflow ... took 40ms" was the event log, not the library. debug/events.js appended a row and then read el.scrollHeight to keep the view at the bottom; a DOM write followed by a layout read is a forced layout, once per event. Box selection emits box + boxselect + select per element, so the numbers are large: measured on em-web, a box over the whole graph selects 7468 elements and the single pointerup task ran 22,406 forced layouts totalling 5,659 ms inside a 6,055 ms handler. The same gesture with this section's selection filter unchecked: 40 ms, one layout read.

    So the library's 22k emits and the selection itself are the 40 ms; everything else was the log. Rows are now buffered and written once per animation frame, and the buffer drops all but the last MAX_ROWS before any DOM node is built for them. After: 41 ms and one read — indistinguishable from not logging at all, and the log still shows its 400 rows in order. Pinned by a Node spec with a DOM stub, because the property is a ratio (reads per frame, not per event) and a ratio does not need a browser: 5000 events must force one layout, and 10 events over 7 frames must force seven — the second half being the control for the first. Reverting append to write-then-read fails it.

  • 43.12 The compound fixture was not the verbatim port its record claimed. Round 43.4 says it "ports v3's hand-built graph (v3/debug/compound.js) verbatim"; comparing the two files line by line, the node list had been sorted (n1, n2, n3, … where v3 interleaves n8, n9, n4, n5, n1, …), four of the eleven edges differed, the three shape data values were dropped, and v3's cy.layout({ name: 'grid', cols: 3 }) was not carried over at all.

    Both omissions matter for the same reason and neither is cosmetic: grid places leaves in declaration order and parents derive their boxes from where their children land, so the node order and the column count together decide whether each parent's children come out adjacent. v3's order over three columns puts n8, n9, n5 on one row and long-name-6, n7, n3 on the next, so the four parent boxes are disjoint; the sorted order over grid's aspect-derived two columns interleaves the families, and n1's auto-box ends up containing n2's and non-auto's. That is the graph the maintainer could not read.

    Now a real verbatim port (order, edges and shape data), with a layout field on the networks.js entry carrying v3's cols: 3init.js reads def.layout for any network that ships no positions. Two things went with it: the sheet maps the fixture's shape data through a case mapper (v3 carries the data and its page never reads it, so all three draw as discs there), and the parents block says shape: 'rectangle' — the parents group overlays the nodes group (14.6), so the new nodes-group mapper reached parents, which carry no shape data, and rounded every parent box.

    Pinned by the readable property rather than the transcription: two parents that are not ancestor and descendant must not overlap. The first version of that spec did not discriminate, and the control is what said so — dropping cols: 3 left it green, because grid takes its column count from the container's aspect ratio and a headless instance is 800 × 600, which picks 3 anyway. The spec now constructs at the debug page's real 930 × 900, where the default is 2; both causes fail it independently.

  • 43.13 The fit() over-estimate underneath it — the library defect, and the reason the graph still drew at a third of its size after the layout was right. cy.fit() with no argument reads GraphStore.boundingBox(), the conservative columnar scan. On the fixed fixture that scan read 1718 × 1572 where the exact cy.elements().boundingBox() is 802 × 637 — and removing the three compound-loop edges made the two identical, which localizes it exactly. The cause: box-bounded edges (FLAG_CURVED_BOX) add curveBoxMargin() and then, for everything except taxi, the chord length.

    The chord is there for weight-extrapolated blob routes, where a control-point-weight outside [0, 1] genuinely puts a control a chord past an endpoint; round 14.10 added CURVE_CMPD to the same flag and it inherited a term that describes a different geometry. A compound loop's controls hang off the union of the two node boxes (v3's findCompoundLoopPoints), at most half the excursion bound past its top-left corner — and the scan visits both endpoints, so whichever node owns that corner covers it. Dropped for CURVE_CMPD in GraphStore.boundingBox and in Collection.boundingBoxAt, which carries the same formula for animated-layout fit targets.

    Soundness was measured before the change and pinned after it. A sweep of 512 compound-loop edges over 60 randomly-shaped compound graphs (varying node sizes, padding, step size and nesting) confirmed the proposed bound contains every exact edge bb, and halves the box area (20.0× exact against 36.5×). Its own controls behave: margin alone violates by 338 px, and p2/2 + margin is contained by only 4.4 px — which is the analysis showing through, since the true requirement is the endpoint's own half plus half the excursion, and keeping the full p2 is what preserves round 14.10's deliberate 2× staleness cushion.

    Two specs in test/compound-loop-edges.mjs: the box is the endpoint AABB grown by header + node-half and no more, and the conservative box still contains the exact one across three arrangements. Controls run: restoring the chord fails the first, and zeroing the header deviation fails both. The cull kernel keeps its chord term deliberately (render/cull.mts): over-inclusion there costs efficiency, never correctness, and changing WGSL would put goldens and parity scenes in scope for a pure efficiency gain. Effect on the page: fit zoom 0.506 → 0.607 on the compound fixture.

    Logged, not fixed: the residual is still ~1.8× the exact box, and it is the formulation rather than the constant. The scan grows a disc of p2 + nodeHalfMax around each endpoint centre, where a compound loop's geometry is directional — its controls only ever go up and left of the union of the two node boxes — so a box-based, one-directional term would be far tighter, and nodeHalfMax is a global maximum that one big parent inflates for every box-bounded edge in the graph. That is a bounds round with its own goldens and benchmarks, not a review-pass edit.

Verification (2026-08-05): typecheck, lint, 2013 Node tests (two new) and 126 module tests (three new — every one of the five run against the mutation it exists to catch), 24 soak tests, the throw gate at 182 run / 10 browser-only / 5 unreachable / 0 Node-reachable dead over 197 sites, JSDoc 100%/100% with @throws 18/18, @param 232/232 and @returns 279/279, test:types:all clean at 45 type exports / 3 statics / 1164 doc blocks (the declaration is byte-unchanged — the source edits are comments and one condition), and 179 browser specs (104 renderer + 75 visual) against a hand-rebuilt bundle with goldens byte-stable and every parity scene at its recorded value, parity-compounds at 2.092% and parity-compound-loops unmoved.

The nine networks were driven in a real browser again, and the compound fixture screenshotted against v3's page.

Risks tracked: 43.13 changes a number a consumer can see — a no-argument cy.fit() on a compound graph with related edges now frames tighter — which is the intended direction but is a behaviour change, logged as open call 16. The event-log batching means a row is now written a frame after its event, so reading the log while stepping a debugger shows one frame less than the model knows; that is the trade for the gesture being usable at all. And the compound fixture is now pinned by a spec that constructs at the page's dimensions, so a change to the page's layout column widths would make the spec and the page disagree without either being wrong.

The status site

Inserted at the maintainer's request, ahead of round 46 and outside the release sequence. The ask: a deployable preview of the branch's current state — the debug page, the benchmark report, the markdown documents — compiled into a gitignored directory and served from Cloudflare Pages, with the benchmark report carrying real provenance about the machine that produced it.

The premise is worth stating because it explains the shape: v4 has been built over fifty rounds with no way to look at it. Every artifact that says what v4 is needs a local checkout, a build and often a GPU, and the three documents that record the design are markdown in a branch nobody has checked out.

The four calls taken with the maintainer

  1. Cloudflare builds from git on every push, rather than a local wrangler upload. Everything the site needs must therefore be in the repo or buildable there — which is what forces item 3.
  2. Contents: the harness, the benchmarks and the documents, plus the golden gallery and an API-reference preview. Audit reports and a test summary were offered and declined.
  3. Benchmarks keep a history, published deliberately into a tracked benchmark/published/. Nothing measures on the builder: no GPU, and the quick profile alone is seven minutes.
  4. The oversized fixtures load from their existing NDEx/R2 source rather than being carried or dropped — superseded the same day by 46.5.7 below, which encodes every fixture into v4's binary wire format instead and so needs no off-site source at all.

What the round measured before designing

Cloudflare Pages caps a file at 25 MiB, and two fixtures are over it. The first plan proposed gzipping them. Measuring first killed that: the v3 fixtures are pretty-printed, the page only ever calls res.json(), and re-serializing gives

fixture on disk minified
network-ndex-large.json 31.6 MiB 20.5 MiB
network-em-desktop.json 23.4 MiB 19.3 MiB — it was at 93.6% of the cap
network-ndex-x-large.json 34.1 MiB 34.1 MiB, already compact

So minifying alone takes one of the two under the cap and gives em-desktop headroom nobody had noticed it needed. With call 4 both NDEx fixtures go remote anyway, but the measurement stayed: it is why the three EnrichmentMap/white-matter fixtures ship at 27.4 MiB instead of 37.

Landed

  • 46.5.1 The lockfile — the root package-lock.json still described the pre-split v3 package (3.35.0-unstable, with handlebars, gh-pages, lodash, heap, gl-matrix). Refreshed: 484 deletions, and the only two added lines are the corrected version. marked and highlight.js — installed by accident through that stale lock — are now declared at v3's exact specifiers. What it exposed: the root suite genuinely needs cd v3 && npm install, because test/modules/benchmark-report.mjs reaches v3/src/test.mjs and so heap. That had been satisfied by root hoisting. AGENTS.md now says so.

  • 46.5.2 scripts/machine-info.mjs (29 specs) — CPU with the physical/logical split and both clocks, RAM, OS, and a GPU inventory with VRAM. Three things a first attempt gets wrong and this does not: physical cores are cpu cores × sockets and not the record count (8 vs 16 here); gpus is a list, because this box has two and a single-GPU return type looks right on every single-GPU machine; and VRAM joins to a card by PCI slot, which lspci and PCI_SLOT_NAME spell differently. Deliberately absent: a primary flag — only WebGPU can say which adapter rendered.

  • 46.5.3 Provenance into the reportbenchmark/run-meta.mjs, because the meta block was being built twice and this is exactly the round that would have updated one of them. The merge exposed that render-bench.mjs captured the WebGPU adapter and threw it away at the --json boundary, so a --renderer report could not say which GPU it measured. meta.dirty is new and renders in the failure colour: a measurement from a dirty tree is not attributable to the sha it prints. machineBlock() returns '' when meta.machine is absent, pinned by a spec, because the site re-renders every published run and half of them predate this round.

  • 46.5.4 benchmark/published/ (20 specs) — the tracked archive and npm run benchmark:publish. Runs group by machine fingerprint and are never plotted across machines; --prune n is explicit and prints what it removes, because silently capping an archive is the invisible truncation this file's benchmark rules exist to prevent. Publishing refuses a dirty tree without --allow-dirty.

  • [~] 46.5.5 The harness's remote fixturesbuilt, then removed the same day by 46.5.7 below. A network could declare a remoteUrl which debug/init.js preferred under window.DEBUG_FIXTURE_SOURCE = 'remote', so the two oversized NDEx fixtures loaded from the bucket they came from. It worked, and the live failure it produced was legible (it names CORS, which is what a browser reports as an opaque TypeError) — but the binary encoding put every fixture under the cap, and a remote mechanism nothing uses is dead config pointing at a key that does not exist. Removed with its flag and its specs.

    One thing from it is worth keeping in mind if a fixture ever does outgrow the cap: the bucket's network-ndex-x-large.json is the 250 MB original this fixture was slimmed from — same filename, different file — so a naive mirror URL would hand a browser a quarter-gigabyte.

  • 46.5.6 The site (52 specs) — npm run status, nine generated pages plus the harness mirror. Two structural decisions carry it: plan then execute, so the specs check the intended output without copying 30 MiB; and the mirror invariant — every mirrored asset sits at its repo path inside the site, so ../build/cytoscape.umd.js resolves by construction and no source file is edited. Exactly two edits are made to the copied harness page and a diff spec closes that list.

What building it found

  • marked does not escape a code span's token.text. Overriding the codespan renderer takes its escaping with it, and PLAN.md contains a code span holding <script> at line 11222 — so it opened a real script element and every page after that point stopped working. Found by driving a browser, not by reading; the smoke test written minutes earlier had no < in a code span and passed. This is the round's clearest vindication of the standing "something has to open the page" rule.
  • @throws and @see are arrays in the docs model while @returns is a string. The API page failed to generate entirely (e.replace is not a function).
  • A bare .gitignore pattern matches at every depth. status also matched scripts/status/ — the ten modules that are the build — so git add -A staged none of them. Caught by reading git status --short before committing, which is the only reason it did not ship a commit that could not run.
  • Two controls failed to fail, and both were findings. One showed an existsSync guard was dead code beneath its own catch. The other showed a "nothing exceeds the cap" loop was not discriminating — with both oversized fixtures remote, nothing planned is near the cap — so a spec that measures the minified size on disk was written in its place and the loop kept as a labelled forward guard.
  • The documented-path audit is now continuous. A rooted path in a code span is checked against the tree, linked to its blob when it resolves and marked when it does not. Eight hits remain, all legitimate: historical references in this file, and spellings AGENTS.md quotes as examples of the round-42 failure.

46.5.7 The fixtures went binary, and the prerequisites went away

The round shipped first with the two oversized NDEx fixtures pointed at an R2 bucket, which left the maintainer two manual steps: enable CORS, and upload a slim copy under a new key. The maintainer then asked the obvious question — what about v4's own binary wire format? — and the measurement answered it:

fixture on disk minified binary
em-web 6.1 3.4 1.2
em-desktop 23.4 19.3 15.3
white-matter 7.2 4.7 2.1
ndex-large 31.6 20.5 9.4
ndex-x-large 34.1 34.1 9.5
total 102.5 82.1 37.5

Every fixture lands under the 25 MiB cap, so both prerequisites disappeared — no bucket, no CORS rule, no off-site dependency, and all nine networks live in the deploy. remoteUrl and its DEBUG_FIXTURE_SOURCE flag were removed rather than left as dead config pointing at a key that does not exist.

The number that would have been guessed wrong. Gzipped, binary and minified JSON are within 1% of each other (7.6 vs 7.7 MiB), and on white-matter binary is worse. Cloudflare compresses on the wire regardless, so this is a file-at-rest win — which is exactly what the cap measures — plus a parse win, and not a transfer win. "Binary is smaller" is true of the metric that governs the cap and false of the one that governs load time.

Three implementation calls, each recorded in scripts/status/wire-fixtures.mjs:

  • Encode through the built CJS bundle, not src/. The page decodes with exactly the code that encoded it, the build needs no tsx, and the import boundary a spec pins stays intact.
  • Encode the output of toGpuElements, the harness's own converter, loaded through node:vm the way its spec loads it. Both page paths then converge on one shape, and there is no second copy of that transform to drift.
  • A manifest, not a rewrite. the generated status-config.js maps network id to encoded fixture; init.js prefers it and falls back to JSON when absent, which is what npm run watch sees. A network that fell back is simply not in the manifest.

What building it found, and it is the round's sharpest lesson. The first fromColumnar read a dictionary column — { dict, indices }, 1-based, 0 meaning absent — as if it were a plain array, so every string column in every fixture came back undefined. The graph still rendered: correct node count, correct edges, correct positions, and no labels or categorical colours at all. Nothing throws on that, and no size or count check would have seen it. The fidelity spec now asserts that each column still carries values after the round trip, and its control — read the dict as an array — fails on all five fixtures.

Verified in a browser against the served site: all five encoded networks load, with exact counts (em-web 569/6899, white-matter 1499/18288, ndex-large 3238/68641, ndex-x-large 19607/464657, em-web-clustered 610/6899 — the derive transform still runs on the binary path), and em-web reports 18,600 glyphs, identical to the JSON run, which is what proves the dictionary columns survived.

What the maintainer still owns

Only one step now: create the Pages project — branch v4, build command npm run status:all (build -> docs:api -> status; plain npm run status assembles against whatever bundle is lying around), output directory status, Node from .nvmrc. The root npm ci is all the builder needs; a spec pins that the status build imports nothing from v3/ or src/.

Risks tracked

  • The site is only as live as its inputs. Documents and the harness are live from git; benchmarks are as fresh as the last benchmark:publish. The landing page therefore prints the run's age, not just its date.
  • The API page treads on round 46. It is labelled a preview and lives at status/api.html, not documentation/. When round 46 ships, this page should become a link to it rather than a second renderer to maintain.
  • CI's job timeout was removed, not raised (maintainer request, same day). GitHub has no "no timeout", so both jobs fall back to the 360-minute default — a runaway backstop rather than a budget. A real cap should return once the suite's wall-clock on a runner is known.

v3's default debug graph

The maintainer's ask: the v4 harness should carry the graph v3's debug page opens on, because it "exercised a lot of style stuff in an obvious way like arrows and edge types". It does — and it is the one fixture here that is about style rather than scale: ten nodes, three self-loops on a, two multi-edge fans, a compound parent with an edge into it, and a different curve-style on almost every edge.

Ported verbatim from v3/debug/init.js — node order included, for the reason round 43.4 recorded about the other v3 port: grid places nodes in declaration order, so reordering the list reshapes the graph and moves every edge. v3's cols: 3 comes with it.

What the port had to change, and why. v3 styles this graph with eighteen id selectors (#ab, node#c, [source = "c"][target = "e"]). v4 has neither selectors nor per-element bypass, so each became a case mapper over data( 'id' ) — which is exactly the shape v4 intends per-element styling to take. Measured against the running library rather than assumed, only three things did not survive:

  • outline-style: solid threw — not yet ported at the time (round 38 has since landed it, and the keyword compiles today). Solid is the default, so the outline rings on b/c/f/i drew either way; the declaration was simply dropped.
  • text-rotation: '45deg' throws; v4 takes radians. Converted explicitly in the sheet, so the 38°/45° labels rotate as v3's do.
  • The list-valued curve parameterscontrol-point-distances/-weights, segment-distances/-weights/-radii — and the arrow widths reject mappers outright and are constants only. v3 gives ab, bc, eh and ed each their own arrays; v4 gives one set per family. It costs almost nothing visually, because a family's parameters are read only by edges of that family (control-point-* reaches only the unbundled bezier, segment-* only the segment kinds) — what is genuinely lost is v3's three different segment arrays.

Everything else ported unchanged, which is a better result than expected: corner-radius, round-hexagon, cut-rectangle, border-position, the whole outline family, text-wrap/text-max-width, text-events, mid arrows, source-arrow-fill: hollow, and all eight curve styles including round-segments, round-taxi with its turn/radius options, haystack and straight-triangle.

Labels live in the fixture's data rather than the sheet, because v4's label accepts only the passthrough mapper — v3's per-id label overrides have nowhere else to go. The zero-width spaces in the three long labels are v3's and are the point of them: they mark where wrapping is allowed.

Verified by opening both pages side by side (v4 on 3333, v3 on 3334, the standing rule that something has to open the page): 10 nodes / 23 edges / 206 glyphs, and at 2.2× zoom the ab unbundled bezier traces the same S-curve through the same control points, with hollow arrows at both ends, the same hexagon-with-outline-ring on b, and the 38° rotated label. The remaining differences are the harness sheet's own palette choices, not fidelity.

WGSL minification

Where it belongs in the sequence: it is numbered after 51 only because it was scoped last. It is behaviour-neutral and touches no API, so it can land any time — but it should land before round 50 cuts 4.0.0-alpha.1, since after that the unminified shader text is in a published artifact. (It landed well before 50, out of the sitting's nominal order — being decision-free and self-contained, it was the cheapest of the three unblocked rounds to close.)

What prompted it

The maintainer asked why v4's bundle is larger than v3's. Measured, not estimated:

raw gzipped
v3 cytoscape.min.js 411.3 KiB 126.1 KiB
v4 cytoscape.min.js 660.1 KiB 178.5 KiB
ratio 1.60× 1.42×

The single biggest contributor is WGSL source: 156.5 KiB across 42 string literals — 23.7% of the minified bundle, and v3 has none of it. It is disproportionate for one reason: a minifier does not touch string contents, so every other part of the codebase roughly halves and this part does not shrink at all. Take the shaders out and v4's JavaScript is 503 KiB against v3's 411 — 1.22×, not 1.60×.

A methodology note, because the first attempt was wrong and would have sent this round after the wrong file: attributing bundle bytes with rolldown's //#region src/… markers gave 111 KiB to image-registry.mts, a 10.6 KiB source, because rolldown merges modules and the marker names only the first. The figures above come from a sourcemap instead. Region markers are not an attribution mechanism.

What minifying is worth, measured

Comment-stripping plus whitespace collapse over all 154.1 KiB of shader text:

stage raw gzipped
as written 154.1 KiB 38.0 KiB
comments stripped 118.7 23.0
+ whitespace collapsed 93.2 20.2

60.9 KiB raw (9.2% of the bundle), 17.8 KiB gzipped (10.0% of the download) — and comments alone are 23% of the WGSL text and account for 15 of those 17.8 KiB.

The gzip figure is the surprise and the reason this is worth doing. The expectation going in was that compression would wash the saving out, since shader text is repetitive; it does not, because comment prose is unique text that gzip cannot dedupe. The gzipped saving is proportionally as large as the raw one.

The obstacle, and how much of it is real

95% of the WGSL is assembled with ${} interpolation — 114 sites — so a build-time static minifier cannot see the final text. The sites are not all the same thing:

KiB share avoidable
static, no interpolation 14.5 9% already static
build-time constants only (${SHAPE_MASK}, ${COMMON}, ${SDF}) 89.4 58% yes, at build time
runtime-generated (ARROW_POLY.cases, edge ? … : '', per-polygon fmtF32( pts[ i ] )) 50.1 33% no

The 58% interpolates constants from src/contract.mts — the co-signed model↔renderer layout — and that interpolation exists for correctness: it keeps one definition of every flag and offset. Hand-inlining the numbers would duplicate the contract, which is the thing that file exists to prevent. Generating the WGSL from contract.mts at build time keeps the guarantee and yields static text, so the ceiling on "make it statically minifiable" is 67%.

The remaining 33% is genuine variant generation — custom polygon shapes compile per-polygon WGSL from user-supplied points. Making it static means shipping every variant (larger) or dropping the feature. It stays dynamic.

Tools surveyed (2026-08-05)

  • miniray — npm, MIT, Go→WASM, v0.3.1 (Dec 2025). The real analogue of glslx: whitespace, identifier renaming, dead-code elimination, validation, reflection, source maps; CLI and JS API; validated against the Dawn Tint suite. 4.35 MiB unpacked, which is fine for a devDependency and rules out any runtime use — it is 27× what it would save.
  • wgsl-plus — npm, MIT, 259 KiB, v1.0.1 (Mar 2025). Preprocessor + linker + minifier + obfuscator. Its C-style #include is a direct answer to the ${COMMON}/${SDF} chunk inclusion, if that route is preferred over generating from contract.mts.
  • wgsl-minifier — Rust crate over naga; not on npm, needs a Rust toolchain. Out for this repo.
  • wgslx-loader — webpack; we use rolldown. Out.
  • shader-minifier (laurentlb) — GLSL/HLSL only, no WGSL.
  • Context: gpuweb#3503, Make WGSL more suitable for minification, is open — the tooling here is young and the language is not yet designed for it.

Proposed passes

  • 52.1 The cheap pass, and probably the whole round (2026-08-08) — landed, and it was the whole round: 52.3 stays unbuilt because the number did not disappoint. Measured on the day's source (which had grown since the plan's table — rounds 53–57 landed in between): cytoscape.min.js 663.3 → 601.1 KiB raw, 182.3 → 163.3 KiB gzipped — 62.2 KiB raw (9.4%) and 19.0 KiB gzipped (10.4% of the download), slightly better than the plan's 60.9/17.8 estimate.

    The shape of the thing: a wgsl template tag (src/render/wgsl.mts, an identity join at runtime) marks every multi-line WGSL literal — 49 across the seven shader-bearing modules — and a rolldown plugin (scripts/wgsl-minify.mjs, wired into all five bundle configs) lexes each tagged literal, strips comments (nested block comments per the WGSL spec), collapses whitespace where tokens cannot fuse, and drops the tag — an untagged template joins identically, so the bundles pay no per-shader call and the tag itself tree-shakes away. The build-time marker is what lets the transform find WGSL without parsing JS semantics or guessing which template literals are shaders (several of those files also hold GPU-label and error-message templates that must not be touched).

    ${…} opacity is the contract the plan named, and it is stricter than "don't parse the interpolation": whitespace adjacent to an interpolation collapses to a single space but is never deleted (the value's edge characters are unknowable, so return ${X} must keep its space), while poly${id}SD stays glued (a space is never invented). Single-line generated fragments (the per-polygon case lines) stay untagged — no comments, nothing to collapse. Two authoring rules became build errors with specs: an interpolation inside a WGSL comment (stripping the comment would strand the interpolated text as live shader code — two such doc comments existed and were reworded to prose), and an unterminated block comment.

    The spec suite (test/modules/wgsl-minify.mjs, 17 specs) carries the plan's control: the fixture puts ${ n - 1 } and a 'a//b' string interpolation next to punctuation, and the non-opaque transform run against it mangles both — the spaced interpolation is rewritten and the // inside the string is eaten as a comment — so the fixture demonstrably discriminates. Beyond the unit specs, a token-stream audit runs every one of the 49 real literals through an independent tokenizer (regex-based, sharing no code with the transform): original and minified static chunks must produce the identical WGSL token sequence, and the literal count is pinned at ≥45 so a scanner that silently stops matching fails rather than auditing nothing. A bundle spec then asserts the built outputs carry the shaders (fn edgeLod) without their comments and without the tag.

    Two things went wrong on the way and are worth keeping. The tag detector first required the preceding significant character to be non-word, which silently skipped the one tag written as return wgsl\…`— caught by the bundle spec finding a surviving tag, and the fix is that identifiers scan atomically so a preceding word char means a *separate* token, a legitimate expression position. And the round-37.1 throw gate fired on the tagging commit itself: the added import line movedgpu-tween.mts`'s exempted throw from line 470 to 471, and the build failed naming it — the fourth time that gate has caught a silent allowlist re-point, this time from a one-line import.

  • 52.2 Prove it did not change what runs (2026-08-08) — the full Playwright suite against the transformed dev UMD build (the plugin is deliberately unconditional across dev and production, so the pixel gate exercises exactly the transform that ships): 220 passed, 103 soft-skipped (the renderer-webkit project — WebKit has no WebGPU here), with all 45 goldens at zero differing pixels under 57.1e's exact bound and every live parity scene at its recorded value (parity-selection 0 px, the close-up tiers at 0.000–0.020%). The Node tier is green end to end (typecheck, 2078 + 280 + 24 specs, throw gate at 182 run / 10 browser-only / 5 unreachable / 0 dead, lint, format). benchmark:renderer ran on this machine's real adapter (AMD gcn-4 — the RX 580 of the round-18.5 hardware pass), 17.9 minutes over all ten scenes: every pipeline compiled from minified text, v4 held its vsync-bound 16.7 ms frames everywhere, and the 160 GPU rows paired against the 2026-08-06 pre-change run have a geometric-mean ratio of 1.018 — run-to-run noise, with the largest movers the sub-millisecond compaction device rows that jitter in both directions between any two runs.

  • 52.3 Only if 52.1's number disappoints — generate from contract.mts. Resolve the build-time constants at build time so 67% of the text becomes static, then run miniray over that portion for identifier renaming. Sequencing matters: this stacks on a comment-strip that has already taken the easy 15 KiB, reaches only two thirds of the text, and buys renaming of identifiers that gzip already compresses well. The estimate is single-digit KiB gzipped for a substantial amount of build machinery, and that estimate should be measured on one shader before the machinery is built.

    Not built (2026-08-08), as the sitting expected. 52.1 landed 19.0 KiB gzipped — above its own estimate — so the trigger ("if the number disappoints") never fired. Ledger item 17 (miniray) stays where it is: reached only if this pass is ever wanted, measure-first.

Calls this round needs

  1. Is 10% of the download worth a build step at all? 52.1 is ~30 lines and no dependency, so the bar is low, but it is still a transform between the source and what ships. Call taken (2026-08-06, sixth sitting): yes — 52.1 builds, with its fixture (interpolation sites next to punctuation) and its control (a transform that does not treat ${} as opaque must fail), gated by 52.2's pixel diff, and it lands before round 50 cuts the alpha. The round is now scheduled, not merely scoped.
  2. Does miniray become a devDependency? (52.3 only.) It is 4.35 MiB of Go-compiled WASM in the toolchain for a single-digit-KiB gain, and it should not be added on the strength of the tool's quality alone — measure first. Held open as ledger item 17 (2026-08-06): reached only if 52.1's number disappoints, and only after the single-shader estimate is measured.

Risks tracked

  • A transform between source and shipped shader is a new class of defect. Everything else in this repo ships the source it was tested against. After this round the browser runs text no human wrote, so 52.2's pixel gate is not optional — it is the only thing standing between a whitespace bug and a silently wrong render.
  • The saving is not where intuition puts it. Comments, not whitespace, are the win (15 KiB of 17.8 gzipped). A future round tempted to skip comment-stripping "because gzip handles repetition" would be reasoning from the wrong model — and losing almost all of the benefit.

CI, green then fast

CI had been red on every push since round 42 on one job and since round 46.5 on the other, and nobody had read a log: the Actions log API answers 403 without repository admin, so the failures were diagnosed by reproducing both jobs in a clean git worktree and confirmed against the real logs afterwards. Both reproductions matched the runner exactly.

The two failures

ci-v3 died in 0.4 s, before a single test. v3/tsconfig.json carried "types": ["@webgpu/types"] — v4 tsconfig that came along in the round-42 split — while @webgpu/types is a devDependency of the root package. Every developer machine and the ci job resolve it by walking up into the root node_modules; the ci-v3 job installs only in v3/, so there is nothing to walk up into. v3 references no WebGPU type at all. "types": [] keeps the "no ambient @types" restriction the config always had.

ci died 40 s in, at test:modules, on the three status-site.mjs specs that plan the mirror of the debug harness — which loads ../build/cytoscape.umd.js, and build/ is gitignored. Playwright's webServer does build one, but that runs later in the run-s chain than test:modules. On any machine that has ever built, the specs pass. A npm run build step before the suite fixes it and costs 0.7 s under rolldown.

Both are the same shape, and it is worth naming: a fresh checkout is a configuration nothing here tests. Every developer machine carries state — a hoisted dependency, a stale build/ — that CI does not.

Then: why the suite was slow

With the failures fixed, the Playwright projects dominate, and the timeouts that had been blamed for it were a symptom. Instrumenting every helper in renderer.spec.js (CI flags, 4 workers, the runner's parallelism):

helper calls total
waitFrames 156 570 s
makeReadyCy 98 19 s
pixelAt 143 14 s
everything else 5 s

waitFrames waits for three animation frames and averaged 3.65 s. The in-page gaps read [7 ms, 5934 ms, 16 ms] — one stall, then 60 fps.

What it was not. Not contention: identical at --workers=1. Not idle rAF throttling: forcing damage every tick changed nothing, and --disable-gpu-vsync --disable-frame-rate-limit fixed idle rAF (171 → 17 ms) only to move the stall onto page.screenshot() (4.6 s) and cost 13 failures. Not a timeout: it burns 105% of one core for 4.45 s. Not cacheable: a persistent Chrome profile makes no difference. Not the browser's: a bare WebGPU triangle — new device, new canvas, same flags, same page-per-test pattern — costs 20–244 ms to set up and 30–50 ms for its first frames.

It is v4's own shaders, compiled by SwiftShader on the first frame of every instance. Dawn returns from createRenderPipeline in 0 ms and compiles when the pipeline is first used, so building the set at init does not pay for itself — it moves the compile onto the first frame, for every feature the graph does not use. Priced by stubbing each out, on a one-node graph:

first frame
all 12 draw pipelines (as shipped) 4.60 s
node pipeline only 2.65 s
no draw pipelines at all 1.06 s
— of which: node 1.58, curved edge 0.68, curved arrow 0.28, edge label 0.22, image 0.17, chart 0.13, arrow 0.12, ghost 0.20

A real adapter shows the same shape an order of magnitude smaller (0.53 s), so this is first-frame latency for every consumer, not only a CI cost.

createRenderPipelineAsync does not help, which is worth recording so the next round does not try it: awaited, it is ~15% better and scales perfectly linearly (1/4/8 pipelines → 621/2409/4756 ms sync, 613/2013/4053 ms async). Dawn compiles them serially on one thread either way. The lever is not compiling what you do not draw.

What landed

  1. Deferred pipelines (src/render/renderer.mts). Image, chart, overlay, underlay, curved edge, curved arrow and both label pipelines build on the first frame that draws them; NodePipeline's ghost pipeline likewise. Four already sat behind a store count. The curved pair needed a new one — GraphStore.hasCurvedEdges(), monotone, set where FLAG_CURVED is written — because recompiling costs more than holding a pipeline. 4.60 s → 2.72 s on a one-node scene, and unchanged for a graph that uses everything.

  2. A frame driver on the harness page (playwright-page/frame-driver.js). This one fixed correctness, not only speed. The renderer schedules through rAF, and an animation started while the page is idle does not begin for ~1 s on the software adapter: a 1500 ms linear tween had run zero frames 800 ms after animate() returned and had not moved a pixel until ~1.2 s, so two specs sampling mid-flight failed deterministically at --workers=1 (verified against the unmodified tree — they were failing before this round touched anything) and several more were intermittent.

    A 1 px element at z-index: -1, behind the opaque full-viewport container, ticking its opacity every frame, keeps BeginFrames coming. It cannot change a pixel any spec samples, and it does not make Cytoscape redraw.

  3. pixelAt reads a clipped screenshot decoded in Node rather than a full-page one shipped into the page as base64 and decoded through an Image and a 2D canvas: 67 ms + 68 ms → 6 ms, over ~94 calls.

  4. The workflow splits into ci-node and one ci-browser job per Playwright project, each installing only the browser it drives and only visual building v3's baseline. npm test is now run-s test:node test:playwright, so the chain a developer runs and the one CI runs still have a single definition.

Measured, on this machine, CI flags, 4 workers

before after
renderer project 3.1 min, 100 passed / 4 flaky 2.1 min, 104 passed
visual project 2.3 min, 75 passed
both in one run 3.6 min, 2 failed / 1 flaky 3.6 min, 179 passed
node tier (test:node) 64 s

Sharded, the browser tier's wall clock becomes its slowest project rather than their sum.

Risks tracked

  • The deferred pipelines move a failure from init to first draw. A broken pipeline used to throw while mounting; now it throws inside the frame that first needs it. The visual project draws every gated feature, which is what makes the change checkable at all — and it is why the curved gate has its own spec (test/curve-stream-gate.mjs, controlled both ways).
  • hasCurvedEdges() is monotone, so a graph that curves an edge once holds two pipelines for its lifetime. That is the intended trade; a counter that falls back to zero would recompile on the next curve.
  • The frame driver makes the harness page unlike a real page, which is a real cost: a stall a user would see is now invisible to the suite. The stall it hides is documented above and in the file, and it is a property of the software adapter, not of the library — but if v4 ever ships a frame-scheduling change, this is the file to read first.

Round 53.1 — the browser suite made honest (2026-08-06)

The maintainer's read of the first pass: "the tests should not be inherently flakey, so address that", and "run the webkit tests here locally anyway". Both turned out to be the same kind of finding — a check nobody had ever run.

WebKit had never run on this machine, and the job it now has would have been red. Playwright refuses to launch a browser whose host requirements it cannot verify, and it verifies them by mapping shared-library needs onto apt package names — a false negative on any non-Debian distro. Skipping that check (and supplying WebKit's libjpeg.so.8, the jpeg8 ABI Fedora does not build) took the project from "cannot start" to 102 skipped, 2 passed, 1 failed. The failure is real and predates everything this round did: ready rejects when no adapter can be acquired stubs navigator.gpu — which Linux WebKit does not have — so it threw instead of asserting.

It skips now; the sibling that covers that state, hard error when WebGPU is unavailable, is one of the two specs that do run there, and passes.

That is the second time in two rounds that the answer to "is this green?" was "nothing has ever run it".

The flakiness was structural, and its cause is a real product characteristic. Nine specs asserted something about a running tween by sleeping to a fixed offset and reading one pixel. Rewritten to poll for the state they are named for, they are both more honest and faster: 29.5 s -> 12.7 s for the ten animation specs, because a poll returns when the state arrives rather than sleeping a fixed remainder.

The bound on those polls is where the round learned something. A first attempt used tight timeouts, reasoning that a bound below the tween's duration was what proved a sample was mid-flight — and it failed 5 runs in 10. Tracing it with four concurrent browsers: a screenshot taken 1779 ms after animate() returned still showed the node at its start with animated() true. A tween's compute pipelines compile on the first animate() of a page — Dawn defers compilation to first use, the same property behind this round's deferred draw pipelines — and on the software adapter under load that stalls the first animation by up to ~1.8 s. The tween's clock starts after the stall.

So the timeout is not what keeps such a spec honest; the state being unobservable at rest is. For most of the nine the predicate itself carries that (a settled node does not sit 35 px past its target). For the four paint specs the end colour satisfies the predicate, and the assertion after the poll is the discriminator — a stale CPU column, or green leading red on the OKLab path, which yellow fails with them equal. untilMidFlight's doc comment says so, because that pairing is what an editor could break without noticing. Five controls, each breaking one behaviour deliberately, all fail.

One worker per core is the setting that fails. Every project drives a browser rendering WebGPU, and without hardware that is SwiftShader, which is itself multi-threaded. Three retry-free runs of the renderer project at each setting, 16-core box, software adapter:

workers wall result
16 1.5 min 3 failed / 1 failed / clean
8 1.6 min clean / clean / clean
4 2.1 min clean / clean / clean
2 3.5 min 1 failed
1 >10 min

One-per-core is exactly what the runner was using (4 workers, 4 vCPU). Half is as fast and does not fail. Serial — which was the maintainer's first instinct — is 6× the wall clock, and its long tail brings its own timeouts.

Where it ended up

npm test runs clean on this machine end to end, 2m41s, exit 0: 2021 + 248 + 24 Node tests, the throw gate, lint, and all three Playwright projects (181 passed, 102 skipped). Under the CI configuration, three retry-free runs of the renderer project are 104/104, and visual is 75/75.

Risks tracked

  • Generous poll timeouts trade failure latency for reliability. A broken tween now takes 6 s per spec to fail instead of 1.2. That is the right trade while the first-animate() compile stall exists, and it is the number to revisit if that stall is ever fixed.
  • The first-animation stall is not a test problem. A user's first animation on a software adapter is late by up to a second. Warming the tween pipelines at init would move the cost into startup, which is already slow; that is a judgement call, not a bug fix, and it is left open. (Logged as open call 18 at the sixth sitting, 2026-08-06 — revisit with data, alongside a noted future direction: a possible WebGL fallback renderer for platforms without WebGPU.)
  • The libjpeg fix lives in Playwright's browser cache, outside the repo, and playwright install will undo it. AGENTS.md carries the command.

Round 53.2 — what running it actually found (2026-08-06)

Three more defects, all of the same kind as 53's and 53.1's: things that had never been executed in the configuration CI uses. Plus a merge of the test/fix-v4 branch, which had been fixing one of them from the other direction. (Not in backticks: a code span holding only a rooted path is a link candidate to the status build, and a branch name is not a file.)

The merge. That branch makes the npm scripts self-sufficient — test:modules and test:playwright build the bundles they read instead of assuming someone did — and adds a stale-bundle diagnostic to the status wire encoder, a tracked v3/package-lock.json, and the same navigator.gpu skip 53.1 had landed independently. package.json merged cleanly and kept both sides: test is still run-s test:node test:playwright from the CI split, while test:modules is now run-s build test:modules:run. Verified from rm -rf build v3/build: npm test exits 0 in 2m42s, having built v4's five bundles and v3's UMD baseline itself along the way.

The tracked lockfile also let both jobs that install v3 move to npm ci. ci-v3 had been running npm install against caret ranges, so it resolved a different dependency set on every push — which is why an upstream release could turn that job red with no commit touching this repo.

heap, and a spec that needed one integer. Splitting the workflow lost v3's install from the Node job, and test/modules/benchmark-report.mjs — a spec about rendering an HTML report — failed with Cannot find package 'heap'. The first fix was to reinstate the install. That was treating a symptom, and the maintainer's question ("why do we need the heap package in the v4 tests?") was the right one. Tracing it:

benchmark-report.mjs  -> bench-run.mjs   (finishManualRun)
bench-run.mjs         -> graph.mjs       (N)
graph.mjs             -> v3/src/test.mjs (makeV3)  -> heap

N is Number( process.env.BENCH_N ) || 2000. One integer. It lived in graph.mjs, which imports both libraries to build its makeV3 / makeGpu factories — and an ESM import evaluates the whole module, so reading that number loaded all of v3 and all of v4. bench-run.mjs is the only one of graph.mjs's twenty importers that wants a constant rather than a factory. The run size moved to benchmark/bench-size.mjs, which imports nothing; graph.mjs re-exports it, so the other nineteen suites are untouched. test:modules is now 250/250 with no v3 install at all, the CI step came back out, and AGENTS.md's "install both" rule — which named this exact spec as its reason — was corrected to what is now true.

Worth noting how the failure reads on a runner, because it is misleading: the spec file fails to load, so its 21 tests never register. The suite reports 229 rather than 250 and one failure named after the module rather than the cause. It looks like a broken spec and is a missing install.

A console warning nobody had seen, because the renderer project had never run to completion on CI: Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. pngAndSample decodes an export into a 2D canvas and reads one pixel per sample point, which is exactly the shape being named. All four in-page decodes in renderer.spec.js set the attribute now, as the two readback sites in src/ always have.

The two remaining unflagged 2D contexts in src/ are deliberately left alone: they are write-only (putImageData + toBlob; drawImage + createImageBitmap), and forcing a canvas that never reads back into software memory is a pessimization, not a fix.

Is every suite in CI?

Asked, and audited rather than assumed. Every test script is: v4's typecheck / test:js / test:modules / test:soak / test:throws / lint and its three Playwright projects; v3's whole npm test and its six type suites. There are also no spec files that no glob picks up — all 128 test/*.mjs are matched, and the only two excluded are the setup shim and types-surface.mjs, which has its own script.

Three things run only by proxy. Two are deliberate: benchmarks do not run in CI because they are machine-dependent and reach the site only through benchmark:publish from the machine that measured (their tooling is gated by specs), and npm run dist is never executed — test/modules/packaging.mjs proves a release would produce every file by parsing dist:copy and .npmignore, which its own header states.

The third is worth recording as a known gap rather than closing: test/modules/status-site.mjs imports buildPlan and nothing else, so executePlan() — the half that writes the deployable site — has no coverage, and npm run status runs nowhere. The pure/writing split exists so the spec need not copy 30 MiB of fixtures, and the consequence had not been drawn. It is 6.3 s to run; the decision was that CI is for tests, so if the site build breaks it will surface on a deploy.

Standing lesson from 53, 53.1 and 53.2 together

Every defect this round found was in something that had never been executed in the configuration that matters — a fresh checkout, a runner without a hoisted dependency, a browser project nobody could launch locally. None of them were regressions in the library. A green suite says the paths that ran are fine; it says nothing about the ones that never did.

The sixth design sitting — the backlog sweep

Prompted by a full read of this file: the ledger's numbered items were all closed, but decisions had accumulated in round records against the standing rule that every open question surfaces in "Open calls for the maintainer" — round 52's two calls, 53.1's warm-up judgement — and the three logged public-surface changes (items 14–16) had never been ratified. Every call below was taken with the maintainer; the per-item annotations are in the ledger, and this is the sitting's own record.

  1. Round 38's three sub-calls, taken (open call 1 — the round is unblocked):
    • double ports v3's erase. Alpha-0 stripe fragments (fill and border are one draw); double-bordered nodes are excluded from the opaque depth prepass on the gradient-fill precedent. The tier's live parity diff is the gate; if the prepass interaction turns out worse than expected, the fall-back is the inner band with the deviation recorded — but the erase is the intent.
    • border-dash-pattern / border-dash-offset both port — full v3 parity for dashed borders, on the plumbing v4 already has for the edge twins, rather than a hardcoded [4, 2] and a recorded drop.
    • border-cap / border-join drop, recorded as a deviation beside the existing edge-layer butt-cut note, with migration-guide rows.
  2. Items 14–16 reviewed individually and ratified: the layout-contract type exports stay (14), the cross-instance comparison throw stays (15), and the tighter compound fit() stays (16). With 16's ratification the logged bounds round is scheduled as round 54, before round 49 — the residual ~1.8× over-fit is visible in every compound app, and the 43.13 soundness-sweep method is already the round's gate.
  3. Open call 12 gains its direction; the enumeration holds for 41.5's docs-first. The maintainer's rule: gestures get explicit toggles first (the panningEnabled() pattern exists precisely to make gesture control easy and explicit), and no gesture default may be controllable only through preventDefault(). Both mechanisms may coexist, toggle primary. 41.5's docs-first therefore maps each candidate default to its explicit toggle — existing or to be added — before proposing any preventDefault rows; the sitting tabled a four-row candidate table (recorded in item 12) for that stage to react to.
  4. Round 40 proceeds taxonomy-first. Before the sitting proper, an autonomous classification pass sorts all 197 throw sites (the throw-coverage script already enumerates them) into contract errors (always throw) vs recoverable runtime conditions (today's warn tier), producing the candidate list demotion would actually touch. The sitting then reacts to a concrete proposal — cytoscape.warnings( boolean ) as the global toggle plus a per-instance ctor override, with the demotion option (errorPolicy demoting recoverable-tier throws only, never contract-tier) decided on the measured size of that list.
  5. Round 52's call 1 taken: the 52.1 comment-strip build step builds, before round 50 cuts the alpha (17.8 KiB gzipped — 10% of the download — for ~30 lines and no dependency, gated by 52.2's pixel diff). Call 2 (miniray) is held as ledger item 17, measure-first, expected never taken.
  6. 53.1's warm-up judgement joins the ledger as item 18: logged, revisit with data — and the maintainer noted the larger future direction to keep beside it, a possible WebGL fallback renderer for platforms that cannot support WebGPU. Logged as a direction, not scoped.

Sequencing after the sitting: 38 → 40 → 46 (decision-free and unblocked — may be pulled forward) → 52 (before 50) → 54 + round 48's limit edges → 49 → 50 (4.0.0-alpha.1) → 51 (4.0.0).

Amended 2026-08-06, after the sitting: round 55 was inserted ahead of 38 at the maintainer's request — the edge-routing and arrow parity round, prompted by opening debug/?network=v3-default. It is a prerequisite rather than a queue-jump: 38's verification plan is per-tier live parity diffs, and 55 is what makes such a diff able to fail (twelve of the 29 parity scenes had no ink floor, and the curve scenes deliberately drew no arrows). 55 landed its harness and one fix; its fix 3 — the arrow gap port — is unlanded and carries into the next round, with failing tests and verified constants in place.

So the sequencing now reads: 55's remainder → 38 → 40 → 46 → 52 → 54 → 49 → 50 → 51.

Process note, on the fifth sitting's precedent: this sitting lands as a PLAN.md edit plus the derived EXECUTIVE_SUMMARY.md open-questions update (an item leaves that table when it is decided, which several did today); the src/README.md true-up rides round 38's docs-first commit.

The bounds round

Item 16's follow-up, promoted from logged to scheduled at the sixth sitting. The conservative whole-graph fit scan still over-frames the compound fixture at ~1.8× its exact box after 43.13, and the residual is the formulation, not the constant:

  • The scan grows a disc of p2 + nodeHalfMax around each endpoint centre, where a compound loop's geometry is directional — its controls only ever go up and left of the union of the two node boxes — so a box-based, one-directional term is far tighter.
  • nodeHalfMax is a global maximum: one big parent inflates the bound for every box-bounded edge in the graph. The term should be per-edge.

Scope: the two CPU call sites only (GraphStore.boundingBox, Collection.boundingBoxAt) — the cull kernel keeps its conservative terms deliberately, exactly as 43.13 recorded (over-inclusion in a cull costs efficiency, never correctness, and touching WGSL would put goldens and parity scenes in scope for a pure efficiency gain).

Verification is 43.13's own method, promoted to the round's gate: the randomized soundness sweep (hundreds of compound-loop edges over dozens of randomly-shaped compound graphs, every conservative box must contain its exact box), specs pinning the tightened box both directions with controls, a benchmark/spatial.mjs row for the scan cost, and the compound fixture's fit driven in the page — the standing rule that something has to open it.

Landed (2026-08-08)

Both formulation fixes landed as planned, and the sweep earned its promotion on its first run by catching a soundness hole the plan had not predicted.

  • 54.1 The directional compound-loop box. A CURVE_CMPD edge now contributes the union of its two endpoints' outer boxes (node.outerHalf, per-edge) grown by the stored excursion bound p2 up and left only — v3's construction hangs both controls off that union's top-left corner, one up and one left, and the curve lies in the hull of the controls and the boundary points on the node outlines, so right and down the edge contributes nothing beyond the nodes the node pass already scanned. The full stored p2 is kept rather than the tight p2 / 2: it is exactly the derivation-time bound whose 2× cushion the curve-index records as its staleness allowance, and p2 / 2 would be zero-margin at freshness — one f32 rounding from unsound. Both call sites take the same form (Collection.boundingBoxAt at the hypothetical centres, with outerWidth()/outerHeight() per end).
  • 54.2 Per-edge margins for box-bounded routes — and taxi went exact instead. The plan said "make nodeHalfMax per-edge", and for the weight-extrapolated blob kinds that is what landed (the edge's own endpoints' outer halves, plus the chord — sound for weights in [-1, 2], the envelope the old form bounded too). For taxi the sweep proved the plan's formulation unsound on its first run: a downward taxi whose target sits above the source is a forced-direction route that overshoots both endpoints by the turn, which no node-half margin bounds — the old global margin had covered it only when some unrelated big node happened to inflate it. Measured: 14.5 px under-contained on sweep graph 23. The fix is not a cleverer margin (the Z/L fallbacks have their own excursions, and a hand-derived bound over evalTaxi's branches is exactly the kind of second implementation that drifts): the scan uses the memoized exact curve bb (curveBBAt, epoch-invalidated, already computed per curved edge by the box-selection path), so taxi is exact and pays once per geometry change. boundingBoxAt gets the new curveRouteAtPositions — the same route evaluation at hypothetical centres — and hull-bounds the raw route points.
  • 54.3 curveBoxMargin() deleted. Its only callers were the two rewritten sites; the cull kernels never used it (they carry frame.curveSlack, whose global monotone maxima stay deliberately). The shipped declaration regenerated (45 type exports / 3 statics / 1205 doc blocks).

Measured, on the debug harness's compound fixture (930×900, the 43.13 viewport): fit zoom 0.607 → 0.822, conservative box 705×769 against an exact 555×618 — the residual per-axis over-frame is now ~1.25×, which is the kept p2 cushion showing, down from ~1.8×. The page was driven per the standing rule (screenshot checked: the compound-loop arcs sit fully framed with modest up-left headroom, and nothing clips).

Verification. test/modules/bounds-sweep.mjs: 60 seeded randomized compound graphs (sizes 10–250, padding 0–40, nesting, step sizes 5–200, five taxi directions with px and % turns, extrapolated weights in [-0.6, 1.8]) assert conservative ⊇ exact in all four directions; a deterministic taxi spec pins the overshoot case and that a 400 px node 2000 px away moves nothing; the rewritten compound-loop-edges.mjs spec pins the directional box in both directions (x1 = -15 - p2 and x2/y2 at the plain node edges, where the disc used to hang p2 + margin past the centres). Controls, each run and each failing what it should: dropping the p2 extension fails 4 specs, reverting taxi to a per-edge margin fails 2 (the sweep itself and the taxi pin), and collapsing the union corner to the source end alone fails the sweep. Node tier green end to end (2078 + 283 + 24, throw gate 182/10/5/0, lint, format, types), full Playwright green with goldens byte-stable and every parity scene at its recorded value — no golden frames a fit over compound loops or taxi, which is why none moved. benchmark/spatial.mjs gains the round-54 group with a fixture that asserts its own kinds (100 compound-loop + 99 taxi edges over 100 parents): warm scan 86 µs (curve bbs memoized), cold 162 µs with a geometry write per call (N=2000 profile).

Recorded limits. The extrapolated-weight chord bound is sound for weights in [-1, 2] only — outside that envelope the old global form under-contained too, so this is a pre-existing edge, now written down; widening it means scaling the chord term by max(|w|, |1-w|), which no graph has yet asked for. And the sweep's own graphs stay inside that envelope deliberately.

Edge routing and arrow parity, measured

Inserted ahead of round 38 at the maintainer's request, and for a reason that makes it a prerequisite rather than a queue-jump: 38's verification plan is per-tier live parity diffs, and this round is what makes such a diff able to fail.

Where it came from

A maintainer opened debug/?network=v3-default and reported five things as impressions — segment edges looking wrong, the taxi edge "breaking in the middle", arrows "not on par", hollow arrows showing the line underneath, filled arrows leaking a sliver of line past the triangle's point, and semitransparent edges reading as two overlapping shapes rather than one. None of them were visible to any test in this repository, and the reason is structural:

  • The 44 goldens are v4-vs-v4. They answer "did this change?", never "is this right?", and they had happily locked in every one of these defects.
  • The 29 live v3-vs-v4 parity scenes do ask the right question, but at 400x300 with MAX_PARITY_RATIO = 0.02 they carry 2400 px of slack. A missing arrow gap at width: 3 is about 6 px of line per end — 0.005% of the canvas, 400x under the bound.
  • Worse, the curve parity scenes deliberately set arrow-shape: none, justified in a comment as "gap 0 is where v3 and v4 agree". True, and precisely the configuration in which the defect cannot appear.
  • Twelve of the 29 parity scenes had no ink floor at all, so blank-vs-blank would have passed.

Decisions taken with the maintainer (2026-08-06)

  1. Hollow and translucent heads are fixed by trimming the line, not by porting v3's erase. v3 makes them read as one shape by erasing the arrow footprint from the canvas (destination-out). But between the gap point and the head's back edge the line is always strictly inside the head — the triangle's half-width at distance k behind the tip is k/2, and v3's gap = 2w starts the line where the head is already wider than it — so trimming the line to the head's back extent produces the same visible pixels. That deletes an entire pipeline, a second draw per end, ~0.1-0.2 s of first-frame shader compile, and a forced change to cy.png's background compositing.

    Recorded deviations: v3's erase also punches through whatever is under the head (compound parent bodies, other edges) and this does not, and mid arrows are not covered — they sit mid-line where a trim cannot reach. Logged as the round's follow-up.

  2. Carrier: widen edge.width to two components and bitcast the existing edge.arrowShapes u32 into .y, deriving the trim in-shader. +4 B/edge (1.86 MB at the 465k-edge fixture), no new bind-group entry, no CPU per-edge work. The two rejected carriers are logged here for the record, in case measurement ever says the vertex stage minds: CPU-computed trims in the same widened column (+8-12 B/edge, no vertex ALU) and widening edge.curveParams 4 -> 8 (+16 B/edge, 7.4 MB — the heaviest, but the one column already bound in all four relevant vertex stages, with room for mid-arrow windows).

    The arrow shaders need nothing at all: they already bind edge.width and edge.arrowShapes, so v3's spacing derives in place.

  3. Order: baselines -> harness -> mechanical fixes, so every fix lands with a before/after number.

  4. Harness: browser tier for composition, Node twins for v3's leaf math. v3 computes routing only inside its renderer, and importing v3's entry from test/ would make the Node tier depend on a v3 install, which ci-node deliberately does not do.

Phase 0 — the baselines (landed)

benchmark/arrows.mjs (new) and two renderer scenes. Nothing priced arrows before: curves.mjs measures an arrow-free graph and render-bench.mjs's eight scenes all drew arrow-shape: none, so the arrow pipelines contributed to no published number.

Two corrections the suite needed before its numbers meant anything, both general:

  • Measurement order biased every row by ~20%. Timing the arrowed side then the plain side read 1.21x on a node drag and 1.19x on build — operations that touch no arrow data. Timing each side twice as A B B A and keeping the faster of each pair collapses both to 1.00x. A B A B would preserve the bias.
  • Rows sized by the graph rather than by the work put the accessor rows under a millisecond, where the two sides differed by more than the thing being measured. They now do a fixed 200k operations, on an edge-heavy graph (four edges per node pair) because a per-edge property cannot be seen past two thirds of the elements being nodes.

Baseline (i9-9900K, amd gcn-4, 20k nodes / 40k edges): every CPU row reads 0.98-1.06x — the honest answer, since v4 does almost nothing with arrows on the CPU, which is the defect. Because a suite whose rows all read 1.00x cannot show that it can move, it carries its own discrimination control: the same accessor on a bezier edge, which already resolves a real boundary point, reads 326 ns/edge against the straight path's 260 (1.25x) — so the rows do see endpoint work, and that is roughly where they should land once the gap fix is in.

Device time, 25k x 50k, fit-all p50: 3.400 ms with no arrows, 3.550 ms filled (+4.4%), 3.556 ms half-hollow, against run-to-run noise of 0.2%. Filled and hollow costing the same is itself worth recording: it is exactly what a second destination-out pass would have changed.

Phase 1a — one parity assertion, with the ink floor (landed)

There were three diff implementations: expectParity (8 scenes), runParity (15) and four scenes that inline-copied the body. Only runParity's had an ink floor. All 29 now go through one expectParityImages, which asserts the floor, prints the ink counts on every run (so a scene drifting toward its floor is visible before it crosses), and takes both the ratio bound and pixelmatch's threshold as parameters — the two families genuinely differ, and the rotated-label scene genuinely needs 0.3.

Proven behaviour-neutral the way round 42's restructure should have been: all 28 logged scenes' mismatch counts compared before and after, byte-identical (text-rotation reads 2.328% both sides; its old log line simply had a different format). Not "the suite is green".

One defect found while writing it, in the new code: spreading opts after the default bound would let a caller without a bound key overwrite it with undefined, which the helper then reads as its own 0.02 — silently tightening every curve scene by a third.

Phase 1b — numeric routing parity (landed)

playwright-tests/routing.spec.js + playwright-tests/lib/route-compare.mjs, playwright-tests/lib/routing-scenes.mjs, playwright-tests/lib/routing-ledger.mjs, and playwright-page/route-probe.js. Nine scenes, ~90 edges, ~440 compared fields. A pixel diff says "3% of the canvas"; this says "edge parent-parent disagrees on tgt.y by 16.23 model px".

Three properties, each deliberate:

  • No adapter, no frames, no screenshots. Routing is model-space and both libraries resolve it on read, so the suite carries no hasAdapter skip and runs in ~11 s where the pixel half cannot run at all.
  • The probe is symmetric — one function asks both libraries the same public question. A per-side fixup is where a finding goes to die.
  • Non-finite is structural, never numeric. NaN > tol is false, so the obvious comparator passes a NaN silently.

What it found on its first run — and the headline is not what the round expected:

scene result
taxi (8 edges, 4 orientations) 0 diverged, 80 fields exact
segments, round-segments 0 diverged
loops (3-loop stagger) 0 diverged
bundles exact to 8.9e-15 — except the odd bundle's straight middle member
families every curve family exact; only the two straight families diverge
shapes ellipse and rectangle exact; the polygon tier measured
compound 46 of 62 fields diverge
arrows 14 fields diverge, by v3's spacing formula exactly

So v4's curve routing is correct — segments and taxi, the two the maintainer suspected, match v3 to the last bit including the axis-aligned degenerates. Whatever is wrong on that page is downstream of routing, in the strip or the arrows. That is worth as much as a defect: it removes a whole subsystem from the search.

The real findings:

  1. The straight-edge endpoint defect is everywhere. Every straight family diverges by exactly the node's boundary offset — v4's sourceEndpoint() answers the node centre where v3 answers the boundary. It is a public-API defect independent of arrows, and test/collection-dimensions.mjs currently pins v4's answer as correct.
  2. The compound tier, with the controls clean. sibling and cross — the two arrangements that must route normally — match exactly, which is what makes the rest trustworthy. Against that: p-child, p-grandchild and child-p diverge on every field by exactly 1.000000 model px, and parent-parent (16.23) and leaf-parent (8.21) diverge by much more, clipping against a derived parent box. This is the maintainer's "edge routing is a bit buggy when compounds are involved", now localized to three arrangements and one constant.
  3. v3's arrow spacing, measured to four decimals. tee diverges by 16 = radius 15 + v3's constant 1 px spacing; circle by 24.8804 = radius 15 + getArrowWidth(5, 1.5) * 0.15 = 9.8804. The harness reproduces v3's formula without being told it.

Two ledger mechanisms, both of which earned their place immediately:

  • Edge-level entries (<scene>/<edge>/*) record the deliberate boundary-approximation tier as three readable entries instead of eighteen per-field ones, with a two-sided band — a deviation that shrank is a decision that changed, and the entry describing it is then wrong.
  • The staleness check caught the round's own first mistake. The initial ledger pinned the axis-aligned round-taxi NaN on mid.x / mid.y; on that configuration v4's midpoint is finite and only boundingBox() collapses. The entries named fields that never diverged and were reported stale on the first run. The defect now belongs to a separate finiteness spec, because a bounding box is not comparable between the libraries in the first place — a parity check would have called two broken values a match.

Five scenes carry test.fail() naming the fix that will flip them, so the suite is green and honest rather than permanently red; a fix that earns one removes it. The finite geometry: <scene> specs are the guard against a test.fail() swallowing a crash — they build the same scenes, are never marked failing, and assert the record count.

Phase 1c — Node twins for v3's leaf math (landed)

test/curve-geometry.mjs gains five twins against v3/src/math.mjs, which imports nothing at runtime and so keeps ci-node's no-v3-install invariant intact. Ellipses, circles and rectangles match v3 exactly (2e-13); round-rectangles and polygons carry measured bands, so the tier that has been described in prose since it was written finally has numbers: 2.247 model px for a 40x24 round-rectangle at its corner, 8.453 for a triangle.

The control: applying the wrong tier reads 6.45 px on the same fixture, against 2e-13 for the exact rows — ten orders of magnitude, so those rows discriminate. And the browser scene independently measures 8.462 for the same triangle at the end of a real edge, against the leaf's 8.453; two tiers built from different code agreeing to a hundredth of a pixel is the reason to trust either.

One bug found by the twins failing: v3 passes half extents to polygonIntersectLine (it scales unit base points by them), so the obvious call reads as a shape twice the size — a silent 2x rather than an error.

Phase 2, fix 2 — the zero-leg guard (landed)

computeCorner and its WGSL twin now return the no-arc corner when either leg has zero length, instead of normalizing it.

This is a deliberate divergence from v3, and the only one in that function. v3's asVec (v3/src/round.mts) divides unguarded and produces NaN for every field, and v3's own collinear short-circuit cannot save it because that test runs after the normalize, where abs( NaN ) < 1e-6 is false.

Matching v3 was not an option because v4's consequence was strictly worse: v3 keeps finite allpts and only loses its roundCorners, while v4's NaN reached every strip vertex, the bounding box and the hit test — boundingBox() answered {x1: null, y1: null, x2: null, y2: null}, so the edge was invisible on the GPU (a NaN clip position is dropped), unpickable, and poisoned any bound that contained it.

The trigger is ordinary rather than exotic: an axis-aligned node pair under round-taxi makes evalTaxi emit two coincident interior points in both libraries — a faithful port — and a grid layout produces axis-aligned pairs by construction. Four edges of debug/'s v3-default network are exactly this, which is what the maintainer saw as "the taxi edge breaks in the middle".

Pinned in three places, deliberately: the computeCorner twin in test/curve-routes.mjs (three coincidence cases, each asserting v3 is NaN here first, so the spec pins a divergence rather than describing agreement), public-behaviour specs in test/curve-route-accessors.mjs, and the browser tier's finite geometry specs, whose allowlist the fix emptied — which is what "a failing test the fix has to satisfy" means in practice.

Control, and a finding inside it. With the guard removed, six of the new specs fail — but only the bounding-box spec of the three public-behaviour ones does. The midpoint and hit-test specs pass either way, because a NaN corner does not reach the midpoint (which comes from the route points, not the arcs) and elementsInBox answers from the conservative store bound on this path. They are kept as contract breadth, with a comment saying they are not what makes the block discriminate — a spec that reads like a guard but cannot fail is exactly what this repo's notes keep warning about.

The compound finding, corrected — and an open call

The first pass through this round's record said the compound tier showed "a constant 1.0 px offset on ancestry edges". True, but the attribution that suggests itself is wrong, and src/README.md is what suggests it: it records that "v4's parent boxes can sit sub-pixel smaller than v3's when children have borders — v3's node bb includes the border's miter-corner overshoot (~(√2−1)·border/2 per side on cornered shapes)". Reading the divergence as that known deviation would have closed the question.

It is not that. Measured directly, with a parent holding two 30x30 ellipse children at x = 0 and x = 90 and padding 10:

border-width v3 parent width v4 parent width left edge, v3 vs v4
default 122.00 120.00 −26.500 vs −25.500
0 122.00 120.00 −26.000 vs −25.000
4 126.00 124.00 −30.000 vs −29.000

The gap is exactly 1.0 model px per side and does not move with the border at all — not at 0, where the recorded explanation cannot apply, and not at 4, where it would have to scale. The children are ellipses, which have no miter corners to overshoot in the first place. v4's box is the children's union plus padding, exactly; v3's is that plus 1 px on each side, for a reason that is not the border.

So the README's note is not wrong about the effect it describes, but it is incomplete as an explanation of parent-box differences, and this round's scene now pins border-width: 0 on both sides precisely so that explanation is removed from the picture and whatever remains cannot be about borders.

Every ancestry-edge divergence (p-child, p-grandchild, child-p: 1.000000 on every field) follows from that 1 px, since v3's findCompoundLoopPoints builds its control points from min(pos − outerW/2) over the two endpoints. The larger divergences — parent-parent at 16.04 and leaf-parent at 8.07 — are not yet isolated; they are consistent with the same box difference amplified where an endpoint clips a box at a shallow angle, but that has not been demonstrated and should not be recorded as though it had.

Direction matters here, which is why this is a call and not a fix. Round 54 is already scheduled to tighten v4's compound bounds, and item 16 ratified the tighter compound fit() — so v4 being the tighter of the two is very likely the intended behaviour and the right answer is to record a deviation, not to inflate v4's box by a pixel to match v3. But that is the maintainer's call on public geometry, so it is logged rather than taken; see the ledger entry below.

Phase 1d — the arrow pixel scenes (landed)

Three scenes, all doing the opposite of what every existing curve parity scene does: arrows and translucency deliberately included. Measured 2026-08-06, with each scene's control run and recorded:

scene mismatch control drop
parity-arrow-hollow 11.775% filled heads: 0.563% 21x
parity-arrow-alpha 26.707% line-opacity: 1: 0.777% 34x
parity-arrow-gap (the tip spill) 3.537% no heads: 0.333% 10.6x

parity-arrow-alpha is now the largest divergence anywhere in the parity suite, and parity-arrow-hollow carries the round's clearest single number: v4 inks 36380 px where v3 inks 17484, more than double, because the line is visible through every hollow head.

The gap scene had to be rebuilt, and why is the useful part. The first version reasoned that width 20 at arrow-scale: 3 puts v3's gap at 120 model px against a 300 px chord, so the two renderers would disagree about most of the edge. It read 0.495% and passed. An opaque filled head covers its own overlap: v3's line stops 120 px behind the tip and v4's runs on to the node centre, but the head spans 137 px back and is opaque, so both renderers paint the same pixels over nearly all of the difference.

What survives is only the wedge near the tip where the head is narrower than the line — the head's half-width grows as k/2, so the line pokes out sideways for roughly the first width px, contributing about width²/2 per end. That wedge is what the maintainer described as "a bit of the line peeking out by the triangle point".

So the scene is tuned the opposite way from the first draft — a thick line and a small head, over eight ends rather than four, since the count of ends is what accumulates. And the general lesson is worth keeping: v3's gap exists mostly so that hollow and translucent heads work, not to hide a filled head's overlap, which is why the two scenes that read 11.8% and 26.7% are the ones that matter and this one is a supporting detail.

All three carry test.fail() naming fix 3, so they are green-and-honest until the fix earns their removal.

Phase 2, fix 3 — the arrow gap: designed and measured, NOT landed

This is the round's honest shortfall, recorded rather than glossed.

What landed: the data, verified against v3's own functions rather than read off its source. src/shape-points.mts gains ARROW_GAP_K, ARROW_GAP_K_DEFAULT, ARROW_GAP_CONST, ARROW_SPACING_CONST and a per-shape ARROW_BACK (the existing ARROW_MAX_BACK is a single max over all shapes, which is right for sizing the arrow quad and wrong for deciding where a line stops). v3's registerArrowShapes only touches this.arrowShapes and this.arrowShapeWidth, so calling it on a bare object with a getArrowWidth stub yields the real table; every constant was read from it at width: 5, arrow-scale: 1.5, and the numbers are in the table's doc comment.

circle's spacing of 9.8804 has two independent confirmations — that probe, and the routing harness measuring v3's rendered endpoint from the other direction.

What did not land: the plumbing. The chosen carrier — widen edge.width from one component to two and bitcast the existing edge.arrowShapes u32 into .y, so the edge vertex shaders can derive the trim from data they already bind — is correct and still the recommendation, but its blast radius is wider than the plan's estimate. Widening that column reaches:

  • every [ slot ] index into it becoming [ slot * 2 ]collection.mts (2 sites), style.mts (4), animation.mts (2);
  • the tween machinery, since 'width' is a kind: 'scalar' channel on edge.width and captureEdgeWidthRides reads the column directly;
  • the cull kernel, which is handed edge.width's buffer in renderer.mts;
  • four render pipelines' WGSL, where widths[slot] becomes widths[slot].x.

That is the endpoints[ at / 2 ] stride hazard the plan flagged, spread over five subsystems, and it is the kind of change that renders plausibly when it is wrong. Starting it without room to verify it properly would have been the worse call.

What the next round starts from, which is more than this round started with: three failing pixel scenes with measured controls, a numeric harness that will show the gap as exact per-shape magnitudes the moment the trim exists, the constants verified, and a baseline (benchmark/arrows.mjs plus two renderer scenes) to measure the change against. The design note stands as written above — including the decision that trimming to the head's back extent replaces v3's destination-out erase, which is what keeps this a geometry change rather than a new render pass.

One correction to the plan's own estimate, worth carrying forward: the plan said the arrow shaders "need nothing at all" because they already bind edge.width and edge.arrowShapes. True for spacing, and spacing is only non-zero for tee (1 model px) — v4's circle and circle-triangle SDFs already bake their offset in. So the arrow-side half of this fix is genuinely one constant; all the difficulty is on the line side.

Fix 2, verified on the page — and two corrections to its own record

The standing rule is that something has to open the page, so the debug harness was driven before and after with a scripted browser, at the exact network the maintainer reported (debug/?network=v3-default). The before/after is unambiguous:

  • before: one edge — gh, round-taxi, between g and a child of the compound parent — answered boundingBox() = {x1: null, y1: null, x2: null, y2: null};
  • after: zero of the 23 edges answer a non-finite box.

Two corrections to what this round first wrote about that defect, both found by checking rather than by reasoning:

  1. The consequence is worse than recorded. The first write-up said the edge was unpickable and "poisoned any bound that contained it". Measured: cy.elements().boundingBox() — the whole graph's bound — also came back all-null. cy.fit() reads that, so a single degenerate edge broke framing for the entire graph. A spec now pins the graph bound as well as the edge's.
  2. One claim was overstated and is withdrawn. The record said the edge was "invisible on the GPU (a NaN clip position is dropped)". That was inferred, not observed: the before/after screenshots of the page show the edge drawn in both. The NaN reaches the corner arc, and the strip evidently still renders something. What is demonstrated is the public geometry — the edge's box and the graph's — so that is what the record should claim.

The second correction is the more useful one to carry forward. Both statements were plausible, both came from the same reading of the code, and only one survived being looked at.

Fix 1 — the straight-edge endpoint, landed (2026-08-06)

The call went to the maintainer as ledger item 20 and came back "match v3", so _endpointPoint now resolves the node boundary along the chord for a straight edge instead of falling through to the node centre. The resolve itself lives in GraphStore.straightEndpointAt, beside curveEvalAt and the other column readers, because it is the CPU twin of the straight arrow shader's own tip placement — which is the property that makes it right: the accessor reports the point the renderer draws to.

One v3 term is deliberately left out. v3 also subtracts the arrow shape's spacing, non-zero only for tee (a constant 1 px) and the circle heads; that arrives with the gap/trim port, where the shader gets it too. Adding it now would have made the accessor describe a point v4 does not draw — trading one wrong answer for a subtler one.

What it did to the harness, which is the useful record:

scene before after
base 4 diverged clean, test.fail() removed
families 8 diverged (both straight families) clean, marker removed
bundles 2 diverged (the odd bundle's straight middle member) clean, marker removed
arrows 14 diverged 4, and exactly v3's spacing term

The arrows residual is worth quoting because it is the fix's own receipt: circle by 9.880383 — getArrowWidth( 5, 1.5 ) x 0.15 to six decimals — and tee by 1.000000, with the other five heads clean. The harness reproduces v3's spacing formula without being told it, twice now.

A third correction to this round's predictions. The plan expected the Playwright probe at renderer.spec.js:4516-4546 to fail and it did not: that spec exercises a manual endpoint (12c), which resolves through the route path rather than the straight fall-through this touched. Only test/collection-dimensions.mjs needed rewriting, and its new expectations are hand-derived rather than pasted from the output — the boundary of a 40x20 ellipse along the (2, 1) chord is 10*sqrt(2) across and 5*sqrt(2) down.

That makes three predictions in one round that measurement corrected: the arrow-gap scene that measured nothing, the "invisible on the GPU" claim, and this one. All three were plausible readings of the code. None survived being run.

The arrow gap

Round 55 designed this fix, measured it, and did not land it. This round landed it, and the useful half of the record is the four places where measurement contradicted the design note it was working from.

What was wrong

v3 keeps two shortened points per edge end and they are not the same point: the drawn line stops arrowShapes[shape].gap(edge) behind the node boundary, and the arrow tip sits spacing(edge) behind it. v4 had neither. Its line ran to the node centre, so it showed through every hollow head, composited twice under every translucent one, and leaked a wedge past every filled tip.

A second defect, which no plan had predicted, came out of rendering the scenes rather than reasoning about them: a hollow head strokes its outline, so its ink reaches half a stroke width outside the polygon — furthest out at the back corners, where two edges meet acutely. The arrow quad's margin was 1 px, so v3's corner tabs were cut off flat. That is the "clipping around the back corners" the maintainer reported, and it is the same defect shape round 27.6 fixed longitudinally (a hardcoded 0.3 clipping the compound heads) arriving in the lateral direction.

The carrier, and why the shape word travels rather than a trim

edge.width widened from one component to two: lane 0 the width, lane 1 a bit-exact copy of edge.arrowShapes. All four edge vertex stages already bind that column and none has a spare storage-buffer slot, so this is the only way the shape word reaches the stage that has to shorten the line without a new binding anywhere. GraphStore.setArrowShapes is the single writer and copies through a Uint32Array view of the column's own buffer — exact for any word, rather than exact only while the packing happens to leave bit 23 clear.

Round 55 logged CPU-computed trims as the alternative. The reason to prefer the word is not the 8 bytes: the head is drawn at the quantized arrow-scale, so a gap derived from the same quantized value meets the head exactly, where one computed from the unquantized scale would leave a sub-pixel seam.

Where the gap applies — routing, not paint

The lesson the new curved-arrows routing scene bought: v3's storeAllpts builds the drawn path from rs.startX/Y and rs.endX/Y, the gap-shortened points. So a head shortens the curve itself, and its midpoint and its flattened bound follow. A trim written as "stop drawing early" would have passed every pixel scene and diverged here — it did, by 2.585 px on mid.y, before the fix.

And v3's straight midpoint is not the chord's:

rs.midX = ( rs.startX + rs.endX + rs.arrowStartX + rs.arrowEndX ) / 4

which lands back on the chord midpoint only when both ends carry the same head. That is why the symmetric arrows scene reported mid clean for a whole round while getting the endpoints wrong. Four rows of the new asym-arrows scene reproduce a hand-derived v3 formula exactlynone->triangle 120.100000, triangle->none 129.900000, diamond->chevron 125.122500, tee->triangle-tee 120.600000 — which is the third independent confirmation of the gap constants, after round 55's probe of v3's own registerArrowShapes and the harness measuring v3's rendered endpoint from the other direction.

Four predictions measurement corrected

  1. Round 55's design note is half right, and the half that is wrong is its scope rather than its idea. It says trimming to "the head's back extent" reproduces v3's erase. Two corrections, and the second was the maintainer's, not the round's:

    • Back extent is the wrong depth for a concave head. v3 shows the line through a vee's notch, so the trim has to stop at the head's contiguous axial depth — 0.15 for vee against a 0.3 back extent. ARROW_AXIAL_DEPTH is computed by walking the axis rather than declared, for the reason 27.6's bound is computed.
    • The round's first answer extended that trim only for hollow ends, chosen from an A/B rather than from a principle, and the maintainer pushed back: if round 55's approach works for most head types, why not use it for most? Re-derived, the principle is "does the head hide the line?" — an opaque filled head does, so v3's plain gap is exact and shortening further would cut the slivers v3 leaves where the head is narrower than the line; a hollow or translucent head does not, so v3's destination-out erase is what hides the line and the trim must reach the head's own depth.

    Measured three ways on the corrected suite, and the generalisation is strictly better than either blanket rule:

    scene hollow only always never hollow or translucent
    arrow-alpha 0.853% 0.000% 0.853% 0.000%
    closeup-gap 0.020% 0.263% 0.020% 0.020%
    closeup-heads 0.000% 0.047% 0.000% 0.000%
    closeup-curves 0.005% 0.092% 0.005% 0.005%

    A translucent edge with arrowheads is now pixel-identical to v3 — 0 differing pixels — which closes the last of the five things the maintainer reported at the head of round 55. The flag rides in edge.width's mirror lane, not in edge.arrowShapes: the mirror is v4's own private channel to the vertex stages, so spending its copy of the reserved span costs the real column's reserve nothing (which item 23 is about). That makes the mirror a derived word rather than a bit-exact copy, and the store re-derives it from both inputs — the shape word and the two stored arrow colours.

  2. parity-arrow-alpha was measuring the wrong thing — round 55's own cautionary case, one level up. At arrow-scale: 4 its heads are longer than the chord, so they overlap each other, and v3 erases before painting each one; the scene read a head-over-head difference under a name that says line-over-head, and sat at 18.6% after the fix for that reason alone. Retuned to scale 1.5: 0.853%, with an opaque-line control of 0.000%.

  3. The goldens could not have caught any of this, so one was built that can. Measured on one machine, pre- and post-change: 11 of 43 goldens moved, the largest by 0.178%, against a 0.5% bound. That is not a tuning accident — it is what the trim is: v3 sizes its gap so the line stops under the head, so on an opaque filled head the whole difference is covered, and every arrow golden in the suite used opaque filled heads. The new arrow-gap golden is built from the heads that do not cover it — hollow and translucent, four rows, at zoom 4.

    Its two controls: with the trim degraded to v3's plain gap it moves 1.394%, and with the trim removed entirely (v4 before this round) 5.253% — 2.8x and 10.5x its bound, against the 0.178% the other 42 managed between them.

  4. arrow-shape is not a property in either library. v3 registers only the four prefixed spellings, so the bare name the routing sheets carried was a no-op v3 warned about on every scene — invisible because routing.spec.js does not watch the console, and harmless only because it named the value already in force. Found when a close-up pixel scene copied the idiom into a suite that does watch.

Measured

scene before after
parity-arrow-gap 3.537% 0.000%
parity-arrow-hollow 11.775% 0.442%
parity-arrow-alpha 26.707% 0.853% (retuned)
closeup-gap 5.610% 0.020%
closeup-heads 0.149% 0.000%
closeup-hollow 2.555% 0.898%
closeup-edges 0.093% 0.004%
closeup-curves 0.972% 0.005%

Four scenes with nothing to do with arrows went to exactly zero — basic, transform, opacity-split, compound-arrows — because the line no longer runs under a translucent node. Routing: arrows is 0 diverged of 42; asym-arrows and curved-arrows diverge only by the arrow-scale quantization, pinned entry by entry in the ledger. No test.fail marker is left anywhere in the suite — every one was earned off. 109 visual + routing, 104 renderer, 2046 Node, 250 module, 24 soak specs green, and the debug harness driven at ?network=v3-default shows heads on their boundaries with no line through the hollow interiors and no cut corners.

The close-up tier

The maintainer asked for parity tests that zoom in closely enough to judge fidelity. Five scenes (parity-closeup-*) render short edges at zoom 3-5, on the property that anti-aliasing is a boundary effect and does not scale: zooming grows the ink while the fringe stays a pixel wide, so AA's share of the mismatch falls and the bounds can be 4-20x tighter than the zoom-1 tier's 2-3%. Each bound is set from that scene's own measured control.

closeup-curves with its heads off reads 0.002% — three curve families magnified 3x, two pixels from v3. That is the strongest statement in the suite that v4's routing is exact.

Goldens: six were cropping the graph

Measured every scene's rendered bounding box against its exported viewport: six of 43 spill, worst arrow-shapes at 109 px below a 300 px canvas — over a third of the scene, and an arrow golden at that. The six get a canvas that fits, and expectGraphFits now runs before every golden diff so a scene that outgrows its canvas fails loudly.

Uncropping arrow-shapes found the better defect underneath: round 27.6 added v3's four compound heads to the scene's shapes list and never added their mapper clauses, so all four fell through to triangle — and their rows are exactly the ones the crop removed. Two defects hiding each other: the crop hid the missing mapper, and the missing mapper meant the crop appeared to remove nothing that looked wrong. The golden had been showing seven heads while naming eleven since 27.6.

Recorded deviations

  • Edge labels and the overlay/underlay/casing strokes ride the untrimmed path. Closed by round 58 (2026-08-09). A binding, not a decision: both vertex stages sat at the 8-storage-buffer budget with no slot for edge.width, and a layout entry counts even for a binding the shader never reads. An edge label on an arrowed bezier anchored ~2.6 model px from what midpoint() answers. The fix was to free a binding, which round 58's fused node.outerGeom column did; logged rather than guessed at, and then taken as its own round.
  • Two translucent heads that overlap composite where v3's erase flattens them. v4 has no erase pass by design (decision 1 of round 55).
  • A hollow head's back corners are radiused where v3 miters them — offsetting a distance field rounds a join by construction. That is all of closeup-hollow's 0.898% residual: its filled control reads 0.000%.

Open call raised — arrow-scale is quantized to 1/16

edge.arrowShapes stores the scale as an integer x16 (round 13 B7), so arrow-scale: 1.4 is drawn and measured at 1.375, 1.8% small — and it is not only readback, as the ledger line said: the head's size, v3's gap and v3's spacing all derive from it. Every residual in asym-arrows and curved-arrows is this and nothing else. See open call 23.

Cleanup

Six items, all from the maintainer, none blocked on a decision. (The round closed with 57.7 and then reopened four times on 2026-08-08 — 57.8 through 57.11, each also maintainer-raised: the edge-press / hit-halo / arrowhead-picking arc, and the debug harness moved onto the default style. Their entries follow the originals below.)

  1. The default stylesheet should look like v3's. Grey by default for both nodes and edges, the same blue on selection for both, and v3's active style expressed through the overlay props — which v4 has (round 13 A2 ported them precisely so the baked-in affordances could become styled defaults) and has not yet used for this.

  2. Adopt oxfmt, and drop the extra-space call style. cy.foo( bar, baz ) becomes cy.foo(bar, baz) — a standard format rather than this repo's own. Mechanical, and best done in one commit that touches nothing else so it stays reviewable.

  3. The status site's design and record pages each open with one huge paragraph. Fix the source markdown (src/README.md and this file's header) so both read as prose rather than as a wall — the site publishes them verbatim, so there is nothing to fix in the generator.

  4. The documents are too bullish about v4 being close to ready. They are not, and the planned rounds are only the ones that happen to be written down — the maintainer can name several more that are not logged yet. Say both things explicitly: temper the readiness language, and state that the round list is the currently documented set rather than an exhaustive plan.

  5. More demo networks in debug/, ported from v3's demos: node types, edge types, edge arrow types, labels. The harness has ten networks and round 46.6 added v3's default debug graph for exactly this reason — these four are the next most useful for judging a rendering change by eye.

  6. The status build's path checker warns on eight paths that are deliberately historical — round 42's record of the src/gpu/src/ rename, and the AGENTS.md lesson that quotes the old spellings as examples of what went wrong. Every one is correct prose; the checker cannot tell a pointer from a quotation. A warning that fires on every build and is always to be ignored is worse than none, so either teach the checker to skip fenced/quoted historical names or exempt those two files' history sections explicitly.

    (Found 2026-08-07 while sweeping the docs; the genuinely stale pointers it did surface — three v3 sources that moved under v3/ and one line-wrapped path — were fixed in the same pass.)

Code investigation (2026-08-07, precedes the passes)

Item 1's first clause is already true, and measuring it is what says so. Every property v4 accepts was read back from a bare node and a bare edge on both libraries (round 47's method — ask the running code, do not transcribe the ledger). 72 (group, property) pairs differ, and 68 of them are spelling: 0 against 0px, -0.7853981 against -45deg, rgba(0,0,0,0) against rgb(153,153,153) where v4 folds a zero opacity into the colour it reads back and the channel is invisible on both sides.

Four are real, and only one of them is this round's:

v4 v3
edge width 2 3 v3's default sheet says edge { width: 3 }fixed here
node text-valign bottom top recorded deviation, round 13 D3 — every label golden pins it
edge curve-style straight haystack signed-off round-12 call 1, performance-first
node font-family sans-serif Helvetica Neue, Helvetica, sans-serif one font per atlas; the stack's tail is v4's value

background-color and line-color do not appear in that diff at all: both are #999 on both sides, and have been since round 1. So "grey by default for both" needed nothing, and the item's real content is the two affordances that are not properties — selection and active — which v4 bakes into the shader where v3 spells them in its default stylesheet.

What v4 actually draws today, against v3's five default-sheet rules:

  • :selected — v3 paints the node body, the line and all four arrow colours #0169D9. v4 draws a ring at the node boundary and gives an edge nothing at all: FLAG_SELECTED is read at exactly one place in all of the WGSL (shaders.mts, the node fragment shader). A selected edge is indistinguishable from an unselected one.
  • :parent:selected — v3 tints #CCE1F9 / #aec8e5; v4 has no such case.
  • :active — v3 lays a black overlay at 25% over 10 px of padding. v4 has FLAG_ACTIVE and the public activate()/active() pair, and nothing reads the bit: the pointer layer never sets it, and no shader binds it. What v4 draws instead is a +0.15 rgb brighten on FLAG_HOVERED | FLAG_GRABBED, which round 13 A2's own record marked as the placeholder ("the existing shader hover/active brighten, accent ring and DOM selection box become the styled defaults").
  • :parent — v4's parents overlay carries v3's five properties exactly (round 14.6). Nothing to do.
  • :loop / edge:compound — v3 forces bezier on both; v4 routes loops as loops and ancestry edges as CURVE_CMPD regardless of declared style, which is the same look by a different mechanism. Nothing to do.

Design calls (round 57)

  1. The affordances stay shader-drawn; only what they draw changes. v4 has no :selected blocks and does not restyle on selection — that is decided design, and round 4's select/unselect fast path (dependsOnSelection) rests on it. So this round does not reopen selection-dependent restyling: it changes the pixels the shader produces for a selected or active element to v3's, and leaves stored truth alone. Consequence, recorded: style('background-color') on a selected node still reads its own colour, exactly as it does today with the ring.

    Reversed by the maintainer, and 57.1d implemented the reversal. The call above is wrong in its premise rather than its trade-off: "v4 has no :selected blocks" is true and irrelevant, because v4 has conditions, and a { when: { selected: true } } case had been compilable since round 14.7's structural pair. The rules live in v4's default stylesheet now, spread before the user's block. Round 4's fast path survives unchanged and generalised: a flag write reaches the style engine only when some condition reads that state, so a sheet naming its own colour makes selection free exactly as before. And style('background-color') on a selected node reads blue, which is what v3 answers. Design calls 3 and 4 below fall with it.

  2. FLAG_ACTIVE becomes the press state, as in v3, set by the pointer layer on press and cleared on release/cancel — v3's near.activate() / unactivate() in load-listeners.mts. It is already public API (activate(), active(), inactive()), so this makes an existing surface mean something rather than adding one.

  3. The active overlay rides the round-13 A2 layer machinery, which is what the item asks for. The overlay pass draws a synthesized default record — black, 25%, padding 10, round-rectangle — for an active element that has styled no overlay of its own; an element that has one keeps it (a user's overlay is not overridden by a press). That needs the cull predicate and the pass gate to admit active elements, which is where the work is.

    Superseded (57.1d). It rides the overlay machinery, but as a style value rather than a synthesized record: the default sheet sets overlay-opacity behind { active: true }, and the other two of v3's three declarations are already v4's constants. So the cull predicate and the pass gate needed nothing after all — a pressed element simply has an overlay — and the "substituted only where nothing is styled" rule turned out to be a hand-rolled imitation of stylesheet precedence, which the spread does exactly.

  4. The grab half of the brighten goes; the hover half stays. A pressed element now gets v3's overlay, so brightening it too would be two affordances for one state. Hover is a different state that v3 does not style at all and v4 has no other way to express — dropping it would remove feedback the maintainer did not ask to remove — so it stays, as a recorded deviation from v3 rather than an oversight.

    Reversed by the maintainer (57.1d). "v4 has no other way to express it" was the same false premise one layer down — hovered is a condition like any other. The brighten is deleted, and a sheet that wants the feedback writes it. Note what it had been: a hard-coded color.rgb + 0.15 that no spec covered and no stylesheet could turn off.

  5. oxfmt lands before everything else. It rewrites every source file, so landing it first means the rest of the round is written in the new style once rather than reformatted after the fact. The one thing to watch is that the throw-coverage gate's allowlists are keyed by file:line (round 37.1) — a reformat that moves a throw by one line fails the build, correctly, and the fix rides the same commit.

  6. The path checker gets a maintained allowlist, not a heuristic. A "skip anything that looks historical" rule cannot tell a quotation from a stale pointer, which is the distinction that matters — the same sweep that found these eight also found four genuinely stale ones. So each exempt spelling is listed with its reason, and the list is validated on 37.1's terms: an entry that no longer appears in any document, or one whose path has come back into the tree, fails its spec. A dead exemption is exactly as bad as a false warning.

Pass split (docs in-commit; each pass its own commit(s))

  • 57.0 Docs-first — this section.

  • 57.2 oxfmt (2026-08-07) — 325 files outside v3/, two config overrides (singleQuote, printWidth: 80) and the rest of the tool's defaults.

    The reformat is a free control on every tool that reads the sources as text, and it found four defects. The @param gate read 232/232 while five public members were invisible to it — it captured the argument list with \(([^)]*)\), so a member whose parameters wrapped matched nothing and was skipped, and Collection.boundingBoxAt had no @param at all behind that. memberBody stopped at a wrapped signature's first parameter line (two-space indented, so it reads as the next member) and again at a multi-line return type's } {, dropping @throws detection for every exported arrow function in src/algorithms/ and for Collection.boundingBox. An export const f = that broke after the = vanished from all four audits. And the throw gate had a false pass: src/style.mts's bg-length guard sits in a module-level arrow const — the misattribution the script documents as its own blind spot — so it read as covered while no spec had ever fired it. Each is fixed by joining the signature before parsing it; the guard got the spec round 30.1 would have written, with a message assertion and a control. @param is 239/239 now (240/240 since round 52 added the wgsl tag). Five file:line allowlist entries moved and the gate named all five, which is the failure mode 37.1 was built for arriving a third time. Goldens byte-stable, every parity scene at its recorded value.

  • 57.6 The path checker's allowlist (2026-08-07) — six spellings, HISTORICAL_PATHS in scripts/status/markdown.mjs, each with the rename or the lesson that quotes it. A quoted span still renders marked (path-historical, the reason as its title) — a reader should see that it names nothing — but it is not a warning, and the build now reports zero documented-path warnings, which is what makes the remaining ones worth reading. Checked in both directions, on 37.1's terms: an entry no document mentions fails, an entry that starts resolving fails, an entry with no reason fails, and the build warning about any path fails. All four controls run: dropping one exemption, adding a dead one, adding one that resolves (src/core.mts) and adding a reasonless one each fail exactly the spec written for them.

  • 57.3 The document openings, then the documents (2026-08-07) — src/README.md opened with a 261-line paragraph and this file with a 182-line one; the status site publishes both verbatim, so that is what a reader arriving at the design page and the record page saw first. Both are now broken at their natural round boundaries — the longest header paragraph in either is 22 lines — and both gained a short lead that says what the document is, what it is not, and which two or three sections a reader usually wants. src/README.md's chronology also gained an h2, so the site's table of contents offers it as something to skip rather than as 250 lines of undifferentiated text.

    No prose was dropped: the edit is paragraph breaks, four connective sentences where a ; became a full stop, and the two leads.

    A second pass took the same treatment through both documents, because fixing the two openings left the walls behind them. Every text block over ~1200 rendered characters is now cut at sentence boundaries — this repo writes two spaces after a full stop, which makes the cut points unambiguous. Measured as the site renders it, blocks over 1200 characters: src/README.md 54 -> 12, this file 179 -> 39.

    Three things are worth keeping from how it was done.

    • Cut, do not reflow. The first attempt re-wrapped the paragraphs it split, which produced a 10,000-line diff in which the real change was unreviewable and the two-space sentence convention was destroyed. The edit that shipped inserts blank lines and changes nothing else, so every hunk reads as what it is.
    • The control is render identity, not a diff. Strip the tags from renderMarkdown()'s output, collapse whitespace, and the before and after must be byte-identical. That is what caught a cut landing inside a ***bold italic*** span — the two halves stopped being one emphasis run, one <em> vanished, and nothing else in the document or the build would have said so. (The bug beneath it: the span guard counted markers from the enclosing block rather than the list item, so an odd * in one item made the count even in the next.)
    • Measure what a reader sees, not what the parser labels. The first measurement counted <p> only, and a tight list item emits none — so the biggest walls in both documents were invisible to it, including several over 3000 characters. Count <p> and tight <li>.

    What remains long is a handful of dense enumerations whose clauses are separated by semicolons rather than sentences (the longest, 4391 characters, is src/README.md's viewport/event surface list). Those are lists written as prose; breaking them is an editing job, not a mechanical one, and is left.

  • 57.4 The readiness language (2026-08-07) — the claim is now made in the same words in four places, because a reader arrives at whichever one they arrive at: this file's header, src/README.md's lead, the status site's landing page ("Not a release, and not close to one") and EXECUTIVE_SUMMARY.md, which round 56 had already tempered. README.md, CHANGELOG.md and MIGRATING.md — the three that ship — were checked and already said "not released; use cytoscape@3", so they gained nothing. Both documents also say why they read optimistically, which is the part that stops the sentence being decoration: they are assembled from rounds that closed green, so "landed", "complete" and "at parity" appear on nearly every line and each is true about its own scope only. Those phrases are kept as written — they are accurate history — and the caveat is what keeps their sum from reading as a claim. And the sweep found the section that had drifted the other way: src/README.md's follow-up hooks still opened with round 55's unbuilt arrow gap, the day after round 56 built it, describing v4's largest measured divergence from v3 as outstanding when the three scenes it cites now read 0.000% / 0.442% / 0 differing pixels. A list of what is still open is worth exactly as much as its freshness, which is now said in the list itself.

  • 57.5 Four more debug networks (2026-08-07) — ?network=node-types / edge-types / edge-arrows / labels, ported from v3/documentation/demos/, built in-page with explicit positions (grid's column count depends on the container's aspect ratio — the trap round 43.12 recorded) and a hand-authored v4 sheet each. Where v3 writes a class selector these carry a data key and the sheet maps it through a case, which is v4's answer to per-element styling. The harness goes from ten networks to fourteen, and five of them are now about drawing rather than scale.

    Three things the port could not take verbatim, each a recorded limit rather than a shortcut: v3's shape-polygon-points: data(points) has no v4 spelling (list props are constants-only), so the cross rides as a constant that only the polygon node reads; the two unbundled-bezier rows share one parameterisation for the same reason (round 46.6 hit this porting v3's default graph); and v4 has the 3x3 label-anchor grid without v3's -inside variants (round 13 D3), so those cells are dropped rather than faked.

    The spec is the interesting part. "The sheet compiles" is what round 43 shipped, and a maintainer found three defects by opening the page a day later — so these four get the property they exist for instead: every keyword the fixture names must read back as itself. That is round 56's defect made detectable, where four compound arrowheads had been listed in a golden's scene since round 27.6 with no mapper clause, drawing as triangle for nine rounds under a golden that passed throughout. It found one on its first run.

    arrow-fill never resolved to hollow: the helper builds its clauses from Object.keys, which turns the boolean true into the string 'true', so the comparison was eq: 'true' against a boolean and every edge read filled. The screenshot would have shown twelve identical filled heads — plausible, and exactly the failure the spec is named for. Controls: unmapping one shape keyword, one curve family, or collapsing two cells of the anchor grid each fail exactly one spec.

    And the page was driven, per the standing rule: all four render in a real browser at their expected counts with no page errors, and the screenshots moved the arrowhead labels above their nodes (a left-aligned triangle-backcurve (hollow) overhangs the next column) — a defect no spec would ever have raised.

  • 57.1a The selection look, and edge width (2026-08-07) — the node fragment shader draws v3's :selected rule instead of v4's accent ring: the fill goes #0169D9 and the border keeps its own colour, and a selected compound parent takes v3's #CCE1F9 / #aec8e5 pair (:parent:selected, which v4 had recorded as unported). Deliberately outside the LOD branch, so a selected node stays visibly selected at far zoom where decorations collapse.

    FLAG_GRABBED leaves the hover brighten, since 57.1c gives a pressed element v3's overlay instead and two affordances for one state is worse than either. Edge width default 2 -> 3, v3's default sheet's only element rule. A parity scene that could not have passed before, which is the point of building it: v3 fills a selected node and v4 ringed one, so the two disagreed over the whole interior of every selected node and no scene covered it. parity-selection reads 0.017% (20 px) over selected leaves, a selected parent and their unselected twins, with neither stylesheet mentioning selection — so what it compares is precisely the two libraries' defaults. Two controls, both failing even the suite's loose 3% default: 3.147% with the colour removed and 5.883% with a selected parent taking the leaf colour. And it found a real divergence, recorded rather than hidden. The scene's first draft styled background-color and read 5.18%: in v3 the selection rules are default-sheet rules, so a user block naming a colour comes later and beats them — a v3 app with a palette shows no selection colour unless it writes its own :selected. v4 has no :selected to write, so matching that exactly would leave an app no way to make selection visible at all. The shader wins instead; the deviation is in src/README.md with the mechanism that would reverse it (a per-group bit in the Frame uniform, which is exact here rather than approximate, because a v4 sheet has exactly one block per group).

    The deviation was rejected, and 57.1d removed it. The premise — "v4 has no :selected to write" — was wrong when it was written: the case mappers had carried a { selected: ... } condition since earlier the same day, so an app could write the rule, and the whole question was which sheet the default lived in. It lives in v4's default stylesheet now, spread before the user's block, which is v3's precedence exactly. Read 57.1d, not this paragraph. Goldens: two moved and eleven did not. UPDATE_GOLDENS=1 rewrites every golden whether or not it exceeded its bound, so the run was re-checked without it — only selection-accent (its scene grew a parent pair) and polygon-shapes (its selected star) actually failed, and the other eleven were reverted as sub-tolerance drift.

    That is round 27.3's discipline, and it is the difference between a golden diff that means something and one that means "a run happened". One renderer spec changed and says more for it: the node it adds while headless is added selected: true, so it now draws blue rather than the sheet's red — which pins that the flag survived the re-mount as well as the geometry.

  • 57.1b Selection reaches edges and arrows (2026-08-07) — the edge, curved-edge and both arrow fragment shaders bind edge.flags and take v3's #0169D9 on line-color and on all four arrow colours, which is what v3's :selected does.

    Before this a selected edge in v4 was indistinguishable from an unselected one: FLAG_SELECTED was read at exactly one place in all of the WGSL, the node fragment shader. The binding is the interesting half. Three of the four shaders had a free fragment slot; the straight edge shader had none — eight storage buffers in each stage — and the fix was not the layout split open call 24 names but something cheaper that the split's own reasoning suggests: the fragment stage wanted one number out of edge.curveParams (the straight-triangle kind), so it now takes that as a flat varying and the column went vertex-only, freeing its slot. A binding for a number is what the budget could not afford. parity-selection grew selected and unselected straight edges, a selected unbundled-bezier, and circle/triangle heads on all of them, and reads 0 differing pixels — v4's selection look is not close to v3's here, it is the same image. Four controls, each failing the scene's 0.1% bound: 3.147% (node colour removed), 5.883% (a selected parent taking the leaf colour), 0.690% (the line left untinted) and 0.367% (the heads left untinted). The last two are the reason the bound is 0.1% and not the suite's 3% default — at 3% both would have passed with the feature missing, which is round 27's own cautionary case.

  • 57.1c v3's :active, through the overlay props (2026-08-07) — the item's third clause, and the one round 13 A2 had already written the machinery for: "the existing shader hover/active brighten, accent ring and DOM selection box become the styled defaults."

    FLAG_ACTIVE and the public activate()/active()/inactive() trio have existed since round 6 with nothing reading the bit — no shader bound it and the pointer layer never set it. Now the pointer sets it on press and clears it on every gesture end (v3's near.activate() / unactivate()), and the overlay layer substitutes v3's record — black, 25%, padding 10, round-rectangle — for an element that has styled no overlay of its own. A user's overlay is never overridden by a press. Three pieces had to agree or the affordance appears in some graphs and not others: the pass gate (the overlay pass is skipped when nothing is styled with one, so it now also runs while something is active), the cull predicate (which rejected a disabled record, so a pressed node was culled before it could draw), and the layer shader. The cull's substitution and the shader's are the same record for the same reason they were in round 12a: a cull that disagrees with a draw either drops the element or sizes it wrong. One thing needed an entry point rather than a uniform: the same shader draws both layers with a different record column bound, so it cannot tell overlay from underlay — and synthesising :active for both would darken the padding ring twice. vsLayerPlain/fsLayerPlain are the underlay's, chosen at construction where the column already is. Verified in the browser, and the sample point is what makes the spec discriminate: it sits in the 10 px ring outside the node, where only an overlay can put ink — a brighten of the body would leave it white. Three controls, each failing it: the pointer not setting the flag, the cull dropping active nodes, and the pass gate ignoring them. Four Node specs pin the store's active count over its three writers (the single-slot path the pointer uses, the bulk path activate() uses, and removal), because a count leaks: a pressed element removed under the cursor would hold the overlay pass alive forever. Both bookkeeping controls fail. Two recorded deviations. :active reaches nodes only — the press target is the synchronous CPU pick, which is nodes-only by round 17.3's own deviation — so pressing an edge activates nothing, and this is that limit rather than a new one. And v4 keeps a hover brighten v3 has no rule for, because v4 has no :hover an app could write.

    The second one is gone (57.1d). "v4 has no :hover an app could write" was the same false premise as the selection deviation's, one layer down: the condition vocabulary is v4's answer to a state selector, and it can hold hovered as easily as selected. The brighten — a hard-coded color.rgb + 0.15 in the node fragment shader, which no spec had ever covered — was deleted, and { hovered: true } is what an app writes instead. Everything this entry says about the machinery (the pass gate, the cull predicate, the two entry points, the active count) also went with it: those existed to make a shader constant reachable, and there is no constant.

  • 57.1d The states are style, not shader constants (2026-08-07) — 57.1a–c drew v3's :selected, :parent:selected and :active from the flags word inside the shaders, and recorded two deviations to explain what that cost. Both were rejected. This pass makes state a case condition, which is what v4 has instead of a state selector, and moves the three rules into v4's default stylesheet — spread before the user's block, so declaring the prop replaces the rule, which is v3's order-based precedence and not an approximation of it.

    What that took, in order of how much of it was new: nothing much. The condition vocabulary, the reserved '::' keys, the value reader, the dependency set and the change-driven refresh all existed for round 14.7's { parent } / { child }. The round is mostly deletion.

    The vocabulary. selected, selectable, locked, grabbed, grabbable, active, hovered, plus childless and orphan as the two structural negations under their v3 names. Each is a boolean, so v3's six negative selectors (:unselected, :unlocked, :free, :ungrabbable, :unselectable, :inactive) are the same key with false — one key per state rather than a pair. The binding lives in contract.mts (CONDITION_FLAGS), in one direction for the reader and the reverse for the flag-write side, because it is flag semantics and both halves have to agree. Deliberately absent, each for a reason worth keeping: :compound (its node meaning is exactly parent; its edge meaning — touching a parent — is not a bit, and a spelling that silently means less than v3's is worse than no spelling), :loop / :simple (a column compare), and :visible / :hidden / :transparent (computed from style, so a rule conditioned on one would be circular — v3 gets away with it by re-running selectors).

    What came out of the shaders. SELECT_ACCENT, SELECT_PARENT_FILL, SELECT_PARENT_BORDER and every selection branch in fsNode / fsEdge / fsCurvedEdge / both fsArrows; the edge.flags binding 57.1b had freed a slot for; ACTIVE_RECORD and layerRecord(); the duplicate of that record in NODE_LAYER_CULL, the one the cull comment said "must agree" with the shader's; the vsLayerPlain / fsLayerPlain entry points and the node.flags binding the layer pipeline carried for them; activeCount() and its three writers in the store; and the three widened pass gates in renderer.mts. Four shader flag constants are now unread and gone.

    And the hover brighten, which nothing had ever tested. A hard-coded color.rgb + 0.15 in the node fragment shader, for a state v3 styles nowhere — no spec covered it, no golden showed it, and no sheet could turn it off. Deleted; { hovered: true } is what an app writes now. It is v4's own condition with no v3 spelling, which is the honest form of "v3 has no rule for this".

    The press affordance is one property. v3's :active block is three declarations, and two of them (overlay-color: black, overlay-padding: 10) are already v4's constant defaults — so only overlay-opacity moves, as { case: [{ when: { active: true }, then: 0.25 }], else: 0 }. That is not brevity for its own sake: it makes overlay-opacity: 0 the way to turn the highlight off, and any other overlay-* value a restyle rather than a loss.

    A state condition is not tied to a property, and a spec says so rather than a comment: { when: { active: true } } on width changes the node's boundingBox(), so the cull extent and the pick follow. That is the property the shader version could never have had — the press could only ever change the one thing the shader chose to draw.

    The cost, which was the reason to hesitate. A default sheet made of case mappers means every graph pays per-element mapper evaluation at load for an affordance almost none of its elements are using: measured ~6% of a 150k-element init (704 ms vs 664 ms against an all-constant sheet). The fix is that such a group is not per-element at all. A def whose mappers read only state flags has one computed record per distinct combination of the bits it reads — two for the default sheet at rest — so applyPartitioned masks the flags word, hits a Map, and does the same write() the constant path does. Re-measured: 675 / 704 / 691 ms against 674 / 680 / 678 — the default sheet is now indistinguishable from an all-constant one. One data mapper in the group turns the fast path off, because the group is on the per-element path anyway and there is nothing left to win.

    Two controls, and both landed where they should: with the partition disabled, only the three partition specs fail and the other twelve pass, which is the claim (an optimisation must be invisible); with the partition ignoring the flags word, twelve of fifteen fail, which says the specs are actually exercising it.

    The restyle hook moved to the flag choke point, which is the structural half. 57.1a had written it by hand in _setSelected, and this round wanted it at six more sites (lock, grab, activate, selectify, grabify, hover). setFlag and flagRefs notify store.onStateChange instead, gated twice so an unstyled state costs one &: the styleable mask rejects the structural and internal bits, and a watched-key set registered by the StyleEngine rejects a state no condition mentions. That set is deliberately not watchDataKeys — that one answers "which data writes feed the GPU eval kernel" and excludes conditionals on principle, while a state condition is always a conditional.

    The parity scene that could not have existed before. parity-selection-named styles a fill on both sides, so in v3 the user block beats the default :selected and a selected node looks like an unselected one — and v4 now does the same, at 0 differing pixels. Its control is the round itself: spreading the default block after the user's rather than before it (v4's pre-57.1d behaviour) takes the scene to 12.333% against a 0.1% bound. The scene it partners, parity-selection, still reads 0 px on the defaults.

    Two goldens were measuring nothing and said so by passing. selection-accent and polygon-shapes both declared background-color, so under the new rule their selected nodes and their unselected twins painted identically — a selection golden with no selection in it. The first lost its fill declaration; the second lost a selected: true left over from the accent-ring era, since it is about polygon SDFs. A new golden, selection-overridden, covers the override case in three rows (default rule / a flat colour / the app's own { selected: true } case), and discriminates because the three rows must not look alike.

    A finding that has nothing to do with this round. Regenerating turned up six goldens whose committed PNG does not match what HEAD's own code renderslabel-boxes by 1.597%, label-align by 0.811%, and four more between 0.25% and 0.46%. Confirmed by regenerating at a clean HEAD, so it is drift that was already there. Each of the six carries a loosened per-golden maxDiffRatio (0.02 for label-boxes), which is exactly wide enough to hide it: a golden with a generous bound stops answering even "did this change?". The regenerated PNGs are committed here because they are the accurate ones; 57.1e is the fix.

  • 57.1e The goldens are exact (2026-08-07) — the answer to 57.1d's finding, and the answer is not a smaller tolerance. It is no tolerance: compareToGolden defaults to zero differing pixels, and the eleven per-golden exemptions are gone.

    The case for it is the measurement, not a principle. The default was 0.5% and eleven label scenes carried 2% on top, and under that arrangement six goldens' committed pixels had drifted from what the code drew — label-boxes by 1.597%, label-align by 0.811%, and edge-labels, label-visuals, labels-open-sans and curved-edge-labels between 0.25% and 0.46%. Note that four of the six were under the default bound too, so dropping the exemptions alone would have fixed two. The drift was real code, which is what settles it: label-boxes had not been regenerated since round 13 B6 on 2026-07-31, while label-layer, glyph-atlas, glyph-buffer, label-layout, label-pipeline and shaders.mts all changed underneath it. A week of green runs over a golden that no longer described the code.

    Exact is affordable because every input is pinned, and each was checked rather than assumed: SwiftShader (the visual project pins it), the browser (Playwright's version), and the font — vendored through @fontsource/open-sans in node_modules, so it is the same file everywhere rather than a system face. Measured across all 45 goldens, four full runs at two worker counts: zero differing pixels, at pixelmatch threshold 0. Then the whole visual project at the new default: 113 passed.

    The one variance source a web font cannot pin is the platform — Chrome rasters the atlas through CoreText on macOS and FreeType on Linux — and this is where the old bound came from. It is now handled by saying where the goldens live rather than by widening them: they are generated on Linux and gated on Linux, which is where CI runs the visual job. A maintainer on macOS will see the label scenes differ, and that is the platform showing; the documented answer is to read the diff, not to regenerate on the wrong platform and not to widen the bound. Per-platform goldens stay the reserve escape hatch.

    The control is the point of the round, so it was run. Widening the text-border band by 15% — a change to one multiply in the label fragment shader — moves label-boxes by 12 px, 0.010%. The old bound was 2% and a per-pixel colour threshold of 0.25, so it would have swallowed that two hundred times over; the exact bound fails on it. Twelve pixels is the scale of thing a golden ought to notice, and is roughly a fortieth of the drift that had already accumulated unnoticed.

    So a browser or driver bump that legitimately moves antialiasing will now fail the suite. That is the intended behaviour and the whole point: it is a change to the rendered output, which is the only thing a golden is for. AGENTS.md and src/README.md both say so at the point where someone reaching for a wider bound would read them.

  • 57.1f One vocabulary for querying and for styling (2026-08-08) — 57.1d gave the stylesheet nine states and left the query API with three, so cy.nodes( { locked: true } ) threw on a state you could style on. A hole the round opened, and the fix is not to add six more entries to a second list: matcher.mts compiles queries from the same STATE_CONDITIONS table style-scales.mts compiles conditions from, so the two cannot drift.

    Query gains selectable, locked, grabbed, grabbable, active, hovered, childless and orphan. The last two are the structural negations under their v3 names, and they behave as negations because the table says so — { orphan: true } and { child: false } compile to the same (mask, want) pair, which a spec asserts by comparing the two result sets rather than by trusting the compiler.

    Two smaller things fall out of the table being the source. The unknown-key error now lists the whole vocabulary rather than a hand-written five — with no selector language behind it, that message is the discoverability surface. And the nodes-only guard names the key the caller actually used ('orphan', not 'parent'/'child'), because it knows which spellings were structural.

    The spec walks the table rather than sampling it: every state, in both directions, as a query key and as a when condition. Control: putting the query vocabulary back to its old three fails all five specs in that suite.

  • 57.7 Closing docs sweep (2026-08-07, re-run 2026-08-08) — both documents end to end plus AGENTS.md, and the EXECUTIVE_SUMMARY.md rewrite the standing rule requires when a round closes.

    It was run twice, and the second run is the one worth reading about. The first closed the round as it stood; then 57.1d–f reversed two of the round's own design calls, which meant the sweep had to correct not just the summaries but the plan section above — a design call that a later pass reverses reads exactly like one that still holds. Both are annotated in place now rather than left to be discovered by someone building on them, which is this file's own standing warning about its stale parts, applied to itself within a day of being written. What the second sweep found, in the order a reader would hit it: the round-57 header paragraph still described the deviation as the useful finding; the "v3 → v4 parity gap analysis" said v4's colour "always wins"; the design direction section said :selected restyling was gone; and the perf section said the default sheet never restyles.

    And MIGRATING.md, which ships, told a porting app that :parent:selected was "not ported" and that the selection affordance was a shader-drawn accent ring — both false, and both in a table someone reads while porting. That guide now carries a "Styling element state" section with the whole vocabulary, six new selector-recipe rows, and the list of v3 state selectors that have no v4 form. CHANGELOG.md gained the feature it had no line for.

    The general lesson is the one this file already states about itself and had not applied to its own plan sections: a claim written when it was true reads exactly like one that still is. A round that reverses its own design calls has to go back and mark them, or the next reader builds on the version that lost. The three named drift sites were checked: "Suggested sequencing" gains the round-57 paragraph; the "Needs a call" ledger needed nothing (this round closes no design call and opens none — the two ideas it logged are directions, items 25 and 26); "Gaps with direction already set" likewise. The "what remains" amendment that added round 57 to the unbuilt list is struck with what closed it, which is the entry this file has most often left standing.

  • 57.8 The press reaches edges (2026-08-08) — raised by the maintainer after 57.1c shipped: pressing an edge activated nothing, and the deviation entry recording that (":active reaches nodes only") framed it as the CPU pick's reach. The maintainer's framing is the better one: an edge not being draggable does not make it unclickable, and the wash is the signifier of the click in progress — in v3 a press on an edge activates it at mousedown and only falls through to a pan once the drag actually starts.

    The reversal is the one 57.1's deviation note already named: the press path waits for the async GPU pick. A press the synchronous node pick misses now resolves through Renderer.pick() (~a frame; a microtask when the cursor sits in the cached pick tile), and the answer decides the affordance — an edge carries FLAG_ACTIVE and becomes the release's tap target (lastPick, which a touch press otherwise never populates, so touch edge taps gain a target too), while a true background press shows the active-bg circle. The circle waits for that answer rather than showing at pointerdown, because v3 shows it only when nothing is near — the alternative was a one-frame circle flash over every edge press. When the press pans, the two affordances swap: v3's mousemove unactivates a pannable pressed element (down.pannable() && down.active()) and anchors the circle at the pressed point, and panStarted does the same at the tap-threshold flip — non-pannable elements keep the flag through the press, as in v3. Three staleness guards, because the answer can outlive the press: a press that ended (release, or a touch gesture morphing into pinch/cxt, both of which null this.down) discards the resolution outright.

    Two renderer specs, each with a control run that failed before the fix half it names: the press/wash/swap spec (fails with activation removed, and again with panStarted neutered), and a released-before-the-pick spec pinning that a late resolution sets nothing (fails with the this.down !== down guard removed — the wash sticks with no release left to clear it). Three circle specs moved from one-shot reads to expect.poll, since the circle is now a pick answer late rather than a pointerdown side effect. What still deviates, recorded in the narrowed deviation entry: latency (a press-and-release faster than the pick never shows the wash), and tapstart still targets the core rather than the edge, being emitted synchronously at press time.

  • 57.9 v3's hit halos (2026-08-08) — the maintainer's follow-up to 57.8: "the edges are too hard to click — are we applying the hit thresholds?" We were not, anywhere: v4 picked exactly the painted stroke, so a default 3px edge was a ~3px target, while v3's findNearestElement counts an edge hit within width/2 + edgeThreshold of the centerline — 8 rendered px for a mouse, 24 for touch (the desktop/touch pair the maintainer pointed at) — and inflates node sizes by its 2/8 nodeThreshold before every shape check.

    The halo rides the pick frames. Frame grew its first field since round 56 (pickPadPx, device px — the struct rounds to 80 bytes, so the CPU arrays go to 20 floats): the straight and curved edge pick quads extrude wider by it, both pick fragment tests accept halfWidth + pickPadPx, and both edge cull margins grow by it — zero in scene/export frames, so drawing is arithmetically untouched, which the exact-golden visual project (119 green, zero differing pixels) pins rather than asserts. The CPU node pick takes an optional padPx (absent means exact, keeping every frame literal in specs and benchmarks honest) and inflates before the LOD/radius derivations, as v3 pads outerWidth before everything downstream; the label-box test grows by the same halo (v3's labelThreshold shares the value). The pick-tile cache remembers the halo it was drawn with — a tile rendered for one pad answers nothing about another, so a touch press after a mouse hover re-picks rather than reading the narrower tile.

    The pointer layer passes the pads per pointer type (padsOf(e)) everywhere it picks: press, hover, cxt, touch-cxt and drag-hover. cy.pick stays exact deliberately — the halo belongs to the gesture, not the API — and the new spec asserts both halves at one point: 8px off a 4px stroke, cy.pick answers null while a press there activates the edge; 14px off (past 2 + 8) is a background press. A curved-edge twin covers the curved stream's separate pick pipeline via midpoint(). Controls: the halo removed from the straight pick FS, from the curved pick FS, and a nonzero default pad on cy.pick — each failed exactly the spec written for it. Node halos are pinned in test/cpu-pick.mjs, including that a circle's halo scales the shape rather than boxing it (the padded corner diagonal still misses), which is v3's own approximation replicated.

    Not padded, recorded: the pick quads do not extend longitudinally, so a press just past an edge's trimmed tip still misses — that end zone is the arrowhead's, and arrowheads are an existing unpickable deviation; both belong to whatever round makes arrows pickable. That round is 57.10, the next entry — the maintainer asked for it the same day.

  • 57.10 Arrowheads are hit targets (2026-08-08) — the maintainer asked for the piece 57.9 parked, with three directions: hollow hit-tests as filled, triangle approximations are acceptable for the arrow-like shapes, and performance decides. The implementation takes the exact shapes anyway, because that was the cheapest option on this architecture: fsArrow already evaluates a true signed distance per head (the generated ARROW_POLY polygon SDFs + the circle), so the pick fragment shader reuses the same switch and tests sd <= frame.pickPadPx — no new shape code, pick-matches-drawn by construction, and the cost is a handful of SDF evaluations in a 64×64 tile. An approximation would have been more code for less agreement. Hollow-as-filled is v3's own semantics (findNearestElement runs shape.collide against the filled point table for all four ends, mid arrows included) and falls out of using sd rather than the scene's stroke coverage.

    Structure: each arrow pipeline (straight + curved) gains pick twins (same vertex shaders, id-writing fragment, r32uint target, no depth — the pick pass has no depth attachment); the four arrow quads grow by pickPadPx exactly as the edge quads did in 57.9; the pick pass draws heads after lines with the same pick id, so order cannot matter — heads add coverage, which since round 56's trim is exactly the zone the line vacated. The curved stream's pick FS drops no-arrow ends by alpha (that stream rasterizes a transparent quad where the straight VS collapses). Gating rides arrowEnds / midArrowCount, so an arrowless sheet pays nothing.

    Three renderer specs: a hollow triangle's unpainted interior picks and presses as its edge while a point past the head + halo stays background (the probe's pixel is asserted white first — the hit is the head's area, not its ink); a mid arrow picks through an exact cy.pick at a point the line cannot answer, with its mirror point in front of the tip answering null; a curved-end head picks via targetEndpoint(). Controls: the four pick draws removed failed all three; the pick FS switched to outline-only (abs(sd)) failed exactly the hollow spec, which is the user-visible requirement. The visual project stayed at zero differing pixels — the scene arithmetic is untouched at pad 0.

    What remains unpicked, deliberately: labels (edge and node — the round-16 deviation), ghosts and outlines, each recorded where they live. Arrows also stay out of boundingBox() — 57.10 changes picking only.

  • 57.11 The default style carries the show (2026-08-08) — the maintainer restated v3's style philosophy as two rules and asked the debug page to follow them. Rule one: the default style is minimal and carries the affordances — grey nodes and edges, blue on selection, the active-overlay press wash. That has been v4's default sheet since 57.1, verified live this round (#999, #0169D9, overlay 0.25) — the work was everything sitting on top of it. Rule two: a custom sheet must not bury those affordances.

    What changed, all in debug/:

    • The 'plain' style kind is now 'default', and it is an empty sheet. The old hand-written "plain" sheet (small blue discs) was itself a style, so it demonstrated neither the defaults nor the renderer alone. The dropdown says "Default (v4's default stylesheet)"; the legacy ?style=plain URL still resolves.
    • The four v3 demo ports are v3-minimal. v3's own demo sheets style nothing but the demoed feature — node-types is shape: data(type) + a label + a 40px body; edge-types is the curve families and width: 3 (10 for straight-triangle, v3's own value); edge-arrows is the head mappers on a 16px body; labels styles only label channels. The v4 ports had grown blue-grey house colours, borders and fonts on all four; those are gone, so every demo now shows default grey with working selection blue.
    • Every production sheet re-states selection. Selection blue is a default-sheet conditional, so a sheet naming a colour channel replaces it — v3's own precedence. Constant colours wrap in selectable() (a { selected } case over the constant); case mappers prepend the selected clause via withSelected(); and scale-mapped colours — where a then cannot nest a mapper — route selection through another channel: EnrichmentMap fills its reserved 12px border (the web app's own affordance), NDEx-large brightens its border, and the 465k-edge scene gives its diverging-coloured edges a blue selected underlay.
    • edge-types lays out in two columns like edge-arrows, band height stretched by the tallest taxi drop so cells cannot collide.

    Two specs enforce the rules in test/modules/debug-harness.mjs: the 'default' kind must be {} for every network (both spellings), and — behavioural, not structural — selecting a node and an edge under every production sheet must change the computed style. Controls: a buried edge (constant line colour and constant opacity) failed em-web; a constant background-color added to node-types failed it; a non-empty default sheet failed the first spec. A control that buried only the line colour did not fail — the opacity conditional still signalled, which is the rule holding, not the spec missing. The page itself was driven (six screenshots, including a box-selection over each half of the rework) per the standing "something has to open the page" rule.

The preventable-gesture proposal, declined

This is the docs-first stage the sixth sitting instructed: map each candidate gesture default to its explicit toggle before proposing any preventDefault() rows, and react to the four-row table the sitting tabled. It is a proposal, not a plan — nothing below is implemented, and the round does not start until the maintainer has reacted to the two flagged calls.

Every code claim in this section was measured against src/interact/pointer.mts on 2026-08-08 rather than taken from earlier records, and the one that matters most is an ordering fact: two of the four candidate defaults already emit their gesture event before the default applies, one emits it after, and that difference is where the design cost lives.

The toggle map (the sitting's precondition)

default instance toggle per-element grain status
grab initiation autoungrabify (+ autolock) ele.ungrabify() / grabbable exists, both grains
tap selection toggle autounselectify ele.unselectify() / selectable exists, both grains
background clear (tap on core) autounselectify coarse only
box apply (boxend) boxSelectionEnabled (+ autounselectify on the select step) selectable filters the set exists

Excluded, per the sitting: pan and wheel-zoom (already option-gated, and high-frequency), taphold (no default to suppress), and the cxt menu suppression (deliberately unconditional).

The one hole in the map is the background clear: it shares autounselectify, which also disables selection itself, so an app that wants tap-select but no background-clear has no toggle for it. The maintainer's rule is satisfied as written — the default is not controllable only through preventDefault() — but the toggle is coarser than the event. Recommendation: add no new option. The want is per-tap in practice (a click that a UI layer already handled), which is exactly what preventDefault() on the core tap gives; a backgroundClearEnabled option can always be added later without breaking anything, where adding it now is surface on speculation.

The four rows, reacted to

  1. tap on an element → the selection toggle — implementable as emitted today: tap() emits first and applies selection after (pointer.mts, the tap helper), so the layer reads isDefaultPrevented between the two. Prevention suppresses the whole toggle step (select, unselect-toggle, and the non-additive clearing of others), so tapselect/tapunselect do not fire for a prevented tap.
  2. tap on the core → the background clear — same shape, same helper, implementable as emitted today.
  3. boxend → applying the box's selection — implementable as emitted today in both box paths (mouse boxEnd and the three-finger applyTouchBox), each of which emits boxend before applying. Prevention suppresses the whole apply step — the non-additive unselect of outsiders and the select — not just the select, or a prevented box would still silently deselect the rest of the graph. The per-element box events still fire (they report the geometry, not the default); boxselect does not.
  4. tapstart on an element → grab initiationnot implementable as emitted today, and this is the call. The press handler sets FLAG_GRABBED and emits grabon/grab before it emits pointerdown/tapstart, so the flag is decided before any handler could set it. Two honest options:
    • (a) Reorder the press emitspointerdown/tapstart first, then the grab block reads the flag. This changes the observable emit order of every press on a grabbable node, which is a parity-relevant ordering (v3's order is a claim to measure before choosing, not to assume).
    • (b) Drop the row — grab initiation keeps its two toggle grains (autoungrabify, ele.ungrabify()), which are the richest of any row, and preventDefault() on tapstart keeps suppressing only the browser default. Recommendation: (b) for this round, (a) logged as its own later call. The row buys per-press grab suppression at the cost of an emit-order change whose blast radius is every press; the toggle story for grab is already the best of the four, and nothing else in the proposal depends on the reorder.

Bubbled prevention rides round 14.5's machinery as the sitting said: compound phases share one Event object (origin → ancestors → core), so a preventDefault() in any phase is visible when the walk returns, and the specs must prove it from a core-phase handler as well as an origin handler.

Implementation notes (for the round, once called)

  • The emit paths involved return void today and the 34.3 no-listener gate skips the emit entirely; the implementation needs the built Event (or its flag) back from the emit call. The no-listener fast path is untouched — with no handler the flag cannot be set, so the default applies unguarded exactly as now.
  • Each row's spec proves both directions (prevented → suppressed; unprevented → the default runs and its follow-on events fire), plus the bubbled case, per the sitting.
  • Docs land with the round: the Event.preventDefault doc comment (which currently — and correctly — says the gesture half is inert), MIGRATING.md's event section, and ledger item 12.

The reaction (2026-08-09, seventh design sitting) — the round does not run

The maintainer declined the whole table, not only row 4: explicit toggles are the entire gesture-control story, and preventDefault() is browser-level only, permanently. The toggle map above stands as the record of why that is sufficient — every candidate default has a toggle, three of them at both grains — and the background-clear recommendation (no new option) is subsumed: with no rows built, the coarse-only grain is simply the contract, as it is in the toggle column. Nothing above the toggle map is implemented, no emit reorder is logged, and ledger item 12 is closed. This section stays as the measured proposal the decision was made against.

The arrow trim reaches the anchors and the layer strokes

Scheduled at the seventh sitting, after the maintainer asked what it would cost (answer: negligible — see item 24's ledger entry). Round 56 landed the trim where the ink is drawn and left three consumers reading untrimmed geometry, each blocked by a vertex stage at the 8-storage-buffer budget or simply unimplemented. Scoping this round against the code found the defect family is wider than item 24's two entries, and gave the fix one rule:

Ink hugs the draw trim; anchors sit at the gap trim. The drawn line uses arrowDrawTrimW — v3's gap, extended to the head's axial depth for hollow/translucent heads, reproducing v3's destination-out erase. The accessors (midpoint(), the endpoints) use v3's plain gap/spacing via arrowTrimAt. So: layer strokes (overlay / underlay / casing) take the draw trim — v3 strokes its casing along the shortened path and its erase then reaches the layers too — while label anchors and mid-arrow anchors take the gap trim, because they must land exactly where midpoint() answers, which is v3's rs.mid built from the gap-shortened allpts. (Measured 2026-08-09: calculateEndProjection walks rs.allpts, whose first point is rs.startX/Y = the boundary intersect shortened by gapedge-endpoints.mts:327-334 — so end labels walk gap-trimmed geometry in v3 too.)

The binding math, measured per stage:

  • Straight stream (EDGE_SHADER): one bind group layout serves the scene, pick and layer entry points, and edge.width is already in it at vertex visibility — vsEdgeLayer just never reads it. No layout change at all; the layer VS today runs node centre to node centre, not even stopping at the boundary.
  • Curved layer VS (vsCurvedLayer): its own layout, at 8 exactly — endpoints, nodePositions, nodeOuterHalf, nodeShapes, curveParams, curveBlob, edgeLayer + the visible list — with no slot for edge.width.
  • Edge label VS (vsLabel, edge variant): at 8 exactly — glyphs, endpoints, nodePositions, curveParams, nodeOuterHalf, nodeShapes, curveBlob + the visible list.
  • Straight mid arrows (vsMidArrow): binds everything it needs already; anchoring at the centre-chord midpoint instead of v3's four-point mean is unimplemented math, not a binding.

Both starved stages bind node.outerHalf and node.shape, which is the freed slot: a derived node.outerGeom column fuses them.

Items

  • 58.1 node.outerGeom — Float32Array(4·cap), derived: [ outerHalf.x, outerHalf.y, shapeId, 0 ]. Contract first (contract.mts owns the layout). Written by updateOuterHalf (which already follows every size/border write, and geometry tweens are CPU-per-tick through the same cascade, so the column is tween-correct for free) and by the node.shape branch of setScalar. Shape ids are ≤ 26, so f32(shape) is exact. Nothing reads it on the CPU — like edge.width lane 1 it exists for the vertex stages — and it holds no slot references, so compaction moves it like any other column. A modules spec pins it in lockstep with its two sources across add, restyle, size writes and compaction, with the control run (drop the shape hook) confirming the spec can fail. +16 B/node CPU and GPU.
  • 58.2 The straight streamvsEdgeLayer resolves boundary points and applies the draw trim exactly as vsEdge does (including the crossing-collapse clamp and the haystack/triangle exclusions); vsMidArrow anchors at v3's four-point mean (lineStart + lineEnd + arrowStart + arrowEnd) / 4 — the CPU midpoint() formula — instead of the centre chord. No binding changes.
  • 58.3 The curved layer VS — its layout swaps nodeOuterHalf + nodeShapes for outerGeom, spends the freed slot on edge.width, and passes arrowTrimOf to the evaluators where it passes vec4f(0.0) today.
  • 58.4 The edge label VS — same swap, same freed slot. A new generated arrowGapTrimOf (the plain-gap twin of arrowTrimOf, mirroring CPU arrowTrimAt) feeds every evaluator call that passes vec4f(0.0) today, the straight mid-label fallback becomes the four-point mean, and the straight end-label walk starts from the gap-trimmed chord (v3's allpts). The two curved mid-arrow call sites switch arrowTrimOfarrowGapTrimOf for the same reason — a mid arrow is an anchor, not ink, and with a hollow end head the draw trim would seat it off midpoint().
  • 58.5 Verification — the lockstep spec (58.1); a live v3-vs-v4 close-up parity scene per fixed consumer, built to expose the fix (labels on short arrowed edges with asymmetric heads; casing/overlay on arrowed edges with hollow heads, where the untrimmed stroke shows through), each run once with the fix disabled to prove it discriminates; goldens regenerated for scenes the anchors move, each diff inspected before committing; the round-56 "untrimmed" comments in shaders.mts and both documents' deviation notes swept.

Deliberately out of scope: hollow mid arrows (item 21, punted by the sixth sitting) and the arrow-scale quantization (item 23, deferred — this round spends no reserved bits, exactly like round 56).

Landed (round 58) — 2026-08-09

All four items shipped as planned, in two commits (58.1 the store column; 58.2–58.4 the renderer, whose pieces cannot land separately), plus the closing docs commit. The suites at the close: 2090 Node + 304 module tests, throw gate clean, renderer 116, visual 121 — the two new close-up scenes included, goldens exact.

What the verification measured. Both new parity scenes read 0.000% — pixel-exact against v3 — with the fix in, and fail hard with it removed: parity-closeup-layers 1.535% (7.7x its 0.2% bound) with the vsEdgeLayer branch deleted, parity-closeup-midarrow 5.580% (27.9x) with the centre chord restored. The label property spec pins the rendered ink centroid to renderedMidpoint() on a straight edge whose asymmetric head shifts the four-point midpoint 16 rendered px off the centre chord (precondition-asserted, so the spec cannot be satisfied by the untrimmed anchor), and on an unbundled bezier; its control fails it. One golden moved — edge-layers, only at the straight row's two ends, where the strokes now stop at the boundary instead of running under the node discs — inspected and regenerated.

What the round found beyond its plan, in the order found:

  • The stale-bundle trap caught this round's own controls. The first control run flipped the shaders off and re-ran the scenes without rebuilding — both stayed green at 0.000%, because npx playwright test invoked directly exercises whatever bundle is on disk. AGENTS.md warns exactly this; the scene comment now records the instance. Every control number above is from a rebuilt bundle.
  • A parity scene's first two drafts each measured a real thing that was not the trim — the round's own instance of "ask what is painted over it". Draft one (big heads) read 3.160%, all of it the recorded erase-overlap compositing deviation where two huge heads meet mid-span. Draft two (small heads, underlay + casing) read 7.105%, and the diff showed band-width, not trim: v3's edge underlay/overlay stroke is 2 × padding wide where v4's is width + 2 × padding — a divergence nothing had measured, logged as ledger item 27 rather than silently patched. The shipped scene is casing-only, because line-outline is the one layer whose width formula the two libraries share; it still exercises the same layer entry points the underlay draws through.
  • The round-37.1 throw gate fired correctly on 58.1's store insertion shifting the SHAPE_MASK invariant off its file:line key — re-keyed 2888 → 2906, the maintained-allowlist flow working as designed, in its second live firing.

Cost, as predicted: +16 B/node CPU and GPU for the fused column, no new per-frame CPU work, no new draw calls; the trim math runs in two more vertex stages over the smallest streams the renderer draws. The curved end arrows deliberately keep arrowTrimOf — their tips sit at spacing points that are identical under both trim functions, and their evaluator inputs stay byte-identical with round 56's.

The force layout, made sound and made good

The maintainer's ask, verbatim in scope: the round-18 force layout "is a good start, but it gives bad results for large networks at least" — it "drifts apart too much on large networks making everything super zoomed out on fit (invisible basically)" — with direction to research what the field does (COSE, fCoSE, other force-directed libraries, GPU ones like AntV G6 in particular), to want good compound support, to verify visually as the work goes, and to write automated tests.

What measurement says the defect is (2026-08-09, before any design)

The reported symptom reproduces on the first real fixture tried, and it is not a tuning problem — the integrator is unstable and the model diverges exponentially on any graph with hubs or high mean degree:

  • Driving debug/?network=em-web&layout=force&seed=1 (569 nodes, 6899 edges, mean degree 24) with a scripted browser ends at a bounding box of 3.0e11 x 3.6e11 px and a fit zoom of 2.3e-9 — a blank canvas with the whole graph in one sub-pixel dot, which is exactly the report. em-web-clustered (the compound twin, so the CPU executor) is worse: 571 of 610 positions are NaN at layoutstop.
  • The CPU reference sim, traced per iteration on synthetic topologies: a 500-leaf star, a 5k scale-free (max degree 1092) and a 20k x 465k uniform graph (ndex-x-large's density, mean degree 46) each grow their max displacement by one to two orders of magnitude per iteration from iteration 1, reaching 1e30+ px and then NaN. Low- degree graphs (mean degree 4) survive but land badly: mean edge length settles at 3.3–6.6x the ideal and a curled hairball.
  • NaN reads as convergence. maxDisp starts at 0 and NaN > maxDisp is false, so a fully-NaN iteration reports displacement 0 and the settle counter runs out — the sim exits "converged" with every position destroyed.

The mechanism is textbook explicit-Euler instability: a node of degree k feels total spring stiffness k · stiffness, and pos += F · alpha is stable only while alpha · stiffness · k < 2 — so with the shipped stiffness: 0.1 every node past degree ~20 overshoots its equilibrium and oscillates with growing amplitude. The ndex fixtures sit at mean degree 47. The sim has neither of the two guards every shipping force layout carries: v3's own cose caps per-node displacement at an annealed temperature (limitForce, 1000 · 0.99^t), and d3's link force divides spring strength by min(degree) and splits the correction by a degree bias, precisely so hubs cannot destabilise.

Two structural quality limits sit underneath the instability, both already recorded in round 18's own notes: the repulsion cutoff at one mean edge length means no long-range force exists (Fruchterman & Reingold's own grid variant, which this is, kept its layout inside a bounded frame with wall clamping; round 18 removed the walls and kept the truncation — Hu 2005 names the consequence: "no force to evenly distribute far away vertices", no cluster separation, curled chains as legitimate minima), and gravity is the only thing containing disconnected components, which every surveyed ecosystem solves with component packing instead (v3 cose: separateComponents, 40 px shelf rows; cose-bilkent/fcose: tiling + polyomino packing; AntV, which has none, interleaves its components — the cautionary tale).

What the research found (2026-08-09; two web sweeps)

Condensed to what this round acts on (primary sources, all verified against papers or shipping source rather than docs: Jacomy et al. 2014 PLoS ONE + Gephi's ForceFactory.java for FA2; the cosmos.gl repo's ForceManyBody shaders for the grid pyramid; Hu 2005 for sfdp's p = 2 and the grid-variant critique; Fruchterman & Reingold 1991; Balci & Dogrusoz IEEE TVCG 2021 + the cytoscape.js-fcose / cose-base / layout-base sources for the spectral phase and the compound recipe; d3-force's link.js/simulation.js for the spring rule and alpha schedule; this repo's own v3/src/extensions/layout/cose.mts for limitForce, per-graph gravity and separateComponents; Zheng et al. TVCG 2019 (s_gd2), Zhong et al. TVCG 2023 (t-FDP), Godiyal et al. GD 2008 and cuGraph FA2 as the surveyed alternatives not taken):

  1. Stability discipline — d3-force: per-edge spring strength stiffness / min(deg(s), deg(t)), applied to each end weighted by bias_i = deg(other) / (deg(i) + deg(other)), which bounds any node's aggregate per-tick spring correction at stiffness — a contraction for stiffness ≤ 1 regardless of topology. v3 cose / FR: per-node displacement capped at an annealed temperature. Both port directly onto the existing gather/apply split.
  2. Long-range repulsion on GPU without pointer trees — the shipped web answer is cosmos.gl v3's uniform grid pyramid: mip-reduce the binning grid to per-cell monopoles (count, sum x, sum y), then per node gather the exact near field from the finest 3x3 plus, per coarser level, the aligned 6x6 block refining the parent's 3x3 minus this level's own 3x3 — every region counted exactly once, fixed traversal order (deterministic), every phase an ordinary dispatch. cosmos moved off a texture-baked quadtree onto this. Hu/sfdp's p = 2 repulsion exponent (force ~ 1/d^2) is the default chosen to limit peripheral over-spread; Hu also measured full long-range repulsion at ~21% over the grid cutoff's cost.
  3. Global untangling comes from the seed, not from more iterations — fCoSE's headline: a spectral draft via landmark MDS (BFS hop distances from ≤ 25 farthest-first pivots, squared, double-centred, top-2 eigenpairs by power iteration), then the force phase runs as an incremental polish at reduced initial energy. "Up to 2x as fast as CoSE with similar aesthetics" is their measured claim; the quality win on trees/meshes is the part this round wants.
  4. Containment — ForceAtlas2's gravity is constant-magnitude (kg · mass, never decaying), so escape is impossible against any decaying repulsion; d3's answer for disjoint graphs is forceX/Y (weak springs), never forceCenter; and the final drawn size is legitimately a normalization question (FR rescaled the finished layout into its frame) as much as a physics one.
  5. Compounds — the whole Bilkent line: children-only integration with parents as derived boxes (v4's round-14 rule already), gravity per compound toward the owner's centre (v3 cose: constant magnitude toward the parent's position; layout-base: dead-zone linear), and ideal edge length elevated per nesting level an edge spans (nestingFactor). AntV's one good idea is the same thing spelled as cluster-centroid gravity, recomputed per iteration.
  6. What not to copy — AntV's GPU layouts themselves (O(n^2) texel-per-node WebGL1 GPGPU, no early termination, no packing, the WGSL path shipped empty), and FA2's swinging/traction adaptive speed for now: it is the best integrator surveyed, but its global per-iteration reduction breaks the alpha-window batching that lets the GPU encode k iterations per submit with no readback — logged as a future direction rather than taken.

A design prototype (scratch, outside the tree) validated the composition before this plan was written: degree-normalised springs + a displacement cap + component anchors run the star, the scale-free 5k, the 20k x 465k and the 489-component 25k cases to zero NaN and bounded boxes; the spectral seed improves the tree's mean edge length where the cutoff model alone cannot; and the one blow-up the prototype hit was a degenerate spectral eigenvalue on a many-component graph — which is why the plan below clamps every component's embedding to its own radius estimate rather than trusting the spectrum.

Design calls

  1. The model becomes stable by construction, not by tuning. Springs take d3's rule verbatim (strength stiffness/min(deg), degree-bias split, stiffness re-read as the dimensionless fraction-of-residual corrected per tick, default well under 1); the apply pass caps per-node displacement at an alpha-annealed multiple of the repulsion range (v3's limitForce shape); and convergence treats a non-finite displacement as not settled (the NaN-reads- as-converged hole closes even though the cap makes it unreachable).
  2. Repulsion gains a far field from a pyramid over the existing grid (research item 2), with one smooth pairwise law across near and far: force repulsion · (L/d)^2 per pair (sfdp's p = 2 shape; repulsion re-read as the push in px/tick at exactly one edge length), near field exact over the finest 3x3, far field from the per-level monopoles under the 6x6-minus-3x3 refinement scheme. The cutoff falloff (1 - r/cutoff)^2 and its "repulsion vanishes at the rest length" rationale are superseded — the balance point now comes from the force law itself. Both executors implement the identical pyramid; the GPU folds cellStart/cellItems/pyramid into one grid buffer, which is also what frees the binding the anchors need.
  3. Placement is component-aware from the start. Components are computed once at build (union-find over the sim edges); their anchors are shelf-packed by estimated radius (largest first, v3's row shape); each node seeds around its component's anchor; gravity becomes constant-magnitude toward the component anchor (FA2's containment against a decaying repulsion), so disconnected pieces neither interleave nor drift; and a final exact re-pack of the real component boxes (v3's separateComponents, componentSpacing default 40) lands at settle on both executors.
  4. The seed is spectral where it can be (research item 3): landmark MDS per component of ≥ 4 nodes — BFS hop distances from up to 25 farthest-first pivots, off the incident lists the sim already builds — with every guard measured in the prototype: eigenvalue floors, embedding clamped to the component's radius estimate, and a deterministic jitter so coincident embeddings (a star's leaves) separate. randomize: false keeps meaning "relax the current positions"; a new init: 'spectral' | 'scatter' option (default spectral) governs what a fresh placement is, so specs and controls can still pin the scatter path.
  5. Compounds get the Bilkent recipe's cheap two-thirds on the CPU executor (which already owns compound graphs by the 14.11 lease rule): per-iteration gravity toward the owner compound's live centroid (gravityCompound, constant-magnitude beyond a dead zone), and ideal edge length elevated once at build for edges spanning compound boundaries (nestingFactor per spanned level). Same-owner repulsion partitioning (Bilkent's third leg) is deliberately out: it exists there because compounds repel as rectangles, which v4 does not simulate, and owner gravity plus real repulsion already keeps siblings coherent — measured on the clustered fixture before this call is final.
  6. The GPU executor stays in lockstep, kernel for kernel — same spring rule (degrees are already in the CSR starts), same cap (alpha is already in the meta window), same pyramid, same anchors (appended to an existing buffer or the freed slot; the force kernel stays at exactly 8 storage bindings). Trajectories stay not bit-agreed (the recorded scatter-order caveat); the 18.4 invariant suite widens to a hub-heavy graph that the old model detonates on.
  7. Defaults are retuned by measurement, and the acceptance is numeric: zero non-finite positions on every probe topology; mean edge length within a small factor of ideal on trees/rings/meshes; bounding box within a small factor of the hex-packed ideal radius; the em-web fit zoom back in a human range. The probe suite from this scoping (star, scale-free, dense-uniform, tree, multi- component) becomes specs.

Public-surface changes, flagged: init, nestingFactor, gravityCompound and componentSpacing are new options; repulsion/stiffness/gravity keep their names but their units and defaults change (documented; the prerelease line has no published consumers). edgeLength, seed, randomize, animate, fit, padding, stepsPerFrame, iterations, threshold, decay are unchanged.

Pass split (tests-first; docs in-commit; each pass its own commit)

  • 59.0 Docs-first (2026-08-09) — this section; the README force-layout section gained the in-flight pointer (replaced by the rewritten section when the round closed).
  • 59.1 Stability (2026-08-09) — landed as planned, tests-first: five specs (star 300, scale-free 1500 with max degree in the hundreds, uniform 600 x 12k at mean degree 40, the NaN-never-settles pin, and a hub-spring quality bound) written red against the shipped sim — all five failed, three by explosion and one by the NaN-reads-as-converged hole stopping a poisoned run at iteration 3. The CPU sim gains the d3 spring rule (stiffness / min(deg) with the degree-bias split; stiffness re-read as fraction-of-residual, provisionally 0.6), the alpha-annealed displacement cap (cutoff · max(alpha, 0.15), clamped at the gather output so both executors share it verbatim), and the non-finite settle guard. The force kernel takes the identical spring rule reading degrees off the CSR starts it already binds — zero new data crossed to the device — and the identical cap; the device side needed no non-finite guard, and the reason is recorded in the kernel: a NaN displacement bitcasts above every finite float in the monotonic-bits ordering, so a destroyed iteration reads as a huge displacement and never settles. The 18.4 invariant spec's graph gained a degree-60 hub; run as its own control against the pre-fix bundle it fails (the GPU executor detonates on the hub), and passes with the fix — with all 20 Node force specs and the three GPU force specs green against a fresh build.
  • 59.2 Components + gravity (2026-08-09) — landed as planned. src/layout/force-init.mts is the new pure module (union-find in first-seen order, the disc-radius estimate, shelf packing in v3 separateComponents' row shape for both the up-front anchors and the exact settle re-pack, and the anchor-relative scatter), specced in test/modules/force-init.mjs including the non-overlap guarantee, determinism, and that the re-pack is translation-only with the packed field centred where the largest component sat — the dominant structure holds its place and the strays come to it. Gravity became the constant-magnitude anchor pull on both executors (gravity re-read as px/tick, provisionally 1); the anchors reach the force kernel through the csr buffer's tail (bitcast f32 at params.anchorBase, the uniform's former pad slot), so the kernel stays at exactly its 8 storage bindings and no ninth crosses the budget. randomize: false anchors each component at its own current centroid — incremental runs relax in place rather than being dragged to a fresh field. Two public specs, the separation one written red first (the round-18 model interleaved the two rings): disconnected rings settle into disjoint boxes with strays contained, and the settle re-pack is skipped whenever anything is pinned, since a re-pack translates whole components and a locked node must never move (recorded scope note, pinned by spec). 28 Node force specs + 3 GPU force specs green against a fresh build.
  • 59.3 The far field (2026-08-09) — landed on both executors: one inverse-square law across all pairs (repulsion · (cutoff/d)², sfdp's p = 2; repulsion re-read as the push at one cutoff length, provisionally 1), the near field exact over the finest 3×3, and the far field from a monopole pyramid over the binning grid — per level, the aligned 6×6 block refining the parent's 3×3 minus the level's own 3×3, cosmos.gl v3's shipped scheme. On the GPU, cellStart, cellItems and the pyramid fold into one grid buffer, so the force kernel lands at 7 storage bindings with the far field in; two new kernels (per-cell aggregate — write-only, so no clear pass — and a per-level reduce driven by per-level uniforms built once, since the frame is fixed per run). The law choice was swept in a scratch prototype first, whose decisive probe is now a spec: a 12×12 mesh seeded at its true grid positions keeps its links near ideal — a model that destroys a good layout cannot be saved by any seed. Three controls, and two of them were findings. The CPU pyramid-vs-exact spec (one pure-repulsion iteration against the exact O(n²) sum — median error < 10%) fails when the level-0 ring is skipped, which is precisely the coverage bug the prototype's first draft had. The 18.4 invariant spec failed to fail with the WGSL far field neutered — it does not discriminate the far field — so a dedicated GPU spec now does (an unconnected pair five cutoffs apart, gravity off, moves strictly apart on the GPU executor), and its first draft measured the wrong thing too: the 59.2 settle re-pack placed the two singleton components exactly componentSpacing + 1 apart, reading as motion. A pinned out-of-frame node (the re-pack's own skip rule) isolates the physics; with the WGSL loop neutered the spec fails, restored it passes. 31 Node force specs + 4 GPU force specs green.
  • 59.4 The spectral seed (2026-08-09) — landed: spectralSeed in force-init.mts (landmark MDS per component of ≥ 4 nodes — BFS hop distances from up to 25 farthest-first pivots at fCoSE's 1.5·L separation, squared, double-centred, top-2 eigenpairs by hash-seeded power iteration, LMDS out-of-sample embedding), wired as the default fresh placement with init: 'spectral' | 'scatter' selecting it (unknown values throw; randomize: false ignores it). The scatter also went uniform-in- area (sqrt(u) radial draw) — the uniform-in-radius draw piled density at the centre, and the seed's density is what the anneal budget mostly preserves. The guards were where the work was: the first implementation threw away a path's embedding, because a path's metric is legitimately rank-1 (λ₂ = 0) and the degenerate-spectrum guard read that as failure — a rank-1 spectrum now embeds on the first axis with the jitter as the second dimension, a dead λ₁ keeps the scatter, and a spectrum blowup rescales to the component's own hop-diameter extent (clamping to the disc estimate would have destroyed exactly the long thin layouts the seed exists to produce). K-complete graphs (every eigenvalue equal) pin the finite-and-bounded case. Measured discrimination on the public path: a 40-node chain ends 3208 px end-to-end under the spectral seed against 346 px under init: 'scatter', with the spec's bound at 936 — the seed reaches the configuration no amount of local refinement finds, which is fCoSE's headline reproduced. 11 module + 11 layout specs green.
  • 59.5 Compounds (2026-08-09) — landed, the Bilkent recipe's cheap two-thirds on the CPU executor (compound graphs never take the GPU path — the 14.11 lease rule): owner-centroid gravity (each leaf pulls constant-magnitude toward its direct parent's live centroid, recomputed per iteration in ascending order — gravityCompound, a multiple of gravity, default 1.5) and nestingFactor (an edge spanning compound boundaries takes length × levels × nestingFactor, v3 cose's multiplicative rule, levels counted below the lowest common ancestor via the parent chains; default 1.2). Same-owner repulsion partitioning — the recipe's third leg — stays out as planned: owner gravity plus real repulsion coheres siblings without it, and v4 does not simulate parents as rectangles. Direct-parent centroids only; deeply nested coherence rides the nesting-elevated edges (recorded limit). The cohesion spec's first bound could not fail, which is the round-27 lesson arriving on schedule: rms-spread < 0.75 × centroid separation passed a plain ring with no compound gravity at all. Measured both ways — 0.599 without the pull, 0.421 with it — the bound is 0.5, and the control (groups disabled) fails it. The nesting spec was red-first (cross-clique edge settles > 1.5× the intra mean). 40 Node force specs green.
  • 59.6 Retune + acceptance (2026-08-09) — the provisional defaults survived the probe matrix and are final: repulsion 1 (px/tick at one cutoff length), stiffness 0.6 (fraction-of-residual), gravity 1 (px/tick constant), with decay/threshold/iterations unchanged from round 18. A tuning sweep moved quality the wrong way — raising repulsion inflates tangled links faster than it opens spacing, and lowering gravity buys no measured link quality — so the sweep's output is the confirmation, not new constants. The acceptance, measured through the public API (seed 1, defaults):
    probe nan bb bb/ideal link/L
    gnm 2k×4k 0 2295×1734 1.26 3.68
    scale-free 5k 0 1848×1816 0.64 4.74
    tree 4095 0 3403×3127 1.31 2.22
    star 500 0 395×397 0.44 2.22
    gnm 1k×23k (dense) 0 659×662 0.52 4.04
    Every probe finite and inside ~1.3× the hex-packed ideal radius,
    against the round-18 baseline of 10³⁰-px explosions and NaN on
    three of the five. The expander link ratios (3.7–4.7×L) are
    topology-intrinsic — no planar embedding of a random graph has
    short edges — and the tree's 2.22 is the number future quality
    work (multilevel, the logged direction) would move.
  • 59.7 Verification + benchmarks (2026-08-09) — the round's before/after, measured in the browser on the scenes the report named:
    scene before (round 18) after (round 59)
    em-web (569n/6.9k e, GPU) bb 3.0e11×3.6e11, zoom 2.3e-9 bb 1774×1486, zoom 0.44, link 2.1×L
    em-web-clustered (compound, CPU) 571/610 NaN, zoom 1e-50 bb 1956×1543, zoom 0.40, 0 NaN
    gen 2k×4k (GPU) zoom 0.36, link 6.5×L zoom 0.52, link 3.8×L
    ndex-x-large (19.6k×465k, GPU) explodes (mean degree 47) zoom 0.76, converges live in 1.3 s
    The screenshots show what the numbers cannot: em-web's clusters
    separated with its small components shelf-packed in rows beneath,
    and the clustered fixture's 41 MCODE compounds cohering inside
    their parent boxes. Full verification: the entire Node tier
    (typecheck, 2104 + 315 + 24 specs, throw gate green at
    184/10/5/0 over 199 sites — the round's own init throw specced —
    lint, format), 238 browser specs across renderer + visual
    with every golden exact and every parity scene at its recorded
    value, test:types:all clean (the declaration regen also picked up
    round 58's node.outerGeom doc, which had never been regenerated —
    a pre-existing staleness this round's regen caught). Benchmarks:
    the layouts.mjs force row now measures a complete run at
    115 ms/2k — the one-time spectral seed (~12 ms warm at 2k)
    dominates the 20-iteration row, per-iteration cost is 1–4 ms — and
    render-bench --layout on ndex reads 1308 ms to converge against
    the round-36.5 era's 759–952 ms (both nine-iteration runs — the GPU
    readback defect round 116.1 found; round 117 has the converged
    figures), the far field's pyramid dispatches
    plus a model doing real work instead of detonating into a clamped
    frame (both inside the ±25% read that section carries).
    MIGRATING.md's cose paragraph now names the cose-alike options
    (gravityCompound, nestingFactor, componentSpacing, the
    spectral seed) and CHANGELOG.md's force line describes the round-59
    model; the README's force section is rewritten wholesale.

Risks tracked

The model changes under specs that pin ranges, not values — the ranges were chosen for that in round 18, and the determinism specs compare run-to-run, so they survive; any spec that turns out to pin the old balance is rewritten with the round that changes it, not loosened. The GPU force kernel is the one place the 8-storage-buffer budget has to be re-fitted (the grid fold), which is round 58's freed-binding lesson applied to compute. The spectral seed is the largest new surface and degrades safely by construction (a clamped embedding is never worse than the scatter it replaces). The far field raises per-iteration cost by a measured factor (Hu's figure is ~1.2x; ours is measured in 59.7 before the round closes). And separateComponents at settle is a visible jump under animate: true — v3 has the same jump, and the anchors keep it small; recorded rather than hidden.

The performance record, kept honest

The maintainer's ask, in three parts: more performance-related work; a way in the status page to compare performance across commits, so progression or regression is visible rather than remembered; and better coverage on both the benchmark and the test side. No library semantics change in this round — it is measurement infrastructure, measurement coverage, and the specs that keep both honest.

The premise is one this file already carries: the archive (benchmark/published/) keeps every published run's full results JSON precisely so that "a report improvement applies to every past run" — and until this round nothing joined two runs. The only cross-commit signal on the site was one geo-mean per run in the trend table, which is a single number over the run's ~109 v3-vs-v4 pairs: a 2× regression in one row moves it by ln 2 / 109 ≈ 0.6%. Detecting a regression was a manual diff nobody had ever run.

Items

  • 60.1 The cross-commit comparison (2026-08-09) — landed: benchmark/report-compare.mjs (pure, Node-testable — the report-html.mjs pattern) joins the published runs of one (machine, profile) into per-row p50 series and renders a self-contained comparison page; scripts/status/bench-pages.mjs plans one such page for every (machine, profile) with ≥ 2 runs and links it from the trend table ("Across commits").

    Three judgements carry the design, each with its reason recorded in the module header:

    • Runs from different machines never share a pagebuildComparison throws on mixed fingerprints rather than producing what the archive's README warns about (a hardware history wearing a performance history's clothes). Unfingerprinted pre-46.5 runs get no comparison at all.
    • A missing row renders as a gap, never dropped. Runs with a --suite filter measure a subset, so the join is sparse; a row's change bridges the gap to the nearest earlier run that measured it, and the page discloses each run's filter. Silent truncation is the failure mode this repo's benchmark rules name most often.
    • Every mover carries its noise controls. The movers lists (beyond ±10%, strong past ±30%) cover v4-side benches only — v3 is frozen code, so a "v3 regression" is the machine — and each mover prints its v3 twin's change over the same span as a per-row control, with the page's headline drift figure (geometric mean change over every shared row, v3 included) as the whole-run control. A mover near the drift factor is the box, not the commit.

    Verified against the archive's own two renderer runs, which is the strongest check available: the computed drift is ×1.014 where round 52.2 independently measured the same pair at a 1.018 geometric-mean ratio, and the top movers are exactly the sub-millisecond compaction device rows whose ±40% bimodality rounds 29.5 and 52.2 already recorded — appearing in both directions (×2.53 and ×0.40 on the same row family), which is what noise looks like and what the drift line exists to say.

    15 specs in test/modules/benchmark-compare.mjs, five controls run and each failing the spec named for it (the header lists them): change inverted to prev/latest — 4 fail; the gap bridge removed — 2 fail; v3 rows admitted into the movers — 1 fails; the fingerprint guard removed — 1 fails; planComparisons merging profiles — 1 fails. Comparison pages are grouped by exact profile, deliberately: renderer and the Node-tier profiles measure disjoint suites, so a joined page would be mostly gaps presented as history.

  • 60.2 Benchmark coverage for the unpriced recent rounds (2026-08-09) — node scripts/bench-coverage.mjs --verbose reads 83.5%, and its missing names are mostly the audit's documented error direction (internals reached through benchmarked public paths — the Viewport methods behind cy.pan(), the StyleEngine members behind cy.style()). The real gaps were the recent rounds nothing priced, and each new row is in the suite that owns its surface:

    • The 57.1d state-condition partition (style.mjs, three rows). applyAll under a fully state-conditioned sheet against the constant sheet it claims to match (~1×, the claim), against a fully data-conditioned sheet (the per-element path the partition avoids — 1.18× at N=2000), and what a select now costs: a 256-band select+unselect is 9 µs under a constant sheet (round 4's skip) and 989 µs under the state-conditioned sheet — ~110×, the restyle every selection pays once a sheet conditions on state, which is v4's own default look. A re-runnable price, not a defect: it is the per-slot channel rewrite, and the constant-sheet row is the escape hatch's number.
    • The round-59 seed split (layouts.mjs). 59.7's "the spectral seed dominates the capped row" was a one-off decomposition; the new row runs the same 20-iteration force with init: 'spectral' vs init: 'scatter' — 111.7 vs 59.9 ms at N=2000, so the landmark-MDS seed is ~52 ms of the row and the figure is re-runnable rather than remembered.
    • The 57.9 hit halo (spatial.mjs). The full miss walk with padPx: 24 (the touch halo) against exact: ~2% — the halo is effectively free on the hover path, with two startup assertions proving the pad is live (a point 8 px outside a boundary must miss exactly and hit padded) so the ≈1× is a finding, not a vacuous row.

    Two rows were caught measuring nothing while being written, which is design call 33.5 earning its keep twice more. The first constant-vs-state row case-mapped two channels of seven and read ~1× — with applyPartitioned disabled outright it still read 1.02×, so the control failed to fail and the sheets now condition every channel (the control then flips the row to 1.07× slower, and restoring the partition flips it back). And the first select band was empty: cy.collection() takes no arguments in v4 and silently ignored the array it was handed — a 256-band selecting in 53 ns, caught because that is not a number a real select can produce. The row builds by union() now and asserts its own length. The spatial probe's first point was inside a neighbour (30 px nodes on a 10 px grid overlap), caught by its own startup assertion. The silent-argument behaviour itself is logged as ledger item 28 rather than patched — it is public surface.

  • 60.3 The tests the tooling was owed (2026-08-09) — two recorded gaps closed in test/modules/status-site.mjs, both about the status site's own machinery rather than the library:

    • executePlan() finally has coverage. Round 53.2 recorded the gap by name ("the half that writes the deployable site has no coverage, and npm run status runs nowhere") and left it open because the spec would have copied 30 MiB of fixtures. It does not have to: the plan is data, so three specs run executePlan over a synthetic five-op plan in a tmpdir — every op kind lands (write writes, jsonmin re-serialises rather than copying, copy is byte-for-byte, omit writes nothing and stays out of the size report), nested output directories are created, and the returned byte counts are the real on-disk sizes, which matters because the 25 MiB cap check downstream reads exactly those numbers.
    • The benchmark index page's judgements are pinned: geoSpeedup is a geometric mean and answers null (not a fake 1×) when a run has no v3/gpu pair; byMachine quarantines unfingerprinted pre-46.5 runs in their own group rather than guessing them into a real machine; the empty archive renders unavailable with the publish command in its reason; and the trend table links the 60.1 comparison page when a machine has two comparable runs.

    Six controls, each failing exactly the spec written for it: jsonmin degraded to a plain copy (2 fail — the minify spec and the byte-count spec, the second being the cap check's number); the mkdirSync dropped (2); omit ops written as empty files (1); geoSpeedup as an arithmetic mean (1); unfingerprinted runs merged into the first machine (1); the comparison link dropped from the trend table (1).

  • 60.4 The first real runs through the comparison — and it found a regression on its first use (2026-08-09). Both profiles were measured fresh at e37d2444 on the archive's machine (2d2ea233, the box otherwise idle) and published: the quick profile (7.1 min, all 8 jobs, clean tree) and the full renderer profile (19.9 min, all 12 scenes — the suite grew from the baselines' 8, which the page shows as new rows; published --allow-dirty because the only dirt was the quick run sitting uncommitted in the archive itself, no source differing from the commit).

    The renderer tier is clean. Drift −0.7% over 290 shared rows against the 2026-08-05/06 baselines — a span containing the round-56 arrow trim, 57.1d's shader changes, round 52's WGSL minification and round 58 — and every mover family appears in both directions across scenes, which is the signature of the families the record already calls noisy: the sub-millisecond compaction device rows (×2.54 up on one scene, −60% on two others — rounds 29.5 and 52.2's bimodality), the compound fit-all pair (+49% with labels, −29% without, same scene), and the init/export rows (+19% labelled against −32% unlabelled on the same 100k scene). The steady-state frame rows are unmoved and wall time holds the vsync floor everywhere.

    The Node tier is not, and the finding is real. Drift reads +8.2%, and decomposing it shows the drift is the regression rather than the box (v3 controls all single-digit): one family moved — everything containing a select. mut-bulk: select + unselect went 47.9 µs → 6.30 ms (×131) with its v3 twin at +6%, so v4 flipped from 38× faster than v3 to 3.3× slower on that row; the single-element round-trip went 106 ns → 4.92 µs (v3: 2.6 µs), and the select-all/explore/drag scenarios follow at ×10/×6.5/×4.5.

    The cause is round 57.1d's designed behaviour arriving in rows that predate it: the suites' bare makeGpu instances run v4's default stylesheet, which now conditions on selected, so dependsOnState is true out of the box and every select/unselect restyles the changed slots — the round-4 skip that produced the 47.9 µs no longer engages for a default-sheet instance. The per-slot cost matches 60.2's deliberate measurement (~1.9 µs/slot). The headroom is measured and directional, logged rather than fixed (a measurement round measures): core.mts's onStateChange routes a flag flip into refreshMapped — the general per-slot mapped-refresh path — while the default sheet's defs are state-only partitioned (57.1d), so a flip could resolve the slot's partition record by mask and write only the channels whose value differs between the two records, the machinery applyPartitioned already owns. That is appetite, not a call: no API moves. Until then, the recorded mitigation is the one 60.2's row already prices — a sheet that does not condition on state keeps the 47.9 µs path, and mutators.mjs/scenarios.mjs/core+collection now measure the default-sheet price, which is the honest configuration for rows named for what an app does out of the box. Closed by round 61 (2026-08-09, the same day) — exactly the routing described: through the built bundle the 256-band select+unselect went 541.9 → 63.5 µs, and the mutators row reads ~8.5× faster than v3 again. See the round-61 record.

    Smaller Node-tier movers, deliberately not acted on: elements() 8.5 → 39 ns, forEach +48%, contains +11%, lock + unlock +63% — nanosecond-to-microsecond rows measured through tsx, where round 34 showed a closure-heavy path measures the transpiler. Each needs a style-bundle.mjs-style re-check against the built bundle before anyone rewrites anything; listed so they are looked at, not so they are believed. Re-checked by round 61.5 (2026-08-09): three of the four are measurement artifacts (elements()/contains noise, forEach order bias), and lock + unlock was real — 57.1d's flag path built a changed-slot array for states no condition watches — fixed the same day. See the 61.5 record.

The select regression: state flips write the diff, not the element

The fix round for what 60.4 measured, taking exactly the headroom that record logged: a state flip routes through refreshMapped — the general per-slot mapped-refresh path, whose write() rewrites every channel of the element — while the default sheet's defs are state-only partitioned (57.1d), so the styling consequence of a one-bit flip is knowable up front: it is the difference between two cached partition records, and for the default sheet that difference is one channel on a node (background-color) and five on an edge (line-color + the four arrow colours), against the ~25 store calls plus the label/image/chart writes a full write() performs per slot.

Re-measured through the built bundle before any change (the round-34 rule; the numbers the round is judged against, N=2000 nodes / 4000 edges on this machine): a 256-band select + unselect is 541.9 µs under the default sheet against 15.5 µs under a constant sheet (×35 — the published suite's ×131 is tsx-inflated, as 60.4 itself predicted for the smaller movers), and the single-element round-trip 3.4 µs against 1.5.

Design calls

  1. A dedicated StyleEngine.refreshState( group, key, slots ), wired from core.onStateChange in place of the refreshMapped call. It knows something refreshMapped cannot: the old flag word is the new word with one known bit flipped, so per slot it has both partition keys (to = flags & mask, from = to ^ bit) and both records are in part.records. onReparented keeps using refreshMapped — its two pseudo-keys flip together, and reparents are rare structural ops.
  2. The record diff is computed per (from, to) pair and cached on the def, never per slot: for each of the def's mappers, evaluate the case program against both flag words (the partitionRecord reader, which is what makes the records per-combination in the first place) and compare. The cache lives beside part.records and dies with the def on sheet swap, the same lifetime rule; its size is bounded by pairs over 2^(bits the sheet reads), which is single digits in practice.
  3. Narrow writes only where they are provably safe; the partition record's full write() otherwise. The applyMapped comment is the constraint: per-channel writes break the cross-channel couplings that live in writeChannels (circle collapse, the arrow-alpha fold, the label re-anchor). So each fast writer is a private method factored out of writeChannels and called by it — one source for the fold math, the dual-consumers discipline — and the fast set is deliberately small: the single-call paint channels (node fill/border colours, node opacity, both layer records per group, the edge line colour, the four arrow colours). A diff touching anything else — geometry, labels, charts, edge opacity with its fold cluster — falls back to the full per-slot write() of the target record, which is byte-for-byte today's behaviour. The store setters are already self-consistent underneath (the arrow-bits mirror re-derives inside setColor, the layer counts inside setNodeLayer/setEdgeLayer), which is what makes a narrow write complete.
  4. Fall back to the general path entirely when def.partition is null (a data mapper puts the group per-element anyway), when the group's transition spec is live (the txn capture is exactly what the general path owns, and a sheet transitioning on a state flip is rare), or when demoted[group] is set. Compounds split leaves/parents against defs.nodes/defs.parents exactly as refreshMapped does. Note what is not a concern: GPU-owned channels — a partitioned def is all-case by definition and case conditionals never join the eval kernel.
  5. An empty diff is a no-op, which the path gets for free: the store's watchedStates set is per group, not per def, so a bit only the parents def reads notifies for leaf slots too — those slots' def diff is empty and they are skipped rather than written.

Pass split (tests-first; docs in-commit)

  • 61.0 Docs-first — this section.
  • 61.1 The specs (2026-08-09) — seven in test/state-conditions.mjs beside the partition suite, five seen red against HEAD (the write-count assertions and the diff cache) and two green by design (the fallback pins, which exist so the controls can prove they discriminate): a paint flip runs no full write() and reads back the target record; the diff caches per flag pair, not per slot; every fast paint channel round-trips in both directions on both groups (arrow shapes as constants, since a 'none' end stores NO_ARROW whatever the colour says); a geometry-prop condition still moves boundingBox() through the fallback; a transition on a state flip still tweens; the parents overlay's diff applies to parents and the nodes def's to leaves; and a bit watched for the group but unread by the slot's def skips the slot. The which-path-ran assertions instance-shadow the engine's write — the partition suite's private-reach idiom.
  • 61.2 The implementation (2026-08-09) — as designed, plus one addition measurement forced: refreshState / refreshStateDef / partitionDiffWriters and the diffs cache on def.partition; eleven narrow writers factored out of writeChannels (both callers, one fold definition — foldRgba / foldLayerRgba replaced four inline copies); partRecordFor extracted from applyPartitioned; the core.mts wiring. The addition: the first cut resolved the diff per slot (a string key and two Map hits each) and measured 131.5 µs on the 256-band — a bulk flip's slots almost always share one masked word, so the record and writers now resolve once per run of equal words, which took it to 63.5 µs.
  • 61.3 Controls + measurement (2026-08-09) — all three controls run, each failing exactly what it should and nothing else: the fast path disabled outright fails the five write-count/cache specs while the fallback pins stay green (the optimisation is invisible — 57.1d's control shape); the writers neutered fail the four readback specs while the write-count specs stay green (the failures come from the writers, not the routing); the classifier claiming width fails both geometry specs — the round-61 fallback pin and 57.1d's own bounding-box spec. Measured through build/cytoscape.esm.mjs (N=2000, this machine): 256-band select+unselect 541.9 → 63.5 µs under the default sheet (constant sheet 15.5 µs — the residual ~4× is the restyle actually happening, one colour write per slot at ~50 ns); single-element 3.4 → 2.2 µs; a 256-band lock+unlock (a state no default-sheet condition reads) at 21.6 µs, pure flag work — the unwatched path stays free. Through tsx, the suites whose rows regressed: mut-bulk: select + unselect 6.30 ms → 250.9 µs (v3 2.14 ms — v4 is ~8.5× faster than v3 on that row again), scn: explore 164.7 → 39.3 µs, scn: select-all + fit 7.14 → 0.99 ms, scn: drag 411.9 → 103.7 µs. The residuals over the pre-57.1d published numbers are the default sheet genuinely restyling on select, which pre-57.1d sheets never did. style.mjs gained the row that prices the fork — its 60.2 state sheet conditions geometry (width/height/border-width), so it correctly keeps the full-write price (989 → 583 µs, the leaner dispatch only) and a new paint-only state row (the default sheet's shape) prices the diff path: 8.9 µs (no restyle) / 31.4 µs (round-61 diff path) / 595 µs (all channels), with a startup probe asserting the paint sheet actually restyles, which is what catches control 2 in a benchmark run. And the quick profile was re-measured and published at the round's close (6df994f1, clean tree), so the 60.1 comparison page — the tool that found the regression — shows the recovery against its own baselines: whole-run drift vs the pre-regression 2026-08-05 run is ×1.017, inside machine noise, with every regressed select row recovered (mut-bulk 6301 → 231 µs against the baseline's 47.9, the residual being the default sheet genuinely restyling).
  • 61.4 Closing docs sweep (2026-08-09) — the 60.4 record annotated with the closure; the README's default-stylesheet performance note rewritten from "what is not free" to the three-configuration contract; the style.mjs header and select-row comments; the summary rewrite (week 4 and the what-remains table).
  • 61.5 The smaller flagged movers, re-checked through built bundles (2026-08-09, maintainer-requested) — the 60.4 list's deferred half, measured the way that record demanded: the baseline commit (dc6d3505, the 2026-08-05 published run's sha) built in a worktree with today's rolldown so the A/B isolates library code from toolchain, both bundles raced in one process under the suites' own K=8 rotation and do_not_optimize. Three of four are noise, one was real, and the real one is fixed. elements() reads 33.4 vs 35.4 ns (the published 8.5 → 39 ns was a tsx-environment artifact on both ends); contains() is 1.00×; forEach() read 1.45× in a combined run and parity in all six isolated runs at both bench orders (3.51–3.66 µs both sides, with whichever side runs first slightly ahead — the round-55 measurement-order bias, which is what the published +48% was too). lock + unlock reproduced at 1.83× (3.64 → 6.69 µs on a 256-band) — real, and localized on sight: 57.1d's flagRefs gate collects changed slots for any condition-family bit, but noteStateChange discards the array for a key no case condition watches, so every bulk lock/grabify/selectify under the default sheet (which watches selection and press only) built a slot array nobody consumed. The fix folds the per-group watchedStates check into the gate; attribution proven by the fix collapsing the row to 4.51 vs 4.52 µs. No observable behaviour changes — the discarded array was the whole defect — so the pin is the existing state-conditions suite (watched bits still notify and restyle) plus the mutators.mjs lock row. The insertion moved the SHAPE_MASK allowlist key and the throw gate failed naming it — the 37.1 mechanism's fifth live firing — re-keyed 2906 → 2912. Verification: typecheck, 2111 + 337 + 24 Node specs, the throw gate at 184/10/5/0, lint, format. Playwright not re-run: the change gates an allocation in the store and touches no rendered or observable path, and the full Node tier is the coverage that exercises flagRefs in both directions. Verification for the round: typecheck, 2111 Node + 315 module + 24 soak tests, the throw gate green at 184/10/5/0 over 199 sites, lint, format, JSDoc 100%/100% with @throws 18/18, @param 241/241, @returns 280/280, the regenerated dist/cytoscape.d.ts (45 type exports / 3 statics / 1226 doc blocks), and the full Playwright suite against a fresh bundle with goldens exact — the factored writers are instruction-identical for a full apply, and the exact goldens are what proves it.

Every benchmark beats v3

The maintainer's directive, verbatim: "Every benchmark for v4 should beat v3." The survey that preceded it (recorded in the 61.5 record and the conversation that followed) found the quick profile at 106 v3/gpu pairs with 11 reading v4-slower, plus known losers in the --all tier (the style getters' 2.3× residual, the whole-object data() read at 6.3×). This round works the list worst-first until a fresh run on an idle box reads every pair v4-faster, then publishes that run.

The known losers at the round's open, from the 2026-08-09 published quick run (ratio = v3/gpu; <1 is a v4 loss):

row ratio standing diagnosis
algo: degreeCentrality (one root) 0.03–0.11× per-call subgraph() view build — O(V+E) for an O(degree) question; 333.7 µs direct through the bundle vs v3's ~7
mut: data set (single element) 0.58× per-write (group, key) column resolution (33.6)
core: getElementById() 0.80× blob-probe + handle fetch vs v3's Map.get
algo: closenessCentralityNormalized 0.85× runs floydWarshall inside — one fix, two rows
algo: floydWarshall 0.85× dense triple loop, v4's inner loop paying an indirection v3's does not (to be profiled)
json: element 0.88× per-call object build
position: get / core: pan() get 0.92× nanosecond rows; need genuine shaves to hold a margin over noise
algo: hierarchicalClustering 0.95× attribute-space parity band; needs a real few-% edge
algo: kMedoids / iter: forEach() 0.99× noise band; forEach gets margin via a cached handle array

Ground rules, all standing ones applied: fixes are measured through the built bundle before and after (round 34); a row that cannot be beaten without changing observable public behaviour gets the change logged in "Public-surface changes" with a spec pinning it, never silently (the elements() memo of 34.2 is the precedent shape); the enumeration run may share the box with editing, but the verification run gets an idle machine (36.5); and every fix keeps its suites and controls.

Pass split (tests-first where behaviour is pinned; docs in-commit)

  • 62.0 Docs-first — this section; the full---all enumeration running as it lands.
  • 62.1 degreeCentrality (one root) (2026-08-09) — solved one level up from the plan's fast path: the SubgraphView is a structural snapshot valid exactly until the store adds or removes elements, which is what structureEpoch (34.2) counts, so subgraph() memoizes the view on the collection against that epoch — every algorithm entry amortizes, not only dc, and the O(N·E) per-node-dc app pattern collapses to O(E). Weights and endpoints read live per call (the view holds structure only), so data writes and moveEdge need no invalidation. Measured through the bundle: 333.7 → 0.62 µs, ~11× faster than v3 on the row that was ~47× slower. Four specs in test/modules/algo-subgraph-memo.mjs (identity, both invalidations, live weights); both controls fail exactly their specs; the four algorithm suites pass unchanged.
  • 62.2 The dense/attribute algorithm tail (2026-08-09) — floydWarshall: running ij/kj indices (the inner loop recomputed k·n+j twice per iteration), a single-alt relax, and an Infinity-row skip (an unreachable (i, k) pair relaxes nothing) — 296 → 151 ms at N=500 against v3's 299, 0.85× to ~2×, with closenessCentralityNormalized riding it. The clustering family recomputed every node's attribute projection per pair — the round-18 rule applied to none of them — and re-resolved the metric impl per call: hierarchicalClustering gets a per-run makeGetDist (impl once, per-node vectors cached on the interned handles), k-clustering a run-token WeakMap vector cache behind its unchanged getDist signature (the token, bumped per public entry, is the staleness guard; mutating feature-array centroids stay live-read), and fuzzyCMeans' k-invariant numerator hoists out of its k loop, float-identical. Measured at N=500 vs v3, both capped at 10 iterations: hierarchical 16.8 vs 29.9 ms (0.95× → 1.8×), kMedoids 52 vs 426 ms (0.99× → ~8×), fcm 5.7 vs 45.9 ms (1.05× → ~8×). All 102 algorithm specs pass, v3-fixture numeric pins included.
  • 62.3 The micro rows (2026-08-09) — two were real, and both were the round-34 transpiler tax on new paths (the suite runs both sides through tsx, so a closure-per-call path pays __name there even when the bundle does not). mut: data set: stylesDependOnData — called twice per write — built 2–3 closures per call, 701 ns through tsx against 41 through the bundle; rewritten as plain loops, ele.data(k, v) reads 134 ns through tsx against v3's 893 (0.58× → ~6×), and dependsOnState shares the method. getElementById: lookup() allocated an IdEntry and a Ref per call; IdMap.code() / GraphStore.lookupCode() answer the packed (slot << 1) | groupBit integer and the pool answers the handle — 102 → 72 ns through tsx against v3's 81 (0.80× → 1.13×). The other four flagged rows (json: element, position get, pan get, forEach) probed as wins or noise-band through tsx and ride to the verification run. Fallout, both audits working: the resolveDistance insertion stranded clusteringDistance's doc block (instance #18; the coverage gate caught it) and lookupCode moved the SHAPE_MASK allowlist key (the 37.1 mechanism's sixth firing) — both fixed in the pass.
  • 62.4 The --all tier's losers (2026-08-09) — the first enumeration's "146 pairs, no --all-only losers" was wrong, from a partial run: launched under this round's own editing contention, several jobs died and the reading was taken as complete (the 36.5 idle-box rule violated by its own round). The idle-box run reads 287 pairs with 28 losers, most in the surface/events suites. All fixed, worst first: indexOfId (0.02× — a lazy id → index map on _keys' immutability grounds, preserving 34.1's answers-for-removed contract); the animation lifecycle (0.12–0.57× — the nine per-handle closures became the prototype-method AnimationHandleImpl at ~93 ns vs ~2.9 µs, the chaining animate() skips the Promise it never exposed, start() allocates its eviction Set only on overlap, animated() gates on an O(1) anyRunning(); delay+stop 3420 → 488 ns vs v3's 561); whole-object data() (0.20× — cached on the handle against a new DataStore write epoch + parent/endpoints + gen, a logged public-surface change, ledger item 17b; json()/jsons() ride it); the style colour read (0.78× — per-raw-name read plans replace the normalize hop, four set tests and a per-edge-read regex, plus a packed-word rgba string cache); slice() (handle reuse); source()/target() (lean endpoint accessor); hasElementWithId (lookupCode), window() (module-load resolve), mutableElements() (inlined memo hit). The audits fired three more times (SHAPE_MASK re-keys, stranded block #19, the handle class joining the public tier) and each was fixed in the pass.
  • 62.5 The nanosecond tail (2026-08-09/10, three batches) — the verification loop's residue after 62.4: ~10 rows per run in the sub-30 ns band, rotating membership across runs (forEach read 1.00×, 0.67× and 0.80× in three consecutive runs with no code change between them). What landed, batch by batch:
    • Hot column caches (62.5): ColumnTable.arraysVersion, bumped in the same call that swaps the arrays (grow/compact) — the timing-safe validity key an epoch cannot be — and the four hottest arrays (node/edge flags, positions, endpoints) cached on the store against it. flags()/hasFlag and every state predicate ride it, as do position() and source()/target(). Collection._store became a constructor field; sort() passes its handles through the 62.4 ctor path; toArray() preallocates.
    • The dense handle array (62.5b): _arr() caches the interned handles as a dense array (safe on _refs immutability), and forEach/map/some/toArray iterate it — array-element access holds its speed in the JIT modes where indexed own-property access is bimodal. toArray() becomes a slice() of the cache: 795 ns against v3's 9.5 µs at 2000 elements. removed()/inside() read the raw first ref (isCurrent repairs a forwarded ref itself); removeAllListeners() gates on an empty list before off('*') allocates to remove nothing; structureEpoch converted from getter to field; the isParent/isChild/isChildless/isOrphan family reads the store-managed FLAG_PARENT/FLAG_CHILD bits through the hot cache instead of the child-list map; the whole-graph collection cache is push-invalidated (onStructureChange from the one bumpStructureEpoch() funnel) so the elements() memo hit is two loads. One over-reach reverted by measurement: routing eq() through _arr() cost more than the property read it replaced on three accesses.
    • The harness itself (62.5c) — the finding that explains the rotation. Every cmp()-style suite shares one op closure between the v3 and gpu benches, and v3 is always declared first, so v3 sampled against monomorphic inline caches and v4 against polymorphic ones — a systematic, one-directional ~0.5–1 ns/call bias, exactly the band where rows kept losing in-suite while measuring at-parity-or-faster in isolated A/B probes (isolated: minZoom+maxZoom 1.0 vs 4.6 ns, pan 4.4 vs 6.2, gating tied). This is the round-55 measurement-order rule in IC form, and round 33's "every 1× line is real" cuts against a row whose sign is decided by which side sampled first. A few alternations through both sides before either bench samples (eight for the micro suites, two for algorithms, whose rows run to seconds) take every call site to its steady polymorphic state. style.mjs's cmp takes separate closures per side and was already fair. Audit fallout across the batches, every instance caught by its gate: the SHAPE_MASK file:line key moved four more times, the _arr insertion stranded forEach's doc block (instance #20, caught by three gates at once), and the one-off single-spec test:js flake fired twice more without reproducing (the 2026-08-02 record's shape; output captured on the re-runs only).
  • 62.6 The last five, and the goal (2026-08-10, three passes) — run six read 287 pairs / 5 losers; runs seven through nine each cleared some and surfaced a floor-row flicker; run ten reads 287 pairs, zero v3-faster rows, and is the published run. The progression across the round's ten idle-box --all runs: 28 → 12 → 10 → 10 → 11 → 5 → 2 → 1 → 0. What landed:
    • Real shaves (62.6/62.6b/62.6c), each probed with margin before riding to a verification run: indexOf skips the full assertCollection walk when one identity compare proves the argument is this instance's (the 29.3/48.4 throw intact for everything else; 1.52× from 0.675×); effectiveOpacity reads the stored opacity column directly behind a new StyleEngine.ownsProp() gate — the one case stored bytes lie is a kernel-owned mapper, the same rule readProp keeps (2.27×); mutableElements() flattens its memo to one dedicated field (1.38×); band connectedEdges() was profiled to 17.7 of 22.2 µs in the walk — outEdges/inEdges allocate a CSR subarray view per node — and AdjacencyIndex.appendIncident() now reads the CSR rows in place (14.1 µs against v3's ~25); data(key) dropped its rest-parameter args-array allocation (arity via arguments.length; 1.13×); _liveNodeRef (the isParent/isChild/isChildless/isOrphan shared head) and the data() getters read the raw ref and let isCurrent repair forwarded ones in place (the flags ×4 row: 1.36× from 0.895×).
    • The harness, twice more (62.6b): data.mjs's cmpRead had been missed by 62.5c's pre-warm sweep — its three rows still sampled v3 against monomorphic ICs, and the one-numeric-key row flickered at 0.985–0.996× until it joined. And pan() get exposed the artifact 62.5c's fix cannot reach: 0.83–0.87× across eight independent --all runs while replicating at 1.01× outside the suite through the same factories, pre-warm and rotation. Order-swap probes showed the sign belongs to group-order sampling at the ~6 ns harness floor (declared order swapped, the same row reads ~1.1× the other way; lead- and tail-bench controls bounce both directions), while per-process monomorphic loops read gpu 0.49 ns vs v3 1.96 ns — v4 is genuinely ~4× faster, v3's arguments use blocking full inlining; the subject outran the instrument. The row now does 32 reads per op — the round-33 rule (a row is guilty until shown to discriminate) applied to the row itself — renamed pan() get (x32) since it is a different measurement.
    • Process fallout: 62.5c had shipped with the SHAPE_MASK allowlist key stale (the 37.1 gate said so on the next test:modules run, its seventh firing; the key now sits at the throw line, 2989) — the round's one red-tier commit, caught by the gate it exists for.
    • Verification: the green run published (benchmark/published/, all profile, 287 pairs at cf5727b4); Node tier green throughout (2115 + 341 + 24; throw gate 184/10/5/0; JSDoc 100% with 244 @param / 288 @returns); Playwright over the changed source; the closing docs sweep.
  • 62.7 The renderer run, and the comparison's first false alarm (2026-08-10) — a fresh renderer run at c182dd0c (12 scenes, amd gcn-4, published as the profile's fourth run) read clean where it matters: v4 vsync-bound at 16.7 ms wall on every scene, whole-run drift +0.7% over 348 shared rows. The comparison page's mover list, though, flagged a one-directional CPU-side cluster spanning exactly rounds 61–62: convert (toColumnarElements) +14–66% across five scenes, compact() one-shot +40–42%, pick-hover ×2 on ndex, export and labeled init +11–18% with flat v3 twins. The follow-up exonerated the code and convicted the sampling: a best-of-7 conversion A/B through both trees' built bundles (a worktree at e37d2444, per the round-53 reproduction rule) put the two within 0.3 ms while single shots on one binary spanned 13–23 ms; and back-to-back runs of one scene on one bundle read compact() at 19.7 then 30.7 ms — the published "+42%" inside same-binary run-to-run spread. These rows are measured once per run and have no v3 twin, so they sample a GC-noisy distribution the frame rows (121 frames per press) never see. The device-pair "×2.5" flag was its own tell: the peak-slot row slowed by the same factor its compacted twin sped up, i.e. the bimodal pair the round-29.5 record already documents, trading modes. Recorded as a caveat in the README's renderer-benchmark section (one-shot rows are not a regression signal; reproduce best-of on one binary before attributing movement to a commit) — and the run's own publish commit carries the original flag so the record shows the alarm and the resolution both. Also in the pass: the stale-bundle warning compared v3's frozen UMD against v4's src mtimes and cried wolf on a fresh build; each bundle now checks against its own tree.

Per-element bypasses

The maintainer reopened ledger item 25 — "bypasses are worth a discussion" — and the sitting took its calls with one requirement set above the others, verbatim: the proposed implementation must be fast/performant. This section was written as the docs-first proposal those calls asked for (the 41.5 precedent), and the maintainer approved it the same day with two amendments, which are folded in below and marked: the sheet section's key is bypasses (not overrides), and style property keys accept both dash-case and camelCase, in the bypasses and everywhere else in the API. With the approval this section is the round-63 plan; the pass split is at the end.

The sitting's calls

  1. The ergonomics return, for apps and extensions; the shape goes design-doc-first (this section, with its measurements, before any round is committed). The residual use case the sitting named: v4 already answers state styling (conditions), data styling (scales) and static per-id rules (case on id) — what it lacks is imperative, runtime per-element styling, and the strongest form of that is extensions, which must style elements without owning the app's stylesheet. The app-side workaround (a data flag plus a sheet clause) needs sheet cooperation and leaks the flag into data(), the wire and every export.
  2. The v3 spellings return: ele.style( name, value ), eles.style( props ) and removeStyle( name? ) work again, as sugar over the bypasses section. This deliberately reverses 29.3's decided drop (the throw whose message 31.1 corrected); MIGRATING.md's "no per-element bypass" row and test/decided-drops.mjs's pin both flip with the round.
  3. Precedence is v3's: a bypass beats everything, the default sheet's selection/active conditionals included (v3 applies "its bypass" ahead of every selector — the header comment in v3/src/style/apply.mts). Consequence recorded up front: a bypassed background-color hides selection blue for that element, exactly as a v3 bypass does — and 57.11's don't-bury-the-affordances rule is about sheets, not about a per-element instruction the app gave explicitly.
  4. Bypasses export, and sheet swaps replace them. cy.style() / cy.json() carry the section — strictly better than v3, whose ele.json() exports no bypass at all (verified against v3/src/collection/index.mts: data/position/group/flags/classes only), so v3 bypasses are silently lost on export today. A full cy.style( sheet ) replaces the section like any other; the keep-them idiom is spreading the getter. A recorded deviation from v3's element-lifetime bypasses.
  5. Performance is the top requirement (maintainer, mid-sitting): a bypass-free graph pays nothing measurable, and every bypass operation costs what it touches.
  6. The two amendments, taken with the approval (2026-08-10):
    • The section's key is bypasses. The prose keeps "bypass" as the concept name too — it is v3's word for exactly this surface, which is the point of bringing the spelling back.
    • Style property keys accept dash-case and camelCase everywherefoo-bar and fooBar are the same key in the bypasses section, the sheet blocks, the getters, transition-property lists, animate({ style }) and removeStyle. Measured before planning: most of this is already true (sheet blocks normalize through normalizeProp in resolveConst; the getters through the 34.5 memo; transition-property and the animation channels each normalize at their parse) — so the round's job is a sweep spec that pins every entry point in both spellings and a fix for any the audit finds bare, not a new convention.

Why not the logged shape — measured, and rejected

Item 25 as logged would rewrite the user's sheet with id-keyed case clauses. Measured against the code (2026-08-10, through the built bundle, N = 2000 nodes / 4000 edges, this machine), that shape hits three walls:

  1. It cannot compose with scale-mapped channels. A clause's then and the else parse through parseOutput to scalars (src/style-scales.mts — the case Program's clause values are number | RGBA), so "wrap the channel's current value in a case" is inexpressible when the current value is a scale — and em-web's background-color, the flagship demo sheet, is exactly that. Fixing it means else holds a nested Program: a mapper-IR change with GPU-pack and readback consequences, at which point the sugar is no longer sugar.
  2. One bypass re-opens the round-60.4 select regression for its whole group. partitionOf (src/style.mts) deliberately returns null on any condition key outside CONDITION_FLAGSid is not one — and since 57.1d the default sheet state-conditions every graph. Measured: a 256-band select+unselect under a state-only sheet runs the round-61 diff path at 53.7 µs; the same sheet with one id clause prepended (what the sugar produces after a single bypass) reads 392.0 µs — 7.3×, the full per-slot path back for every selection the app ever makes, with applyAll gone per-element too.
  3. The chain is O(k·V). applyAll with k id clauses on one channel reads 10.0 / 10.4 / 10.8 / 16.9 / 61.7 ms per swap-pair at k = 0 / 1 / 10 / 100 / 1000 — the ledger's quadratic concern, confirmed. On top sit the ledger's other two concerns — cy.json() exporting a sheet the app never wrote, and removal needing clause identity — both of which the shape below dissolves rather than engineers around.

The design: a first-class bypasses sheet section

cy.style( {
  nodes: { /* ... */ },
  edges: { /* ... */ },
  bypasses: {
    n42: { 'background-color': 'red', width: 40 },
  },
} );
  • Declarative and id-keyed. Round 8's invariant holds — every value analyzable and serializable; the representation is data, not a closure and not a forged clause chain. Values are constants-only: mappers stay the sheet's job (a recorded deviation — v3's bypass technically parses mapper values).
  • The v3 methods are sugar over it. ele.style( name, value ) writes one entry; eles.style( props ) writes one per element; removeStyle() with no argument clears the element's entries (v3 semantics), with a name clears one. The cy.style() getter returns the live section.
  • Id-keyed means declaration, not element state — a deliberate semantic difference from v3: a bypass survives remove/re-add of its element, may name an id not yet present (inert until it is — what makes a hand-written section legal in a sheet set before its elements), and round-trips through cy.json(). The one fail-loudly softening this admits: a typo'd id in a hand-written section is silently inert — recorded rather than hidden, and the sugar path cannot hit it (the caller holds the element).

The performance contract (the requirement, made concrete)

  1. Bypass-free graphs pay one load. The engine keeps a per-group bypass count and slot set; every touched path gates on count === 0. The gate is measured, not asserted: the round-62 published zero-losers run is the baseline, and the round fails if any row moves past machine noise.
  2. A bypass set is one narrow write, not a restyle. Round 61 factored eleven per-channel writers out of writeChannels (partitionDiffWriters) at exactly this granularity — one channel of one slot, ~50 ns measured — so ele.style( name, value ) is a map update plus one writer call, with geometry channels riding their existing write-through cascades (setLane, updateOuterHalf). The round adds the comparative row v3 makes possible for the first time since 29.3: ele.style( name, value ) spelled identically on both sides.
  3. Apply punch-out is O(bypassed). The sheet paths (applyBulk, applyPartitioned, refreshMapped, refreshState) re-assert bypasses after the sheet write for bypassed slots only — a slot-set test per run when the count is non-zero. The 57.1d partition and the round-61 diff path survive untouched; bypassed slots fall out of record runs and take per-slot writes.
  4. GPU eval demotes per channel, count-gated, reversibly. A channel carrying ≥ 1 bypass joins the B1 paintInputs exclusion while any exists (the kernel evaluates every slot and would overwrite the bytes); the last removal restores kernel ownership. The recorded bound is round 7's 78.5-vs-15.9 ms whole-channel re-derive at 200k — paid only on data writes of a mapped key whose channel also carries a bypass — re-measured on a real renderer in the round.
  5. What the round must measure before claiming any of this: the count-gate's nullity (the full published suite unchanged), the set-path row against v3, punch-out cost at k = 1 / 100 / 10k on the apply and select paths, and the demotion cost on an em-web-shaped sheet.

Semantics inventory (the round's checklist)

  • Precedence, per channel: bypass > user block > default sheet; the burying consequences (selection blue; the press wash via an overlay-opacity bypass) recorded as v3 parity.
  • Readback needs no new path: stored truth already answers the bypassed value through style() / numericStyle() / renderedStyle() — v3's semantics for free.
  • Transitions come free: bypass writes flow through the write funnel the 24.1 txn capture wraps, so a configured transition-property tweens a bypass change; round 21's latest-wins eviction against animations is unchanged.
  • Validation: prop names validate at set — the sugar path against the element's group, the sheet path against the union with the group checked when the id resolves. Unknown props throw, as everywhere.
  • The existing carve-outs ride along: parent width/height stay auto-bounds-owned (the 25.1 filter), label stays constants-or-passthrough, list props take constants like any sheet block.
  • Out of scope: mapper values, classes, per-element style events, and the wire format (the sheet is not in it).

Controls the round owes

Readback in both directions; precedence against the default sheet and against a user block; removal restores the sheet-resolved value; a sheet swap replaces the section; a transition on a bypass tweens; kernel ownership restored on the last removal; and the O(0) gate as a benchmark row, since a spec cannot see a nanosecond.

Pass split (tests-first; docs in-commit; each pass its own commit(s))

  • 63.0 Docs-first (2026-08-10) — this section, amended to the approved design; ledger item 25 annotated.
  • 63.1 The camel/dash sweep (2026-08-10) — landed as test/style-camel-case.mjs: a fully camelized copy of the 153-prop styled fixtures reads back identical to the dash copy through every sheet group, the getters compare outcome-for-outcome across the whole surface, and transition-property and animate({ style }) take camel entries. The audit found every existing entry point already normalized — the spec turns the coincidence into a contract. Control: normalizeProp neutered to identity fails 5 of 7 (the survivors are the meta-check and the animation spec, whose parser has its own normalizer — the correct reading).
  • 63.2 The model (2026-08-10) — Stylesheet.bypasses; id-keyed raw declarations + per-group parsed patches; parsing reuses the sheet compiler's own pieces (the per-group guards extracted from resolveConst into assertGroupProp, and applyProp as validator/parser — a bare scratch object captures exactly the Computed fields an entry assigns, surviving a value equal to the channel default). Slot resolution is lazy against store.structureEpoch: O(declared ids) per structural change, never O(elements). Wrong-group props behave exactly as sheet blocks treat them — the guarded families throw, the rest are accepted-and-inert — one rule for both surfaces.
  • 63.3 Apply integration (2026-08-10) — the write() merge hook (every full-write path respects bypasses by construction; the transition capture wraps it, so bypass changes tween wherever a spec configures them), the refreshStateDef punch-out (bypassed slots take the merged full write; the run optimization untouched), paintInputs demotion per bypassed prop (count-gated, reversible), and the one-load gate (mergeBypass is a method so a spec counts invocations: zero on a bypass-free instance, pinned). Four controls, and one strengthened two specs: the epoch-rebuild control caught the re-add and compaction specs passing by accident (a recycled slot; permuted-but-unrewritten bytes) — they claim the freed slot with a squatter and restyle after compacting now, and the control fails all three lifecycle specs.
  • 63.4 The sugar (2026-08-10) — ele.style( name, value ), the object form and removeStyle( name? )/removeCss (the 85th alias row), validating against the element's own group and re-applying through the single-slot funnel — a configured transition tweens a sugar bypass in both directions, pinned. The 29.3 throw and its pins flipped (test/decided-drops.mjs, test/style-getters.mjs), style() grew real overloads with one doc block each, and the round-45 gate caught the stale shipped declaration by name (regenerated: 45 type exports / 3 statics / 1330 doc blocks). 23 specs in test/style-bypass.mjs.
  • 63.5 Benchmarks (2026-08-10) — four rows in style.mjs: the set path 3.9× faster than v3 through tsx and 2.0× through the built bundles (3.32 vs 6.59 µs/call — the maintainer's fast-requirement met on the headline row), the set+remove round-trip 4.4×, the 256-band default-sheet select at 34.5 µs with 0 bypassed vs 352 µs with 128 (O(bypassed) at the known full-write cost, the k=0 side the standing O(0) gate row), and a 128-bypass sheet swap at 1.02–1.06× of bypass-free. The count-gate's nullity measured through the bundle: the pre-round state-only select read 53.7 µs, the post-round 48.0 — no regression. Both bypassed rows carry startup discrimination probes.
  • 63.6 Closing sweep (2026-08-10) — src/README.md's style-getters section carries the contract and the two live claims trued (the transition taxonomy, ledger idea 25's own sketch — which shipped in a different shape than it proposed); MIGRATING.md flips its bypass rows with the two porting differences named (export is better than v3; sheet swaps replace); CHANGELOG.md gains the feature; the summary rewritten; full verification below.

The collection guard and the query aliases

Three maintainer calls taken in one sitting and landed the same day — small, decided, and all on the query/building surface:

  • 64.1 cy.collection() throws on any argument (closing ledger item 28). Both boundaries — the core's accumulator and the collection delegate that shared the silent-ignore shape — guard on arguments.length, with a message naming the replacements (build with union(), query with cy.$( query ) / cy.filter( query )), and the guard spec executes the advice it gives (the 31.1 rule: an error's recommendation must actually run). The zero-arg accumulator is unchanged.
  • 64.2 cy.$ returns as a plain alias of filter() — over the v4 query API, in line with cy.$id(): cy.$( { selected: true } ), cy.$( ele => … ). A selector string still throws, through filter's own rejection, and the decided-drops pin flipped from absence to alias-plus-strings-throw. cy.byId lands beside $id as the brevity id lookup. Both ride the standard declare + wiring + alias-table row mechanics (85 → 87 rows, the table's cross-checks passing in both directions).
  • 64.3 Verification + sweep (2026-08-10). Three controls, each failing exactly its pin: the guards neutered fail the guard spec; $ wired to elements instead of filter fails the alias table and the decided-drops identity; byId unwired fails the table's two directions. MIGRATING.md carries the porting rows (cy.$ works over the new forms; collection( eles ) is not ported and now throws), CHANGELOG.md the entries, src/README.md's selector-removal design bullet the amendment, and dist/cytoscape.d.ts regenerated. Full tier: typecheck, test:js, test:modules, soak, the throw gate (the two new guards pinned), lint, format.

Note what the round deliberately does not reopen: the selector language. cy.$ accepts exactly what filter() accepts — the alias restores a spelling, not a dialect.

The async algorithm tier and the GPU executors

The design question arrived as "which algorithms could the GPU speed up?", and the answer split the algorithm surface in two. The traversal tier (bfs/dfs, dijkstra, aStar, bellmanFord, kruskal, tarjan, hopcroft-tarjan, hierholzer, kargerStein, degree/closeness centrality) already beats v3 by 13–39× slot-native and is called per-root in tight loops — the round-62.1 memo lesson is exactly that usage — so it stays synchronous forever, and no GPU formulation would beat it. The dense-matrix and clustering tier (pageRank, floydWarshall, betweennessCentrality, markovClustering, affinityPropagation, kMeans, kMedoids, fuzzyCMeans, hierarchicalClustering) sat at deliberate CPU-parity with v3 (identical math dominating, the round-33 reading) — which is precisely the statement that only a different execution model moves it.

Design calls (maintainer-approved in discussion, 2026-08-10):

  1. The nine whole-graph algorithms are async-only — Promise-returning, awaited once per computation (they are call-once-query-many, so the await is idiomatic), decided now because pre-4.0 the contract change costs a migration row and post-4.0 it is semver-major. No asyncPageRank() twins: one spelling, and the executor choice stays inside the library.
  2. executor: 'cpu' | 'gpu' | 'auto' (default 'auto') on each. 'cpu' is the bit-reproducible f64 reference — the spec, the round-18.4 force-layout determinism precedent applied to algorithms. 'gpu' rejects rather than silently degrading when WebGPU or the algorithm's GPU path is missing. 'auto' takes the GPU above a per-family measured crossover; only acquisition failure and GpuUnfitError (input past device buffer limits) fall back — a kernel error propagates, so a defect cannot hide behind the router.
  3. Contracted CPU-only paths say so: weighted betweenness (Brandes over weights needs a priority queue), custom distance functions and attribute-less feature runs (kernels never call back into user code).

Landed, in six commits (65.1–65.6):

  • 65.1 the async reshape: per-module *Async wrappers (sync validation throws at the call site; everything later is a rejection), the executor router (src/algorithms/executor.mts), standalone device acquisition (algo-gpu.mts — no canvas, no renderer coupling, cache cleared on loss), the four spec files converted to await, test/algorithms-executor.mjs pinning the routing contract with stubbed-navigator paths so every guard fires in the Node tier (throw gate at zero), cmpAsync in benchmark/algorithms.mjs (rotation + 62.5c pre-warm preserved; the gpu-side await asymmetry is µs-noise under ms rows), MIGRATING.md, d.ts.
  • 65.2–65.4 the kernels, family by family, each validated on a real adapter before its commit: shared dense WGSL (algo-gpu-dense.mts — tiled matmul, column ops, rounded compare, the flags discipline: all iterations encoded up front, barrier-free kernels early-return on the converge bit, the matmul guards its store instead because WGSL uniformity analysis forbids divergent returns before workgroup barriers), MCL, pageRank, FW (per-k relaxation over a bumped storage counter; finite 3.0e38 sentinel instead of Inf — WGSL may assume finite floats), AP (row/column-owning kernels; the exemplar-history convergence ring verbatim; 'as' is a WGSL reserved word), the feature-space clusterers (featuresOf materializes attributes once; k-medoids turns its iterations into O(n²) lookups over two device-built matrices; fcm's one deliberate deviation — denominators clamp at 1e-30 where the CPU would NaN/Inf — is noted on the kernel), the hierarchical pair-matrix (merge chain stays CPU under every executor), and pulled level-synchronous Brandes (one writer per node, no atomics; 64-source batches; weighted contracted out). One live catch: extracting buildBrandesNeighbors displaced the impl's doc block onto it — the seventeen-instance stranded-block hazard — and the JSDoc gate caught it red before commit.
  • 65.5 the parity suite (playwright-tests/algorithms-gpu.spec.js, riding the renderer projects): nine live CPU-vs-GPU comparisons — discrete results exact, floats under f32-honest tolerances, plus the in-browser routing contract. Controls run per the can-it-fail rule: MCL fails with the inflate exponent degraded, betweenness fails with sigma corrupted; both reverted, 9/9 green. One assertion corrected by its own first failure: the pageRank fixture has genuinely tied ranks, which f64 and f32 break differently, so the ordering invariant is zero inversions among pairs separated by >1e-4, not total-sort equality.
  • 65.6 the sweep (npm run benchmark:algorithms-gpu): whole public call, cpu vs gpu, per family per size, adapter identity reported and SwiftShader refused. On this machine (amd gcn-4, RX 570-class): markovClustering 71.9× / 266× / 478× at n=256/512/1024 (31.2 s → 65 ms), kMedoids 9.6–36.6×, floydWarshall 2.4–16.7×, fuzzyCMeans ~7.5×, kMeans ~2×, hierarchical ~2× (the CPU merge chain is the shared floor), betweenness 0.74× → 3.6× (crossover ~1024), affinityPropagation 0.65× → 1.43×, pageRank 0.70× → 1.33× (both ~1024) — the serial per-row/column kernels are the modest tier, the dense-matmul and n²-matrix families the headline one. Each wrapper's 'auto' threshold now encodes its measured crossover in place of the uniform 256 guess, machine-stamped for re-measure.

Open follow-ups, logged not scheduled: two-stage reductions for the serial pageRank/AP kernels (would move their crossovers left); a device-side benchmark row via gpu-timer to split kernel time from upload/readback; Playwright coverage of the algorithms-gpu suite on WebKit once its compute story firms up; and revisiting GPU_MIN_N constants when a second machine's published sweep exists.

65.8 — the kernel performance pass (2026-08-10)

"See how far you can push it." The 65.6 sweep's modest tier had a common signature once read as a profile rather than a scoreboard: kernels launching one invocation per row, column or cluster — n threads total, two workgroups at n=512, a >95%-idle device. The pass was therefore mostly an occupancy pass, plus three structural items, each verified against the parity suite and re-measured in isolation before moving on:

  • Workgroup-per-line rewrites: pageRank's mat-vec (256 lanes stride the row — fixing coalescing and occupancy in one move — and tree-reduce), AP's responsibility/availability updates (the R row's top-two reduces with a (value, index)-lexicographic champion, reproducing the CPU's ascending >= tie-break exactly), the k-means/fcm centroid updates, and k-medoids' cost/pick kernels (argmin by (cost, index) — the CPU's first-minimum rule).
  • Dispatch-count surgery: pageRank's epilogue (sum, scale, diff, converge) fused into one single-workgroup kernel — after the matvec fix, per-dispatch overhead × 200 encoded iterations was the dominant term; AP's tracking similarly fused from four kernels to two. workgroupUniformLoad joined the flags discipline: it makes the converged bit uniform, so barrier kernels may return whole workgroups early instead of running converged iterations to completion and discarding the stores.
  • Blocked Floyd–Warshall: the per-k chain (2n dispatches; most of the n=1024 run was dispatch overhead) replaced by the textbook three-phase blocked formulation — 32-wide k-panels, four dispatches per panel, shared-tile working sets, and a per-cell best-k register accumulation in phase 3. The 5-node parity fixture is a single phase-1 block, so a multi-block n=100 spec joined the suite — distance parity plus path-sums-to-its-own-distance consistency (tie-tolerant where f32/f64 executors may route differently) — and its control (phase-3 relaxation weakened) fails exactly it while the 5-node spec stays green, which is the reason it exists.
  • Brandes batching: 256-wide source batches, and a per-level check kernel that latches a frontier-empty bit the moment a level assigns nothing — every remaining encoded level no-ops, the between-chunk readback becomes a 16-byte completion probe carrying the deepest level (which sizes the dependency sweep exactly), and a batch costs two submits and one probe instead of a sync per 32 levels.
  • The one CPU finding: profiling AP showed ~640 ms of its n=1024 run was fixed cost — the shared similarity build evaluated the attribute accessors per pair (n²·d calls, the round-18/62.2 rule violated in code that shipped citing it) and took a comparator-sorted median of a million-entry plain array. The build now materializes vectors once, runs named metrics as a tight symmetric typed-array loop, and math.median sorts typed input without a comparator (identical ordering — non-finites are rewritten before the sort). The CPU executor got faster too, and the stats helpers' widened signatures displaced min's doc block onto the new type alias — the stranded-block hazard's second appearance this round, caught red by the JSDoc gate both times.

Re-measured, one run, same machine (gpu ms, cpu×):

markovClustering 47.2 ms at n=1024 663× (65.6: 65.3 ms, 478×) kMedoids 11.8 ms at n=4096 146× (61.3 ms, 37×) fuzzyCMeans 39.6 ms at n=65536 70× (306.6 ms, 7.8×) floydWarshall 47.2 ms at n=1024 28× (72.2 ms, 17×) kMeans 26.5 ms at n=65536 25× (237.9 ms, 2.4×) betweenness 35.5 ms at n=2048 18× (182.3 ms, 3.6×) affinityPropagation 298.8 ms at n=1024 3.8× (1109.8 ms, 1.43×) pageRank 75.4 ms at n=2048 1.65× (93.1 ms, 1.33×) hierarchical (unchanged ~2× — the CPU merge chain is the floor both executors pay)

Every family is now GPU-favored at every benchmark size, and the 'auto' thresholds moved left accordingly (pageRank/betweenness 1024 → 512, AP 1024 → 256, kMeans 2048 → 1024, fcm 1024 → 512, kMedoids 512 → 256). What was deliberately left on the table, logged in the round's open follow-ups: transposed R/A copies to coalesce AP's column walks (~2.6 ms/iteration remains), 4×4 matmul register blocks (shared-memory-limit tradeoffs), and pageRank's dense mat-vec, which is now genuinely memory-bound — its real headroom is a sparse CSR formulation, which would change the memory story rather than the kernel.

65.9 — every benchmarked thing reaches the status site (2026-08-10)

The GPU sweep measured well and published nowhere: an ad-hoc JSON shape in gitignored results/, invisible to the report, the publish machinery and the archive — while the site's algorithm rows showed only the Node-tier CPU executor, from runs that predated round 65 entirely. The maintainer's directive: all the benchmarked things represented.

  • The sweep is a first-class producer on render-bench's exact pattern: standard results shape (one job per family × size, benches cpu / gpu / gpu first call — the last carrying the once-per-page pipeline-compile stall the steady-state rows exclude), raw sample arrays through toStats, buildMeta provenance with the adapter identity, its own algorithms-gpu profile. Publish, the index, pruning and compare-page grouping needed zero changes — the round-46.5 machinery was already generic.
  • The report learned the second pair form: exact-named cpu beside gpu is a comparison pair (both sides v4) next to the classic v3/gpu one. Dumbbells, the speedup overview, the scaling table (which now reads families × sizes for the sweep — 72×/300×/ 642× across MCL's three sizes on one row) and the tiles all light up; legends, tooltips, the pairs tile and the page tagline name the baseline honestly per run, and the run-time tile stops mislabeling 'all'/'renderer' runs as 'quick' (hardcoded since 46.5). The compare page's v3-twin noise control is deliberately not extended to cpu twins: v3 is frozen code, the cpu executor is live v4, and calling it a noise control would launder real regressions.
  • Three fresh runs published (quick, all, algorithms-gpu — commit 1f324088, plus the quick run on d4ff0730), so nothing on the site predates the round. The first ladder design published between measurements and tainted its own second run's provenance — the dirty-tree refusal caught it, working as built; ladder 2 measures everything first and publishes at the end.
  • Reading the log, not the exit code, found a round-42 corpse: style-bundle.mjs has been a module-level SyntaxError since the restructure's factory-rename sweep collapsed its two bundle imports onto one identifier — published as a recorded failure in every --all run since, its rows (the one measurement of style reads through the built bundle, the round-34 __name lesson's suite) absent from the archive the whole time. Fixed (cytoscapeV3), and un-masking the compile error un-masked the guard beneath: the suite needs v3's ESM bundle, which the parity harness's UMD-only build does not produce.
  • The new compare-all page did its job on arrival: it surfaced a +17–34% mover cluster in the curve-premium rows (no v3 twin) between the pre-65 run and the ladder's. A solo re-run on the idle box read 2.2 ms where the ladder run read 4.43 (old run: 1.89) — the cluster is the box running hot late in an 18-minute profile, not a regression; the drift/twin framing exists for exactly this readout, and rows without a twin are where it must be done by hand.

Verified: the built site driven in Chromium (benchmark index lists all four profiles; the sweep page renders 27 dumbbells, the cpu legend, the scaling matrix; screenshots taken), module tier 344/344 including the three new cpu-pair specs and their inexact-name control.

65.10 — the CPU takes two families back (2026-08-10)

The maintainer's directive after the wasm/SIMD/threads assessment: build the plain-JS items, log the worker pool (ledger item 29). Both plain-JS items landed, and both ended somewhere more interesting than "faster":

  • The hierarchical merge chain went flat — distance matrix, min pointers, active order and sizes in typed arrays, the merge structure as a (left, right) log replayed iteratively at the end (a single-linkage chain makes the tree n deep; recursion was the tarjan lesson waiting to happen). Custom linkage functions keep the object path — they need live Collections per merge. Semantics pinned to the object path exactly: lower-triangle min seeding, first-in-order tie-breaks, the stale-min repair rule, in-order member order. Then the profile said the matrix build dominated, and the 65.8 AP-build treatment (vectors once, metric inlined — Math.pow(x, 2) and x·x round identically, so entries are bit-identical) closed that too. Net: CPU 73/306/1245 ms → 30/103/435 ms at n=1024/2048/4096 — 2.5–2.9×, and now a wash with the GPU (0.92–1.03×), whose only edge was the matrix build the CPU just matched. 'auto' routes hierarchical to the CPU.
  • En route, a v3 defect: mean linkage never worked — its size field is read by the weighted-average formula and never assigned, in v3 and the port alike, so the first mean merge wrote NaN distances and froze those rows out of every later merge (v3 tests only exercise min). v4 now tracks sizes and deviates deliberately; two specs discriminate both the NaN behavior and a dropped size weighting, and both controls were run red.
  • PageRank went sparse: temp = M·v decomposes into an O(E) edge gather plus two rank-1 terms (damping teleport, dangling mass), so an iteration costs O(E + n) instead of O(n²). On the sparse bench fixture the CPU dropped from 30–124 ms to 0.3–0.6 ms; at E = n²/12 — dense enough that the GPU's mat-vec was expected to win — the sparse CPU still measured ~5× ahead, because denser graphs converge in fewer power iterations. 'auto' therefore never routes pageRank to the GPU; the kernels stay for an explicit executor: 'gpu' and the parity suite, and the revisit that could change the verdict is a sparse SpMV kernel. A pageRankDense family joined the sweep so the losing configuration stays measured rather than asserted.
  • The status site's benchmark archive went wide (maintainer request): the shell gained a wide page variant that lifts the 82ch prose cap while keeping paragraph caps, and the run table now uses the screen it is given. A follow-up (2026-08-11, same request) made its rows single-line unless the note needs more: every column but the note holds a short fixed-shape value, and each was wrapping because the browser could shrink it to make room for the note — so a run with a one-line note, or none at all, still cost a two-line row. BENCH_CSS pins those columns to nowrap and leaves the remainder to the note. Measured at 1440 px: nine of thirteen rows fell from 50 px to 31 px, the four that stayed tall having notes that genuinely wrap. The note's min-width: 46ch is the other half — without it the fixed columns win the layout on a narrow screen and the note is squeezed into a ribbon (760 px: a 193 px note column, rows five lines deep — worse than before), and with it the table overflows and .table-wrap scrolls, which is what that wrapper exists for.

The round-65 scoreboard reads differently after this: the "GPU-modest tier" is gone — not because the GPU got faster, but because the CPU implementations stopped leaving their own headroom on the table. The honest routing table now sends pageRank and hierarchical to the CPU under 'auto', and the GPU keeps the seven families where it wins outright.

65.11 — the comparison's movers, re-investigated: the instrument, not the library (2026-08-11)

The maintainer asked what the cross-commit pages are reporting as performance regressions. Across the four (machine, profile) comparisons the mover tables flag 56 rows as slower than the previous run beyond +10%. Every one of them was traced, and none survives as a library regression — four instrument mechanisms account for the list, three of them measurable on this box in an hour. What follows is the evidence, because a verdict of "noise" that is not measured is worth no more than the alarm it dismisses.

  • The mover threshold sits below the harness's own repeatability. Eight back-to-back runs of index.mjs at one commit (identical code, idle box): 14 of 35 v4 rows span more than 10% across the eight, 8 span more than 20%, 3 more than 30%. The v3 rows — frozen code, same processes — span >10% just as often (15/35) but never exceed 20%, which is the shape to expect when the noise is proportional and v4's ops are ten to ten-thousand times smaller. mut: position set [gpu] reads [50.7, 66.1, 66.1, 61.0, 69.3, 64.0, 47.6, 68.9] ns over those eight — two clusters, not a trend — and the all profile's headline "+39% regression" is that flip caught between two published runs. A ±10% flag on the v4 side is therefore a coin toss for two rows in five.

  • The round-62 step in the core+collection series is 62.5c's own pre-warm. Every row that steps at cf5727b4 steps on the v4 side onlycore: filter(fn) 263.5k → 344.2k ns with its v3 twin flat at 423.7k → 416.3k, and the same shape for same(), collection(), degree(), outgoers(), neighborhood(), iter: filter(fn), nodes ($("node") vs filter({group})). Two controls place it:

    1. Remove the eight alternations at HEAD and the old numbers come back. core: filter(fn) falls 352.2k → 261.8k, against 263.5k measured at 6df994f1 before the pre-warm existed; outgoers() 490.2 → 436.7 (was 439.9); neighborhood() 963.7 → 898.2 (was 886.5); degree() 28.4 → 25.9 (was 24.1); iter: filter(fn) 133.6k → 117.1k (was 101.6k); same() 15.0 → 14.0 (was 12.5). The v3 side moves ±2% under the same control.
    2. The library is flat across the whole of round 62 when probed in isolation. A fixed probe — one library per process, monomorphic call sites, best-of-9 — run against ten worktrees from 6df994f1 to cf5727b4 and HEAD: cy.collection() 46.4 → 47.8 → 47.3 ns, node.degree() 23.0 → 22.6 → 20.8, nodes.same() 11.9 → 12.1 → 10.9, nodes.filter(fn) 107.0k → 109.5k → 101.9k, and node.position() 23.0 → 16.8 → 15.7 with the step visible at 90b71d6b, which is round 62.5's hot column caches doing exactly what they were written to do.

    This corrects the record rather than the code. 62.5c priced the bias it removed at "~0.5–1 ns/call on the trivial accessor rows"; measured, the pre-warm costs the v4 side 12–35% on any row that iterates, because the shared closure's inner per-element call site goes polymorphic too, so the cost scales with elements touched rather than with calls. It lands on v4 alone because v4's per-element work is small enough for a polymorphic dispatch to be a large share of it and v3's is not. Whether the pre-warmed number is the more honest one is a question, not a defect: it removes a real order bias between the two sides, and it also measures v4 in a state no application produces, since an app loads one library. What is not defensible is the published series crossing a methodology change with nothing recording it — the step reads as a regression to every later reader, and did.

  • One-shot rows still lead the tables. 17 of the 56 flagged regressions are rows with samples = 1 — twelve of the renderer profile's sixteen — the class round 62.7 already ruled out as a regression signal and wrote into src/README.md. The comparison page carries no mark for it, and ranks by magnitude, so the all profile's number one (curve premium: cy.elements().boundingBox() x 20, +52%) is one measurement against one measurement. 49 of the 791 rows in an all run are one-shot (curves 22, arrows 21, labels 6).

  • The renderer's device pair flips between two modes, and the cause is still open. (This entry was rewritten on 2026-08-11 after round 65.12 measured it. The original said the sampler was the cause — panScenario recorded gpuFrameMs only when the value changed, so a two-mode series would contribute one sample per transition — and that was wrong. The sample counts say so: the old sampler already yielded 120 of 121 frames, because the per-frame values jitter rather than repeat. The dedupe-by-value was worth removing on its own terms, and 65.12 removed it, but it never explained the flip. Recorded in full because a wrong mechanism confidently written down is worse than an open question.)

    What is measured: across the four published runs the peak-slot p50 reads 460 µs, 1.16 ms, 465 µs, 1.17 ms with no relation to the commit, and the page ranked the flip first at +152%. Within a run the row is stable — at e37d2444 min through p75 all 0.46 — so this is bistability per run, not per frame. And 65.12's verification run pins it tighter still: in one process, the ten scenes split 0.46/0.47 (five), 0.57, 0.64, and 1.10/1.17/1.18 (three), while the compacted twin reads 0.98 in every one of them. So it is per scene instance, not per run, machine or driver.

    The clue worth following: where the peak-slot row reads 0.46 it is twice as fast as its compacted twin, which is backwards — the peak-slot pan walks a 25 000-slot instance range with 2 500 live, the compacted one walks 2 500, and the cull pass covers the whole range either way. A number that is too good usually means the measurement is covering less, not that the work is. GpuTimer.read reports the span from the earliest begin to the latest end across whichever passes have non-zero timestamps, skipping any pair that comes back zero — so a frame whose render pass reported no timestamps would yield the cull pass alone and land exactly where 0.46 does. That is a hypothesis with a mechanism, not a finding: proving it needs the timer to report which pairs contributed, which is its own pass.

    pick: hover while panning is a different and simpler problem — 25 samples, a 100 µs-quantized clock, min 0 · p25 100 µs · p50 400 µs · p75 43 ms · max 73 ms on the ndex scene, where one sample crossing the median prints as +100%. 65.12 took it to 80 samples. Note what that verification could and could not show: on the generated 25k × 50k scene the p50 was already stable (17.4 ms at 25 samples, 17.3 ms at 80), so the fix is justified by the ndex scene's distribution rather than demonstrated on it.

  • The algorithms-gpu movers track the sweep's composition. 65.10 changed three files under src/algorithms/: src/algorithms/hierarchical-clustering.mts, src/algorithms/page-rank.mts, and one purely additive export in src/algorithms/clustering-distances.mts. Nothing on the kMeans / kMedoids / fuzzyCMeans / floydWarshall paths the page flags at +11–31%. Two fresh runs at HEAD, same code, same adapter: fuzzyCMeans n=16384 reads 12.8 and 12.8 ms against the published pair's 12.4 → 16.3 (the "+31%"), kMeans n=16384 8.0 and 7.8 against 7.7 → 9.0, and kMedoids n=1024 4.1 and 3.2 — a ×1.28 spread on identical code, bracketing both published values. Where a systematic shift is plausible at all (floydWarshall n=1024, HEAD 46.4/50.4 against 42.1 before), it tracks the sweep gaining the pageRankDense family ahead of those families in one shared page and one device, not any source change. That profile's mover list also has no noise control at all: twinOf in report-compare.mjs looks up a bench named v3, and 65.9 taught report-html.mjs's pairOf about the cpu baseline without teaching the comparison, so all ten rows print "no v3 twin" while their cpu twin sits in the same file.

  • One row is left open, with two suspects excluded. core: collection() steps 57.1 → 68.2 ns at cf5727b4 and stays there (66–68 at HEAD). The pre-warm does not explain it (65.9 with the alternations removed), and neither does round 64's arity guard — deleting the arguments.length check at HEAD reads 65.4 and 66.6, i.e. free — while the isolated probe holds the constructor flat at 46–48 ns across the same span. So it is a suite-context cost of ~10 ns on a 57 ns row, not a library one, and it is the only flagged row this pass could not attribute.

What the pass recommends (none of it applied here — changing the instrument changes the record, which is the maintainer's call): mark samples = 1 rows in the mover table or drop them from it; give the comparison a per-row noise envelope from two same-commit runs instead of a flat ±10%; teach twinOf the cpu baseline; record a harness-methodology epoch in the published index so a step like 62.5c's is attributable at a glance; sample gpuFrameMs per frame rather than per change; report the pick scenario's deferral share instead of a 25-sample p50; and give cmpMutEle (collection.mjs), cmpMut (core.mjs) and cmpMut (mutators.mjs) the 62.5c pre-warm — they still share one op closure between the two sides, which is the bias 62.5c exists to remove and the shape mut: position set is bistable in.

65.12 — the instrument gets the rules 65.11 wrote down (2026-08-11)

The maintainer's concern, put plainly: the status site must not suggest performance has been degrading when it has not. Round 65.11 established that it had been doing exactly that — 56 flagged regressions across four comparison pages, none of them the library — and proposed rules. This round builds them, because a rule that lives in prose is read once by the person who already knows it.

The principle the whole round reduces to: a comparison may display a change only when it can name what was held constant. Machine (enforced since 46.5), harness (new), and sampling (new). Anything else is a value, not a change.

  • --repeat 3, and the number is not arbitrary. The runner runs each job N times in N processes and publishes the per-row median, carrying one repeat's whole stats object — the median by p50 — rather than a per-key median, so no published row is a distribution assembled from parts. Beside it goes repeatSpread, the band the repeats spanned, which is the per-row noise the comparison screens against. Measured over the eight identical-code runs 65.11 left behind: single-vs-single flags 28 of 245 row pairs beyond ±10% (worst +48%); median-of-3 flags 0 of 105 (worst +9%). Two is not enough (10% still flagged — these rows are bimodal, so a 2-run aggregate lands between the modes), and min-of-3 is no better than no aggregation at all (11%), because best-of takes the fast mode whenever it appears. Three is the smallest N that reports the majority mode. An even count resolves to the slower middle value, deliberately: an even split is where the evidence is weakest and flattering the library there is how a benchmark stops being one.
  • The harness fingerprint (benchmark/harness-id.mjs), the machine fingerprint's twin. Each job carries a hash of the suite file, its ./-relative import closure (which is what makes index.mjs answer for core.mjs and collection.mjs, where its benches live) and the shared inputs — never src/, which is the subject. buildComparison refuses a change across two hashes and renders ⋮ harness, exactly as it refuses one across machines. Nobody declares an epoch; editing a bench file starts one.
  • What the hash ignores, and why that mattered immediately. The first thing it would have flagged is round 57.2 — oxfmt reformatted every benchmark file in one commit and moved no number. A break nobody believes is worse than no break, so the hash is over comments-stripped, whitespace-normalised text, and the control is a spec: a reformat is inert, a changed constant is not. What survives normalisation can be declared in EQUIVALENT_HARNESSES with a reason — the shape of throw-coverage.mjs's exemption lists, and audited the same way, so an entry naming a hash the archive no longer carries fails rather than sitting there reading as permission.
  • The archive was backfilled from git (scripts/benchmark-backfill-harness.mjs), so all thirteen published runs carry the hashes their own commits imply. An unstamped run is treated as unknown, never as unchanged.
  • The mover table screens, and the page leads with the verdict. A row that moved is a regression only if it clears its own measured band; a one-shot row (samples = 1) never is — round 62.7's rule, enforced rather than written down. Everything else goes to a collapsed unscreened list with the reason stated, ranked nowhere. The verdict sits above the tables and refuses to overclaim in the other direction too: "no row regressed" and "no row could be compared" are different sentences, and a page spanning a harness change says the second. twinOf also learnt the cpu baseline, which report-html.mjs was taught in 65.9 and this module was not — all ten algorithms-gpu movers had been printing "no v3 twin" with their twin in the same file.
  • The two renderer samplers — and what verifying them corrected. panScenario recorded a device time only when the value changed, which cannot distinguish a repeat from a stale reading; GpuTimer counts its readings now and RendererStats.gpuFrameReadings exposes the counter, which is what a sampler needs (a value cannot say "I am new"). That is right in principle and it is not what caused the +152% flip, which 65.11 had claimed: the run that verified it reads 121 samples where the old sampler read 120, because the per-frame values jitter rather than repeat. The 65.11 entry above is rewritten with the measurement and the flip is back to being an open question, with a hypothesis (the timer's pass-span may be covering fewer passes in the fast mode) and the observation that makes it interesting: in one process the ten scenes split 0.46–0.64 ms and 1.10–1.18 ms while the compacted twin holds 0.98 throughout, and where peak reads 0.46 it is faster than a pan over a tenth as many slots. The pick scenario went from 25 samples to 80, on the same reasoning as before but with its evidence stated properly: it is justified by the ndex scene's 0 → 73 ms spread, and the scene the verification actually ran was already stable at 25.
  • The three mutation helpers got the 62.5c pre-warm they never had. This was the cause of the archive's most bistable row, not only its symptom: mut: position set spanned 47.6–69.3 ns in two clusters over eight runs, and with the pre-warm it measures a 2.7% band across three repeats at 52.0 ns.

What the pages say now. Re-rendering the existing archive through the new comparison: 0 regressions on all four pages. Not because the tables were emptied — because 81 of the algorithms-gpu profile's rows and 184 of quick's are behind a harness break (the sweep gained the pageRankDense family; the core suites crossed 62.5c), and the remainder are listed as unscreened with their reason, the archive having been measured without repeats. The mechanism reproduced 65.11's two manual findings on its own, which is the argument for having built it: the algorithms-gpu movers were a sweep-composition artifact, and the quick profile's step was 62.5c, and neither conclusion now depends on someone remembering.

The acid test, and what it cost to run honestly. Two --repeat 3 quick runs at one commit, on a clean tree, 22.6 min each — identical code, so every change between them is false by construction. The comparison reports 0 screened regressions and drift +0.1% over 219 rows. Six rows moved beyond ±10%; five were caught by their own band.

The sixth is the number worth publishing: mut-bulk: select + unselect moved −11.3% where run A's three repeats had agreed to 1.3% and run B's to 9.2%, so the screen let it through. That is not a bug to tune away — a three-repeat band is a sample of the noise, not the noise, and a row whose distribution has a mode three draws can miss will occasionally clear it. Stated as a rate: 1 false flag in 219 rows, against 6 under the flat ±10% the page used before. The pair is published as replicates with notes saying so, and the mover row carries its band in the evidence column, so a reader sees the same thing this paragraph says.

The instrument also measured its own blind spot. Of 106 v4 rows, 94 (89%) have a band tight enough to detect a real +10% regression; 12 do not, 3 are worse than 20%, and the worst is sweep: elements() at 4.9 ns with a 551% band — round 62's sub-floor rule, arriving as a measurement instead of a warning.

A first pair was discarded before publishing: both runs carried dirty: true, because a report-html.mjs edit sat in the tree when buildMeta stamped them. That file cannot touch a number and is excluded from the fingerprint by design — and publishing the run that establishes the epoch's noise floor with an asterisk on it is exactly the asterisk this round exists to remove, so the pair was re-run on a clean tree.

Verification: 2167 test:js, 384 test:modules (25 comparison specs, 29 new for the fingerprint and the merge, 2 for the index marker), 24 soak, throw gate 213/0 never-run, JSDoc 100% with the new gpuFrameReadings documented, types regenerated, lint and format clean. Eight controls run red, each failing exactly its spec — the formatter rule, the import walk, the whole-stats merge, the union shape, the ledger audit (both entries), sameEpoch forced true, screenOf forced true, and the cpu-twin fallback.

65.13 — "the debug page doesn't work with the binary networks" (2026-08-11)

The report: the hosted harness fails on the networks that load from a fixture — enrichmentmap named as the example — while the generated ones are fine. The investigation found the binary path correct, and the message it prints wrong in a way that produces exactly that report.

What was measured before changing anything. Every fetched fixture, encoded and decoded through the page's own two functions and compared column by column against the JSON path: 26,173 nodes and 574,515 edges, no column lost, no value changed, positions within 2.3e-4 of f32, edge endpoints identical. Then the page itself, driven in Chromium on both servers — the JSON tree on 3333's shape and the built status/ on 3335 — for all fourteen networks: identical node/edge/glyph counts, and the two screenshots of em-web differ by 20 pixels of 765,000 (0.003%), which is antialiasing. Picking, a data query, a click-select and a wheel-zoom all agree across the two paths. The ?columnar=true and ?binary=true toggles work on every fetched network, up to the 465k-edge one. A six-day-old encoder round-trips every fixture too, so the format is as tolerant as its version rule claims.

The defect is the error handling, and it is binary-specific by accident. init.js called loadNetwork inside the fixture's promise chain, under a single .catch( fail ). So every error the library threw — the sheet compile, cytoscape(), the layout — was reported as a fixture failure, and for a wire-loaded network the text was "A decode failure means the buffer and the library disagree — rebuild the site". The eight generated networks build outside any promise, so the identical failure surfaced there as itself. A WebGPU problem therefore reads as "the binary networks are broken, the built-in ones are fine". Reproduced in Firefox (no WebGPU): em-web printed the decode message, v3-default printed the real one.

And a second, worse one underneath: the stats overlay erased the error. startStats rewrites #stats every 500 ms, and the cy.ready rejection — the most common real failure, no GPU adapter — wrote its message there. Measured in plain Chromium with no adapter: the console carried "no adapter could be acquired" and the page showed a blank canvas above "569 nodes, 6899 edges, 0 glyphs". The one thing a harness owes you, it was deleting twice a second.

Landed:

  • debug/load-error.js — the message, with the phase passed in by the caller that knows it rather than guessed from the error: network (the fetch never completed), http (the server answered, badly), decode (bytes arrived, would not parse), init (the data is loaded and the library failed). Only decode may blame the wire buffer; init says how many nodes and edges came through and that the data is not the suspect.
  • init.js restructured so loadNetwork runs outside the fetch chain, and showFatal/showStats make a fatal message stick — the overlay cannot overwrite one.
  • A file:// diagnosis, because opening status/debug/index.html from disk breaks every fetched network and nothing else, which is the reported symptom exactly. It now says so and names the two servers.
  • The encoder uses the bundle the page loads. wire-fixtures.mjs encoded with build/cytoscape.cjs.js while debug/index.html decodes with build/cytoscape.umd.js, and its own comment claimed that made a mismatch "impossible by construction". It did not: npm run watch rebuilds the UMD alone and npm run status builds nothing, so a tree can hold a fresh UMD beside a CJS from any earlier commit. Latent, not broken — but the invariant the comment asserted is now the one the code has, pinned by a spec that reads the page's script tag.

Verification: 7 new specs in test/modules/debug-harness.mjs and 1 in status-site.mjs (388 test:modules, 0 fail), and three controls run red — every phase given the wire-decode hint fails exactly the four specs its comment names, renaming a phase string fails the routing spec, and pointing the encoder back at the CJS fails the bundle spec. All fourteen networks re-driven on both servers after the change, plus the four failure paths: no-adapter Chromium, Firefox, file:// and a 404 each report their own cause now.

What is not explained. Nothing measured here makes a binary fixture fail on a working browser, so if the page is still blank after this, the message on it is now the evidence — it will name the phase.

The definition-form load takes the columnar path

The question that started it was about the debug harness: why aren't "Columnar load" and "Binary wire round trip" checked by default, and would they speed up first load? The answer to the first half is that they are instrumentation — the harness's default should be the compat path most apps use, and the two boxes exist so a person can A/B the three ingest forms in a browser. The second half is where the round came from.

What the demo measured. ndex-x-large (19,607 nodes / 464,657 edges) in Chromium on a real adapter (AMD GCN-4, not SwiftShader), fetch start to first rendered frame, p50 of 3 fresh page loads, all paths fingerprint-checked identical (counts, positions, data(), computed background-color/line-color, post-fit zoom):

path fetch+decode convert init ready fit+frame total
JSON (npm run watch) 268 104 2018 87 400 2899
wire (the status build) 37 95 2101 91 420 2754
wire → columnar, no fromColumnar 37 17 1215 86 401 1762

The harness change that motivated the table — stop converting the decoded columnar payload back to definition form for the sheet builders — was worth 78 ms of it. The other 886 ms was the factory ingesting definitions instead of columns, and that is not the demo's, it is the library's. Confirmed on synthetic graphs with no debug/ code: converting to columnar and ingesting that beat ingesting the definitions at every size from 4k to 800k elements, with and without data columns (1.28–1.70×), the conversion costing 7–10% of what it saved.

Why, from a CPU profile of a 50k/150k load (924 ms defs vs 580 ms columnar), as self time the def path pays and the columnar path does not: Table.alloc 159 ms, _addDefs 54, the IdIndex (allocSlot, get, probe) 76, addEdge 38, setDefData/markDataWrite 21 — against ~85 ms for the whole conversion. It is structural, not a micro-optimisation: ~350 ms of per-element work (a slot allocation each, two id lookups per edge, whose get allocates a result object per hit) traded for one pass that hands the store columns. GC is the same on both sides (134 vs 127 ms), so this is not allocation pressure in general.

Landed:

  • buildColumnar( part, strict ) in columnar.mts — the conversion over already-partitioned defs, in two modes. The public toColumnarElements is the strict one and still throws on an endpoint it cannot resolve; the loader's is not, and answers null instead, so a payload that is not self-contained simply is not this route's and the definition path raises the error in its own words. No try/catch around the converter, deliberately: a real bug in it must not read as "not a candidate" and silently cost 1.3× forever.
  • The flag deviations no column can carry. A def may set locked, grabbable and pannable; the columnar form has columns for selected and selectable only. Converting blindly would have dropped three flags silently — the exact shape of 46.5's lost dictionary column. buildColumnar returns the deviating defs by index and _addColumnar writes them, before the style pass (::locked and ::grabbable are styleable conditions, and that is where the def path has them). This also leaves toColumnarElements lossy for those three, which its doc comment now says out loud.
  • _bulkAdd routes, _addPartition splits _addDefs so one partition serves whichever route is taken, and cy.add() is untouched: adding into a populated graph may legitimately name nodes already there.

Measured after (same machine, order alternated, each side its own process, 5 reps): definition-form cytoscape({ elements }) 23.6→19.3 ms at 1k/3k, 173.7→146.5 at 10k/30k, 848→624 at 50k/150k, 4134→3173 at 200k/600k — 1.19–1.36×, models identical at every size. benchmark/load.mjs's warmed init row (N=2000) moves 24.95→16.64 ms, taking it from 6.28× to 8.87× v3 on this machine. The debug page, with no harness change at all: JSON 2899→2163 ms, wire 2754→1944 ms. And benchmark:renderer's ndex-x-large scene — lean defs, no edge ids, a real adapter — 1696 → 980 ms, which is 11.4× → 19.0× v3 and the figure EXECUTIVE_SUMMARY.md's headline quotes.

The spread is the payload's, and one guess about it was wrong. The gain runs 1.19× (200k/600k with data on both groups) to 1.78× (the lean ndex shape). The first hypothesis — that auto-generated ids would shrink the gain, since both paths must mint 465k strings — was refuted by measuring it: dropping edge ids at 200k/600k took the ratio to 1.66×, the largest of the three variants, not the smallest.

A near-miss worth recording, because it nearly published a wrong number. The first baseline for that renderer row read 977 ms against 945 — a 3% gain that contradicted everything else, and was taken as a finding to explain. It was git stash push -- src/ followed by a benchmark that serves build/cytoscape.umd.js: stashing source does not rebuild the bundle, so both runs measured the same code. The trap is the one AGENTS.md already documents for Playwright ("rebuild the bundle before trusting a run"), and it applies to every browser harness here, not just that one. A baseline for a browser measurement has to swap bundles, not sources.

Verification: 14 new specs (13 in test/bulk-load.mjs, parity between the two routes over a fixture carrying every def feature — including a column-for-column comparison over every column in columnSpecsForGroup rather than a hand-picked four; 1 in test/columnar.mjs, below), 2181 test:js, 394 test:modules, 24 test:soak, 250 Playwright across every project, all audits at 100%.

Three controls, and two of them found something.

  • Not writing the flag overrides fails exactly three specs — including the column-for-column parity — which is also the proof that the new route is the one being taken.
  • Making the converter throw instead of answering null fails exactly the fallback spec, on the message.
  • Moving the flag write after the style pass failed to fail. The spec was named for the ordering and could not observe it: setFlag notes a condition-flag change and the slot restyles, so a late write is correct, merely wasteful. The spec has been renamed to what it actually asserts and the comment now says which control does land.
  • The throw gate went red on graph-store.mts:1313, the columnar positions-length guard. It was never run before this round either: instrumented, it executes zero times across the whole Node suite on both sides of the change, so its previous green was line-coverage misattribution — the gate's documented blind spot — and routing def loads through addNodesColumnar moved the attribution and exposed it. It now has a deterministic spec (ids must be unique, or the duplicate-id guard fires first).

Not done, and now smaller. The harness change that raised all this — have debug/init.js hand the decoded columnar payload straight to the factory instead of fixtures.fromColumnar, with a columnar-aware extent/magnitude for the two sheets that scan node data (em-web, em-desktop; 569 and 1260 nodes) and fromColumnar kept for the one network declaring a derive — is worth 167 ms of the hosted page's 1944 now, not the ~990 it would have been. (Restated after 66.1, which moved the page underneath this sentence: ~160 ms of 1756. The rule this file keeps tripping on is that a forward-looking number reads exactly like a measured one a week later.)

66.1 — the mapped apply costs per distinct value, not per element (2026-08-11)

Round 66's own decomposition left one item standing: of the harness page's remaining init, 340 ms was style mappers over 465k edges. Asked which mapper, the answer was one — and it is not the one the figure suggests.

What each clause costs, same fixture and payload, only the sheet varying (headless, median of 3):

sheet init delta
the production sheet, as shipped 1340 ms
edges: line-color constant 986 −354
edges: both onSelected opacities constant 1290 −50
nodes: width/height/background constant (3 case mappers) 1372 +32
nodes: no label 1345 −5

The three node case mappers and 19.6k labels cost nothing measurable; the whole 340 ms is the edge line-color diverging mapper, at 762 ns per edge. A profile diff said where that goes, and the surprise is that the colour arithmetic is the small half: linearToByte 31 ms, oklabToSrgb 17, continuousEval 20 — against ~218 ms of per-element apply machinery (applyMapped, applyGroupDef, bindCase, writeChannels, setStyle) and 29 ms of GC. The reason is partitionOf: one data-driven mapper denies the whole group the round-57.1 flag partition, so the two { selected: true } opacity clauses are evaluated per edge as well.

Landed, both exact — every computed value identical, goldens and parity untouched:

  • A per-value memo on continuous/discrete evaluation over a numeric column (style-scales.mts). The fixture has 1,920 distinct Mechanism_of_Action values over 464,657 edges, its two commonest covering 43% of them, so the work was being redone ~242× per distinct input. Sharing one evaluated array across elements is safe for the same reason the channel defaults already are: the write path folds into fresh arrays and never mutates what it was handed.
  • The state hoist (style.mts): a group without a partition still evaluates its state-only case mappers only when flags & mask changes from the previous slot. partitionOf's comment had said there was "nothing to win by partitioning the rest" once a data mapper is present; measured, there was — and more than expected, because what goes away is not just the evaluation but the per-element closure calls and condition reads behind it.

Measured (same machine, alternated, own process, 5 reps): the fixture's init 1364 → 1149 ms (1.19×); the hoist is ~219 ms of that and the memo ~23–58 on top (they overlap). In the browser, the harness page's fetch-to-first-frame: JSON 2129 → 2022 ms, wire 1914 → 1756. benchmark:renderer's ndex scene does not move (936 → 930), correctly: its defs are lean and its sheet has no mappers at all, so there is nothing there for either change to improve — which is also why that scene could not have found this.

The memo's own control decided its shape. A first version memoized unconditionally: +8% where data repeats, −3% where every value is distinct. A second gave up adaptively once misses ran ahead of hits — and measured no better, because the wrapper costs ~5% on 200k distinct values whether or not it still consults its map. The shipped version decides once, from a 512-value sample, and returns the original direct closure when a memo will not pay: all-distinct back to parity, repeating still ~9% ahead.

Verification: 8 new specs (4 in test/mappers.mjs for the memo — same values as an uncached evaluator, no value carried across an auto-domain change, fallback for an absent value; 4 in test/state-conditions.mjs for the hoist, interleaved and in runs, plus the control that a sheet whose state mapper cannot be hoisted agrees element for element). 2189 test:js, 394 test:modules, 24 test:soak, 250 Playwright, all audits 100%. Both controls land: making the hoist never notice a changed word fails exactly the three per-element specs, and making the memo answer with any cached value fails the continuous-scale suite wholesale.

66.2 — deferring the CPU eval of GPU-owned paint: measured, and not landed (2026-08-11)

The third idea from 66.1's investigation: at construction the CPU evaluates every mapper, including the paint channels the eval kernel is about to own — and mapperRuntime.update() "configures + fully evaluates" before the first scene pass reads them, so for a rendered instance that CPU work is redundant. Deferring it would serve all three of the round's goals at once, most directly the third (the main thread free sooner).

It is worth ~5%, for a sheet this repo does not have. Prototyped by skipping exactly the props the packer ends up owning, on ndex-x-large with a sheet whose line-color is owned: init 996 → 932 ms, fetch-to-first-frame 1487 → 1417 ms, the screenshot byte-identical and style() still answering correctly (the getter re-evaluates for an owned prop). Real, and small.

And on the sheets that exist, it is worth nothing at all. Nine of the harness's networks, driven in Chromium on a real adapter, reporting what the kernel actually owns:

network mapped paint props GPU-owned dispatches
em-web (569/6899) 12 background-color 1
em-desktop (1260/16030) 12 background-color 1
em-web-clustered (610/6899) 12 background-color 1
white-matter (1499/18288) 9 0
ndex-large (3238/68641) 12 0
ndex-x-large (19607/464657) 8 0
v3-default, node-types, edge-types, labels 12–18 0

Where ownership happens the graph is small (a background-color over 569–1260 nodes, microseconds); where the graph is large, nothing is owned. Both halves have the same cause: these sheets map line-opacity for the round-57.11 selection affordance, and a mapped channel opacity demotes line-color and the arrow colours (the B1 fold — a kernel colour program would overwrite the folded bytes); node colour is a state case, which is not packable at all.

So the deferral was not landed. It would buy 0 ms on every sheet here, and a correct version is not free: candidates are a superset of what the packer owns, so it needs a reconciliation at setGpuOwned, a recovery path for when no adapter arrives, and getters that treat a deferred prop as lazily evaluated — three new failure paths and a window in which stored paint is neither correct nor stale but absent. The prototype demonstrated exactly that hazard: skipping candidates (rather than owned props) left the node case mapper evaluated nowhere, and the graph rendered with grey default nodes — a picture that looks plausible, which is this repo's recurring failure mode.

The finding that outlives it: the blocker on the big sheets is the demotion rule, not the deferral. The kernel already folds a mapped opacity into colour alpha (FLAG_MUL_ALPHA, opacityNow from an opacity program packed first), so a continuous line-opacity need not demote its colours. What it cannot fold is a state case opacity — exactly what these sheets use — because a conditional is not packable. Teaching the kernel a flag-conditioned constant (a state case over conditions alone has one value per masked flag word, which is what round 57.1's partition already computes on the CPU) would hand it the 465k-edge colour mapper: ~160 ms of CPU work per apply on this fixture, and it would make the deferral above worth having as well. That is a round, not a follow-up.

Amendment (same day): the blocker is the alpha fold, not the case. 66.2 above closed by naming the state-case opacity as what keeps the kernel off these sheets' colour mappers. That reading was challenged and does not survive measurement. Same fixture, same sheet, only line-opacity varying, ownership read after the first frame (it is configured on the first update(), not by cy.ready — the first version of this probe read it too early and reported nothing owned anywhere):

line-opacity GPU-owned edge props
case (as shipped)
constant 1 line-color
constant 0.25

A plain constant 0.25, with no conditional anywhere in the sheet, demotes exactly as the case does: the trigger is computed.lineOpacity !== 1 || mapped('line-opacity'), i.e. any non-1 alpha, however it is spelled.

And case mappers are not the expense. Priced on the same 464,657 edges, as the cost of the line-color clause over a constant baseline:

clause cost per edge
state-only case, 1 clause −34 ms ~0 (at the noise floor)
state-only case, 3 clauses −26 ms ~0
mixed case (state + data) +31 ms 67 ns
case on a data condition +40 ms 85 ns
continuous diverging mapper +129 ms 277 ns

A state-only case is free since 66.1's hoist — it measures at the constant baseline — and even a data-conditional one is a third of what the continuous mapper costs. Moving conditionals into the kernel is therefore not where the value is; the continuous data mapper is.

The nearest real opportunity, then, is narrower and better founded than what 66.2 first named: the kernel already folds a constant edge opacity into colour alpha through domain.w, and the packer already wires it — but only for arrow props (isArrowProp( m.prop )). line-color itself never gets an alphaMul, which is why a constant 0.25 has to demote. Extending that fold to line-color (and the node fill/border pair with their own constant opacities) would make every constant-opacity sheet's colour mapper kernel-owned, and FLAG_MUL_ALPHA already covers the mapped-opacity case for arrows the same way. What neither mechanism reaches is a state-case opacity, which is what this repo's sheets use — so ndex-x-large stays CPU-evaluated either way, and that remains a question about per-element alpha in the kernel rather than about case being slow.

And the cost is on the update path, not on load (measured after the amendment above, in answer to why should anyone care). Ownership does not save CPU at construction — the initial apply evaluates every mapper whatever the kernel is about to own, which is exactly what 66.2's rejected deferral would have changed — so the two opacity spellings load indistinguishably (985 vs 968 ms). Where it shows is a data write to a mapped key, which an owned channel re-derives on the GPU and a demoted one re-derives per element on the CPU. 50,000 writes of Mechanism_of_Action on ndex-x-large, timed to the next rendered frame:

line-opacity line-color 50k writes → frame
1 kernel-owned 20–27 ms
0.25 demoted 92–119 ms

4–5×, for changing one constant. That is the argument for the fold, and it is a principle of least surprise argument as much as a performance one: opacity is the property a web developer expects to be the cheap one — compositing, not layout — and in v4 today setting it to anything but 1 quietly moves a whole colour channel off the GPU. Note what this also says about ordering: the fold and the deferral are complementary, and the fold is the one that pays on its own. Deferral without it saves ~64 ms once; the fold without deferral is worth 4–5× on every restyle for the rest of the session.

66.3 — a constant channel opacity stops demoting its colour (2026-08-11)

The amendment above ended with a claim to act on: the kernel already folds a constant edge opacity into colour alpha through domain.w, and the packer already wires it — but only for arrow props, so line-color and the node fill/border pair had to demote whenever their channel opacity was anything but 1. That is now fixed.

  • The demotion is mapped(...) only. paintInputs demoted on computed.<channel>Opacity !== 1 || mapped(...); the constant half is gone. A mapped channel opacity still demotes — its value varies per element, and the state case form this repo's sheets use is not packable at all.
  • Every colour program carries its own fold. PackInput gains alphaMul, resolved per prop by constOpacityFor (background ← background-opacity, border ← border-opacity, line ← line-opacity); arrow programs keep overriding it from paintContext, which also covers their mapped case.
  • The owned-prop getter folds too. readProp re-evaluates a kernel-owned mapper and formatted it unfolded — correct only while a non-1 opacity was guaranteed to demote. It now applies the same constant, so stored, drawn and reported alpha agree.

Measured, on the update path this was about: 50,000 data writes to a mapped key on ndex-x-large with line-opacity: 0.25, timed to the next rendered frame — 91 → 26 ms, 3.5× (three reps each side, bundles swapped). Load time does not move, and should not: the initial apply is CPU either way (see 66.2).

Exactness is the whole claim, so it is a spec rather than a golden. visual.spec.js renders one scene twice — once with constant opacities (kernel-folded) and once with the same values written as mappers with a constant range (which demotes, so the CPU folds) — asserts the two really did take different paths, and diffs the exports: zero differing pixels, style() equal on both. Degrading the fold (alphaMul dropped in packPrograms) fails it, on the pixel count. The whole suite is unmoved: 251 Playwright including 46 exact goldens and every v3-parity scene, 2192 test:js, 394 test:modules, 24 test:soak.

What it does not change: this repo's own sheets. Re-driving the nine harness networks shows ownership exactly as 66.2 recorded it — three background-colors on small graphs, nothing on the large ones — because those sheets map line-opacity for the round-57.11 selection affordance rather than setting it constant. The beneficiary is the ordinary app idiom (line-opacity: 0.4 to dim edges), which is why this landed and 66.2's deferral did not: same zero on the harness, but this one is exact, has no correctness window, and pays 3.5× wherever the pattern occurs.

Still open (unchanged by this): a mapped channel opacity. A continuous one could ride FLAG_MUL_ALPHA, which the shader already has and nothing currently reaches, since the demotion removes the colour before the packer sees it. A state case one — what these sheets use — needs the kernel to resolve a flag-conditioned constant, which is round 57.1's partition expressed device-side.

The load path, decomposed and attacked in order

Round 66 took the ndex-x-large harness page from 2899 to 1756 ms and left the question of what is next. This round starts by measuring rather than guessing, then takes the items in order, keeping only what its own benchmark supports.

The decomposition. ndex-x-large (19,607 nodes / 464,657 edges) in Chromium on a real adapter (AMD GCN-4), JSON path, fetch to first rendered frame:

phase ms whose
fetch 105 app (the wire format takes this to ~37)
JSON.parse 175 app
toGpuElements 105 harness
cytoscape( { elements, style } ) 1150 library
cy.ready 100 library
first frame 85–400 (unstable — see 67.3) library/driver
fit + frame 18 library

Inside init, through the built ESM bundle (three loads, self time): the style apply is 818 ms of 1150 (71%)_applyStyleapplyGroupDefapplyMappedwritewriteChannels; the columnar store ingest ~210; buildColumnar ~73; fit() ~30.

The round's result, measured in one A/B rather than chained (the round's starting commit 44d84999 built in a detached worktree against HEAD, bundles swapped and alternated, five browser reps and nine headless):

before after
ndex-x-large, headless init 1195 ms 622 ms 1.92×
ndex-x-large, browser init 1164 649 1.79×
ndex-x-large, fetch → first frame 2047 1569 −478 ms
synthetic 50k/150k, minimal sheet 385 221 1.74×
synthetic 50k/150k, typical sheet 454 297 1.53×

The rendered frame is byte-identical at both ends: the harness's ndex-x-large page fitted and screenshotted through both bundles, 0 differing pixels of 1,024,000.

And inside the style apply, the cost is not the mappers. Two controls through built bundles, on the edge branch of writeChannels:

edge branch init
as shipped 1170 ms
only the one mapped channel (line-color) written per edge 470 ms
skipped entirely 483 ms

The mapped channel is at the noise floor (470 vs 483). The 687 ms between them is per-element writes of ~20 channels whose value is the same for every edge in the graph. Priced directly: store.setScalar is 26.4 ns, of which 15.8 ns is column( id ) alone — two string-keyed map lookups — and 1.2 ns is the write; a fill() over the same run is 0.1 ns per element.

Not specific to that fixture: on a synthetic 50k/150k graph with all-constant sheets, skipping the edge branch takes a minimal sheet from 380 to 200 ms and a typical one from 470 to 260.

67.1 — the curve index takes one pass, not a mark per edge (2026-08-11)

CurveIndex.setStyle marks the edge's endpoint pair pending for every edge, since any one record change may re-fan a bundle. Over a whole load that is 464,657 Set inserts of a float pair key — measured at 112 ms of self time — plus a flush that iterates all of them, and it derives nothing that one pass over the finished pair map would not: at the end of a load every pair is new, so the union of the marks is the whole map.

Landed: beginBulk()/endBulk() on the index (marks suppressed inside the window; endBulk marks every key in the pair map and every loop list), beginBulkLoad()/endBulkLoad() on the store, and Core._bulkAdd holding the window across the whole ingest in a finally. cy.add() deliberately gets none: adding into a populated graph touches a small subset of the pairs, so the union would be more work than the marks.

Measured (bundles swapped, alternated, own process): headless init of ndex-x-large with the derivations flushed 1223 → 1044 ms, 1.17× (medians of 9); the same page in Chromium init 1142 → 1021, total 2017 → 1893 — every "after" total below every "before". Synthetic 50k/150k, no flush in the timed region: 392 → 371 and 450 → 434.

Verification: test/curve-bulk-window.mjs compares the two routes into the same graph — cytoscape( { elements } ) against cytoscape() + cy.add() — over every derived curve param, blob record and curve flag, on a fixture with three- and two-member bezier bundles (one reversed), a lone member, three staggering self-loops, a compound relation and a blob-family edge. Control: with endBulk marking nothing, 42 specs across the existing suite fail and two of the three new ones do.

67.2 — the bulk edge style apply (2026-08-11)

The 687 ms above, taken directly. A contiguous run of edges whose per-element variation is confined to props with round-61 narrow writers takes its whole styled record from one template slot: the template is written the ordinary way, every column in EDGE_STYLE_COLUMNS is filled from it by copyWithin doubling, and each remaining slot pays only its own mapped props plus the per-slot half of the edge branch.

Three pieces:

  • The column split lives in contract.mts. EDGE_PER_ELEMENT_COLUMNS names the three a shared record does not determine — edge.endpoints (the ingest's), edge.flags (per-element bits) and edge.curveParams (derived) — and EDGE_STYLE_COLUMNS is everything else. It sits beside COLUMN_SPECS because adding a column is what forces the classification, and a spec fails until a new edge column appears in one list or the other.
  • writeChannels's edge branch splits in two: writeEdgeColumns (the fillable columns) and writeEdgePerSlot (the two flag bits, the invisibility cascade, the curve record, the label sidecar). One definition, two callers — the round-61 discipline.
  • The gate. A run qualifies when it is edges, contiguous, ≥ 64 slots, has no open transition capture and no bypasses, and every mapped prop either has a fastStateWriter (which by round 61's invariant writes every column that prop affects) or reads state flags only over a run whose masked word never changes — which is what a freshly loaded graph is. That second clause is what admits this repo's own sheets: line-opacity has no narrow writer and could not have one without the whole B1 fold cluster, but at rest its value is the template's for every edge. Nodes decline outright: their branch hands out per-slot blob records (custom polygons, images, charts) whose refs a copy would alias.

Measured (bundles swapped, alternated, own process, on top of 67.1):

workload before after
ndex-x-large, headless init 1031 ms 836 ms 1.23×
ndex-x-large, browser init 992 790 −203 ms
ndex-x-large, fetch → first frame 1864 1674 −190 ms
synthetic 50k/150k, minimal sheet ~370 ~250 1.48×
synthetic 50k/150k, typical sheet ~445 ~295 1.5×

Over 67.1 and 67.2 together the harness page goes 2017 → 1674 ms, and init 1142 → 790.

The spec's own history is the lesson of this round. The first version of test/bulk-style-apply.mjs compared the bulk route against a one-element-at-a-time route over a rich sheet and passed — and so did four controls that deleted a column from the fill. The sheet mapped curve-style and label, neither of which has a narrow writer, so the gate declined and every assertion compared the per-element path against itself. The fix is a counter (StyleEngine._bulkRuns) the spec asserts, plus a second fixture that must decline. With the route actually taken, five of six column controls land — and the sixth (edge.gradient) only after a gradient was added to the sheet, since a column the fixture leaves at its default cannot show a missing fill. That is round 46.5's "a control that fails to fail is a finding" twice in one afternoon.

A second control: the whole existing suite run with BULK_MIN_RUN forced to 2 — which takes the route on hundreds of runs that normally never reach it — passes 2195/2195.

And the picture is byte-identical. The harness's ndex-x-large page driven in Chromium on a real adapter, fitted and screenshotted through both bundles: 0 differing pixels of 1,024,000. That is the check AGENTS.md asks for when a change touches drawing — the goldens answer "did v4 change?", and this answers it on the one network that actually takes the route.

Verification: 2203 test:js, 394 test:modules, 24 test:soak, all audits 100%, throw gate 0.

67.2b — the state affordances leave the per-slot loop too (2026-08-11)

Re-profiling after 67.2 put 143 ms of the remaining 498 ms apply in the narrow writers themselves: eight of them per edge, 464,657 times. Seven were rewriting bytes the fill had already put there.

bulkEdgeWriters checked for a narrow writer first and only fell through to the uniform-state clause when there was none. But the two clauses are not alternatives — a state-only mapper over a uniform run never leaves the template's value whether or not its prop has a writer, so the writer is pure waste. Ordering the state clause first is the whole change. It matters because the selection affordances are most of the mappers on an ordinary sheet: v4's default sheet alone contributes five to the edge def, and the harness sheet's underlay-color/underlay-opacity/underlay-padding all resolve to the same writer, so the same record was being rebuilt three times per edge.

Measured (bundles swapped, alternated, own process; medians of 9 for the headless rows, two independent A/Bs): headless init 816–824 → 660–679 ms, 1.20–1.25×; the harness page's init 792 → 655 and its fetch-to-first-frame 1676 → 1543. The screenshot is again 0 differing pixels.

The spec that guards it is the one this change makes load-bearing: 'is exact when the run's state word is NOT uniform' selects every third edge, re-applies the whole sheet and compares columns against the per-element route. Control: making uniformMaskedWord always answer true fails exactly that spec and nothing else.

And writing it found a real bug in 67.2, which had shipped an hour earlier. A state-only mapper reaches the per-slot loop only when the word is not uniform — and applyBulkEdges evaluated the state mappers once, for the template, so its writer wrote the template's value to every slot. The first spec written for it passed, twice, for two different wrong reasons: the sheet's only state mapper had no narrow writer (so the route declined), and then _bulkRuns > 0 was satisfied by the initial load rather than by the re-apply under test. What exposes it is narrow — a data mapper beside a state mapper that has a writer (the data mapper is what denies the def a round-57.1 partition and so routes the pass through applyMapped), over a mixed selection, asserting the run count delta across the re-apply. The fix is round 66.1's hoist inside the bulk loop: watch flags & mask and re-evaluate the state mappers when it changes. A uniform run passes null for the flags column and skips the watch entirely.

Two lessons, both old ones in new clothes. A graph loaded at rest cannot exercise the non-uniform branch at all, so the entire round's benchmark workload is blind to it. And an accumulating counter answers "has this ever happened", not "did it happen here" — assert the delta.

Not done, and named: the node branch. Its per-element cost is real (128 ms for 50k labelled nodes on the synthetic fixture) but it is smaller than the edge side on every graph measured, its blob refs make replication unsafe without further conditions, and node labels are usually mapped — which the gate declines anyway.

67.2c — an unlabelled element stops paying for a label (2026-08-11)

Re-profiling after 67.2b left writeEdgePerSlot as the largest item in the apply, and most of it was writeLabel — on a fixture whose edges carry no label of any kind. An unlabelled element built the whole record anyway (~15 colour folds, a closure per call, the anchor solve) to hand setLabel a null it discards on the first line.

Landed: the two end-label texts (D4) resolve up front rather than in their own loop below, and an element whose main and end texts are all empty clears its three streams and returns before the record is built. Clearing is still correct for a slot that had a label — setLabel( null ) is what does it, and it is the cheap half.

Measured, and worth recording because the profile oversold it. The CPU profile put writeLabel at ~75 ms per load; three independent A/Bs (bundles swapped, alternated, medians of 9) put it at 1.04×, 1.06× and 1.08× — 669 → 641, 649 → 613, 656 → 609 ms. Consistent in direction, about 35–45 ms, and roughly half what the profile suggested: at 1 ms sampling over a 650 ms load there are only ~650 samples to divide among a hundred functions, so a per-function figure is a hypothesis to A/B, not a measurement. On the 50k/150k synthetic it is at the noise floor. The harness page: init 677 → 644, total 1569 → 1526, screenshot 0 differing pixels.

Controls, both landing: an early-out that forgets to clear fails 5 specs; end-label texts that never resolve fail 9.

67.3 — overlapping GPU acquisition with the ingest: measured, and not landed (2026-08-11)

src/index.mts ingests the elements and runs the layout before it constructs the Renderer, so requestAdapter() + requestDevice() — which need nothing from the model and nothing from the DOM — start after ~1.1 s of synchronous CPU work they could have overlapped. Probed on an idle page they cost ~100 ms, and a synthetic control looked promising: an acquisition started before a 1000 ms spin() costs only ~38 ms after it, so ~60 ms overlaps.

Implemented — requestGpuDevice() split out of initGpuContext, the promise started in the factory before _bulkAdd, adopted once by the renderer — with a Node spec against a fake navigator.gpu for the adoption, and it does exactly what it claims: instrumented in the browser, requestAdapter moves from 2 ms before the factory returns to 1121 ms before, and the container holds no canvas yet when it fires.

And it buys nothing. Five reps, bundles swapped and alternated: cy.ready 99.0 vs 99.8 ms, totals indistinguishable. The reason, measured in situ: the adapter promise resolves ~34 ms after the factory returns in both cases — whether the request was issued 2 ms or 1100 ms earlier. Whatever the synthetic spin() case overlaps, a real ingest does not: the remaining cost is main-thread work that cannot run while the main thread is busy. Reverted.

The lesson is round 33's, in a new place: the ~100 ms came from an idle-page probe, and an idle page is exactly the configuration this measurement is not about.

67.4 — the first frame is not measurable on this machine, and that is the finding

The first frame read 85 ms, 390 ms and 1100 ms across runs differing only in browser launch environment, with the library unchanged. One observation is worth recording because it was stable across seven consecutive runs before it stopped reproducing: 312 of a 390 ms first frame was a single synchronous stall on the glyph atlas canvas's first draw opctx.clearRect in GlyphAtlas.build, where the immediately following identical clearRect cost 0.0 ms. The whole fixture rasterizes 40 distinct glyphs and computeSdf accounts for 7 ms of the total, so this is not the SDF and not the glyph count. Warming that canvas in the atlas constructor removed it in one run and did nothing in a later 3-rep A/B with bundles swapped.

So: a real stall with an unidentified trigger, and not a validated fix. Anyone taking the first frame should pin the trigger before writing code, and should expect to need a way to hold the machine in the state that reproduces it.

The benchmark suite's wall clock

The publish run had grown to about an hour, and the maintainer's observation was the whole diagnosis: it uses one core of eight. report.mjs spawned one job, waited, spawned the next. Measured on the 65.10 archive, --all is 18.4 min of work at --repeat 1 and so ~55 at the --repeat 3 publishing requires — with seven cores idle throughout, and (measured live, ps -o nlwp,pcpu) a bench process wanting only ~1.1 of the one core it has, over 7 threads.

Where the time goes, from that archive — four jobs are 62% of the run:

job s share
algorithms N=500 206.0 18.7%
algorithms N=500 (a duplicate — see 68.1) 205.6 18.6%
surface N=2000 174.9 15.8%
algorithms N=2000 93.0 8.4%

68.1 — --all ran its slowest job twice (2026-08-12)

algorithms.mjs @ N=500 was listed in both QUICK_JOBS and STANDALONE_JOBS, and --all is quick + standalone. So the slowest job in the table ran twice, and the second copy contributed nothing: rowsOf (report-compare.mjs) and toSections (report-html.mjs) both keep the first-seen (group, bench) and discard the rest — the duplicate's rows were parsed, merged and thrown away on every run since the two tables were split. 206 s per repeat; 10.3 min of a --repeat 3 run, recovered by deleting one line.

68.2 — the runner takes the other seven cores (2026-08-12)

--jobs N (or auto) runs the jobs concurrently: benchmark/schedule.mjs holds the policy, pure and spec'd, and report.mjs holds a worker pool. The default is 1, and the serial path is unchanged down to the spawn order, because that is the condition the whole published archive was measured in.

auto takes one worker per physical core less one, capped at 6, and pins each with taskset. Both halves are measurements, not guesses: a bench process is ~1.1 cores, so one-per-core already oversubscribes; and the physical set comes from thread_siblings_list rather than being assumed to be 0..N-1, which on this box would put two jobs on cores 0 and 1 (cpu i and i+8 are one core) while four idled.

The three scheduling rules, each chosen against the obvious alternative:

  1. One pool, no barrier between passes. The obvious design runs every job's pass 1, waits, then pass 2 — and that multiplies the longest job by the repeat count and pays a barrier at each wave. One pool of 72 units pays the chain once. (What that leaves is measured in 68.4, and it is not the 7.5 min this planning note first claimed: with rule 3 in force the run is bounded by the longest job's own repeat chain, ~11 min, not by total work ÷ workers, 8.4 min.)
  2. Every job's first pass before any job's second, then longest-first. Pure LPT from a cold cache discovers a long job late and hands it the tail; a duration cache (results/.durations.json, gitignored) carries the lengths between runs so the first pass packs too.
  3. A job's repeats do not overlap each other — softly. Round 65.11's median-of-three assumes three independent samples; three siblings running side by side would share one wall-clock neighbourhood on top of the machine state. Soft because parking a worker at the tail costs more than the correlation it avoids.

Not done, deliberately: splitting the two long suites into BENCH_OP chunks to pack better. It was in the plan until the arithmetic — the longest job only binds the schedule above ~13 workers (2694 s ÷ 206 s) — and it would change what each suite measures (a chunk rebuilds the fixture and starts cold) while being invisible to the harness hash, since report.mjs sits in NOT_INSTRUMENT and correctly so. A measurement change no fingerprint can see is the exact failure the fingerprints exist to prevent. The GPU profiles stay serial and exclusive — one adapter, one queue, and frame time is the measurement.

68.3 — a concurrent run is its own epoch (2026-08-12)

Concurrency is an instrument change the file hashes cannot see, so concurrentHash folds the worker count into each job's harness hash and the comparison refuses a serial-to-parallel line the way it already refuses a cross-machine one. Different worker counts are different epochs: four-way and eight-way contention are not one condition.

A serial run is untouched, and that is verified rather than asserted: a --jobs 1 run of compound stamps 9e76d83b, which is exactly what the three published archive runs carry, while the same suite at --jobs 2 stamps 9a662dc2. meta.concurrency records the fact, and the report grows a provenance row saying the numbers were measured under contention — because the page is also read on its own, by someone wondering why every row sits above the archive's.

68.4 — measured (2026-08-12)

--all --repeat 3 on the i9-9900K (8 physical / 16 logical, three qemu VMs holding their usual ~29% of a core), round-67 HEAD, --jobs auto = 6 workers pinned:

wall
before this round (--repeat 3, serial, duplicate included) ~55 min
serial with the duplicate removed (derived from the same per-job times) 44.9 min
--jobs auto 11.0 min

5.0× against the command that was being run, 4.1× against a deduplicated serial run. Utilisation is 77% of six workers (50.4 min of unit work in 11.0 min of wall), and the missing quarter is the tail: the last three units are algorithms @ 500 pass 2, surface pass 3 and algorithms @ 500 pass 3, finishing with three cores idle.

What binds it is the chain, not the packing — worth a second run to establish, because the duration cache was written on the assumption that packing was the problem. A second --jobs auto run with the cache warm started the six longest jobs first, in the order LPT wants, and finished in 10.9 min: 0.1 min different, 77% utilisation again. The reason is rule 3. algorithms @ 500 takes ~226 s a pass and its three passes may not overlap, so its chain alone is ~11 min — within a rounding error of the whole run. Total work ÷ 6 workers is 8.4 min, and that is what would be reachable if the chain were broken.

So the cache earns its place by making the shape right — long jobs start first, and the first pass packs like the later ones — rather than by moving the wall clock, and the only remaining lever is the one 68.2 declined: splitting algorithms @ 500, or relaxing rule 3, for about 2.5 min. (Written here as "neither is worth its cost today" — and 68.5 immediately revisits that, because the two options do not cost the same: one edits a suite file and breaks its epoch, the other touches only the scheduler. The wrong judgement was treating them as one item.)

The contention penalty, per job — parallel pass time against the same job in the 65.10 serial archive. Median 1.11×, and the shape is the finding:

spatial, store, surface, mappers, style-bundle 1.02–1.06×
core+collection, materializers, compaction, style, algorithms @ 500 1.07–1.11×
traversal, algorithms @ 2000, load 1.21–1.28×
layouts 1.55×

It is not a uniform slowdown, so no single correction factor could turn a parallel number back into a serial one — which is the measured form of the argument for keeping publishing serial, and worth more than the wall-clock figure. (curves 1.57× and labels 1.48× are excluded from that reading: both are sub-second one-shot suites where process startup, not contention, is most of the number.)

The rule for using it. Parallel is for the iteration loop, where the question is a suite's own v3-vs-v4 ratio — both sides measured seconds apart in one process, so a uniform slowdown cancels. Publishing stays serial, and scripts/benchmark-publish.mjs now enforces that with a guard shaped like the dirty-tree one: a run carrying meta.concurrency > 1 is refused unless --allow-concurrent. The reason is not that the page would be wrong — the epoch hash sees to that — but that it would be blind: every row would read as a break, and, worse, if the epoch were waved through, contention would widen each row's repeatSpread and the mover screen would file real 10–20% wins as "inside the row's own run-to-run band" — the band the style rounds live in.

Controls, all landing: the collision tier removed from the picker fails the two-repeats spec; explore-first removed fails the unmeasured-job spec; longest-first removed fails two; the exclusive drain removed fails two more; concurrentHash made a no-op fails the two epoch specs; the provenance row removed fails the concurrency spec.

Open: the serial-vs-parallel validation for publishing, ~70 min of machine time at one commit. Three numbers decide it — the median row-wise parallel/serial ratio (the per-job spread above says it will not be one number), the inflation in per-row repeatSpread, and how many of the ~245 rows would still be screenable at a true 10% change. Until that is run, the guard stays.

68.5 — the chain yields, and the run reaches its work bound (2026-08-12)

68.4 named two ways past the ~11 min chain and judged neither worth its cost. That was wrong about the second one, and the correction is cheap enough to be worth making: relaxing rule 3 costs nothing that reaches the archive.

Splitting algorithms @ 500 into BENCH_OP chunks would have to edit the suite file, which changes its harness hash — breaking the epoch for every algorithms row in the archive over a filter that is inert when the variable is unset. That is the false break harness-id.mjs's own header warns about: "a break nobody believes is worse than no break at all".

Relaxing rule 3 touches no suite file and no hash. criticalKeys computes, from the duration hints, which jobs have a remaining chain longer than the work left divided among the workers — on this profile, algorithms @ 500 and surface — and those jobs alone may run their repeats concurrently. It affects concurrent runs only (a serial run has nothing to overlap with), and those already carry their own epoch and are refused by the publish script, so the correlated band it produces was never going to reach the archive. The runner prints which jobs it applied the exception to, because a narrowed band nobody was told about is precisely what this harness exists to prevent.

Measured, same machine, same profile, --repeat 3 --jobs auto:

wall utilisation
serial, before the round ~55 min 12% of one core of eight
--jobs auto, rule 3 absolute (68.2) 11.0 / 10.9 min 77%
--jobs auto, rule 3 yielding (this) 8.4 min 100%

100% of six workers, which is the work bound (50.3 min of unit work in 8.4 min of wall) — there is nothing left for a scheduler to win here. 6.6× against the command that was being run, 5.3× against a deduplicated serial run.

The cost, looked for and not found at this sample size. The worry is that overlapped repeats share a wall-clock neighbourhood and so report a narrower repeatSpread than the truth. Comparing the run that overlapped against the run that did not, per-suite median bands: the two critical suites narrowed (algorithms 1.086 -> 1.060, surface 1.073 -> 1.058) — but so did two suites that were not overlapped (style 1.085 -> 1.065, traversal 1.086 -> 1.051), while core+collection held flat and spatial widened. The movement is inside the band statistic's own run-to-run variation, so this comparison does not isolate the effect. The mechanism is still real and the exception is still announced; what cannot be claimed is that it was measured to be harmless.

The matmul-first families

The round-65 benchmark's own headline said where the next wins were: MCL leads at up to 647× because its inner loop is a dense matrix product — compute-bound, the one regime where the GPU's advantage is ~100× rather than the ~10× bandwidth ratio — while every memory-bound family sits an order of magnitude lower. The maintainer's goal for this round: build the four families that assessment named. All four landed, each with a CPU reference (the spec), a GPU path, executor routing, Node specs, live parity specs with verified controls, and benchmark rows.

69.1 — closeness centrality joins the executor tier

eles.closenessCentralityNormalized() is the O(n³) all-pairs computation — it always ran full Floyd–Warshall — so it moved onto the async tier exactly as floydWarshall did in round 65. This is a public API semantics change: the method returns a promise now, and the options gained executor. The single-root closenessCentrality stays synchronous (a Dijkstra walk; nothing to win).

Two refactors made the family cheap: the CPU relaxation left floydWarshall as an exported relaxFloydWarshall (the closeness CPU reference reads the matrix directly now, instead of paying an accessor-with-handles call per pair), and the GPU side exports fwRelaxPlan — upload + the blocked dispatch chain — which closeness reuses unchanged, appending one row-fold kernel (CLOSENESS_ROWS, workgroup-per-row, two tree reductions) in the same pass. The readback is n floats instead of the 2n² the public FW must return. Unreachable pairs ride the FW sentinel band; a row the CPU would sum to Infinity (plain mode with an unreachable peer, harmonic with a zero distance) is marked via the second reduction and written as one sentinel, because summing sentinels can overflow f32 and WGSL implementations may assume floats are finite.

69.2 — triangle counting: the first family designed matmul-first

eles.triangleCount() — per-node triangle counts, local clustering coefficients, totalTriangles, transitivity; no v3 counterpart. The collection reads as a simple undirected graph (direction ignored, parallel edges collapsed, loops excluded). CPU reference: sorted neighbor lists, each triangle found exactly once at its sorted (u, v) edge by two-pointer intersection — O(Σ deg²). GPU: one tiled MATMUL (A², the shared kernel MCL leads with) plus a Hadamard row fold (TRI_ROWS); row i's fold is twice its triangle count, so the readback is n floats and the counts are exact integers in f32 until a row's sum passes 2²⁴.

The 'auto' gate is a density gate, not a size gate. The CPU walk is O(Σ deg²) where the matmul is O(n³) regardless, so the GPU can only win where Σ deg² approaches n³ — dense graphs. Under 'auto' the GPU routes only at n ≥ GPU_MIN_N and m ≥ n²/32; sparse graphs stay on the CPU however large they are. A starting figure in the GPU_MIN_N tradition, to be re-measured by the sweep on the benchmark machine.

69.3 — neighborhood similarity

eles.neighborhoodSimilarity({ metric, directed }) — pairwise Jaccard / cosine / overlap coefficients over deduped neighbor sets; no v3 counterpart. The shared-neighbor counts for every pair are C = A·Aᵀ: undirected A is symmetric so the same buffer binds as both matmul factors; directed: true compares out-neighborhoods and uploads the transpose. CPU reference: wedge counting over witness lists — every shared neighbor w of (u, v) is one wedge u–w–v — O(n² + Σ deg²) with no per-pair set intersection. The metric normalizes lazily per query in the shared accessor, so the counts stay exact integers on both executors and the executors agree exactly (the parity spec asserts zero delta, not an epsilon). The result is inherently all-pairs — O(n²) memory, like floydWarshall's distances — and the same density gate as triangles governs 'auto' (m ≥ n²/16 here: the CPU walk is the same shape but the GPU pays a full n² readback).

69.4 — Katz centrality

eles.katzCentrality({ alpha, beta, maxIterations, tolerance, directed, weight }) — attenuated walk counting, x = α·Aᵀ·x + β from x = 0, L1 stopping rule Σ|Δ| < n·tolerance; no v3 counterpart. Directed runs count incoming walks; parallel edges each contribute; loops are excluded (the pageRank family conventions). alpha/beta are validated synchronously (resolveKatzParams — two new guards, both pinned in test/algorithms-matrix.mjs, so the throw gate stays at zero). CPU reference: sparse O(E)-per-step edge gather, pageRank's shape. GPU: pageRank's MATVEC (now exported — it was always a generic flags-guarded tmp = m×v) plus a KATZ_EPILOGUE (add β, L1 reduce, converge bit), all iterations encoded up front, one readback. 'auto' never routes Katz to the GPU — the 65.10 pageRank verdict applies to the identical iteration shape, and the entry carries the same comment; the GPU path serves an explicit 'gpu' and the parity suite, and a sparse SpMV kernel remains the revisit that could change the verdict for both families.

69.5 — verification, and the controls

Node tier: test/algorithms-matrix.mjs (18 specs, every expected value hand-computed — K4-minus-an-edge for triangles/similarity, the closed-form star and chain for Katz), the executor-contract sweep extended to the four new entries, throw coverage at zero unrun, JSDoc gates at 100% (453 public members), types surface and consumer tests green, full test:js 2223 green (the two specs that called the old sync closenessCentralityNormalized moved to await).

Parity tier: five new live CPU-vs-GPU specs in algorithms-gpu.spec.js — closeness at multi-block n=100 in both modes plus a two-component disconnection spec (the sentinel path), triangles at n=96 (three matmul tiles, exact integer equality — a discrete invariant, like MCL's memberships), similarity across all six metric×direction combinations (exact equality), Katz in both directions (1e-4 plus the pageRank spec's tie-tolerant ordering invariant). Every spec was run once with its kernel deliberately degraded and failed: closeness with 1/d skewed and separately with the unreachable mark dropped, triangles with the Hadamard mask dropped, similarity with the directed transpose dropped, Katz with β skewed 1%. All five controls failed; all 15 specs green restored.

69.6 — benchmark rows, and a harness note

Four families joined algorithms-gpu-bench.mjs: closeness at FW's sizes (what the on-device row fold saves over the n² readback), triangles and similarity on the dense fixture (where their 'auto' gate routes to the GPU at all — the row comment says why), Katz on the sparse fixture (documenting the 'auto' verdict, as the pageRank rows do). Rows validated end-to-end on SwiftShader locally (--allow-swiftshader, results deleted, not published — a software rasterizer lies about the crossover); real crossover numbers await a run on the benchmark machine, after which the density-gate constants (n²/32, n²/16) and the closeness GPU_MIN_N should be re-tuned from measurements.

Editing the suite file moves the algorithms-gpu harness fingerprint e97610c5c965d9b8. The existing families' fixtures and op strings are byte-identical, so once the first run under the new hash is published, the pair belongs in EQUIVALENT_HARNESSES with that reason — it cannot be added earlier because auditEquivalences refuses a hash no published run carries (correctly: unknown is not unchanged).

Open: the crossover sweep on the benchmark machine (gates re-tuned from it); the sparse SpMV revisit for pageRank + Katz; whether closeness should also gain a Brandes-style batched-BFS unweighted path (the FW route was chosen because the CPU reference is FW-shaped, so parity is structural — a BFS path would be faster still on sparse graphs but needs its own reference).

The propagation tier: network biology's algorithms

Round 69 asked "which algorithms could take MCL's lead"; the follow-up question was which of the candidates matter for scientific use, and the answer reordered the queue: network biology has converged on propagation methods, so the round builds the five families the maintainer picked from that assessment — random walk with restart, heat-kernel diffusion, effective resistance / commute time, SimRank, and the triad census. Seven public methods, each with a CPU reference (the spec), executor routing, hand-computed Node specs, and — for the five dense forms — GPU kernels, live parity specs with verified controls, and benchmark rows.

70.1 — the seed/dense split, decided once for the tier

RWR and heat diffusion each have two natural forms. The seed form — propagate from a seed collection, answer a score per node — is the everyday bio call, and it is an O(E)-per-step sparse walk: the pageRank verdict applies, no kernel can win, so randomWalkWithRestart and heatDiffusion are CPU-only and an explicit executor: 'gpu' rejects with a message pointing at the dense sibling (the weighted-betweenness no-path precedent). The all-pairs form — the full proximity/kernel matrix — is iterated dense products, the MCL shape, and that is where the GPU tier lives: randomWalkWithRestartProximity (Neumann iteration S′ = (1−c)W·S + cI, one matmul per step), heatKernel (exp(−tL) by scaling-and-squaring: the scaled Taylor series as a matmul chain, then s squarings), simRank (S′ = C·Q·S·Qᵀ, two matmuls per step) and the census's trace products. The CPU references for the all-pairs forms are deliberately sparse (one seed-vector solve per column; per-column Taylor applications; per-row/column neighbor averages), so the density gates are honest: the m ≥ n²/16–n²/32 gates of round 69, same reasoning, and 'auto' stays on the CPU for sparse graphs however large.

effectiveResistance is the exception and the headline: the Laplacian pseudo-inverse has no sparse shortcut, so the CPU reference is dense f64 Gauss–Jordan at O(n³) and the GPU runs Newton–Schulz — X ← X(2I − BX), nothing but matmuls, quadratically convergent for the positive-definite B = L + J-blocks this family builds (the per- component 1/n_c shift makes B invertible while cancelling out of every resistance difference). Like MCL it wins at every density. f32 bounds the achievable accuracy on ill-conditioned systems, documented; the parity bound is relative 5e-3 against the f64 elimination.

70.2 — semantics worth recording

  • RWR: W column-normalized by out-weight; the undirected default walks both ways; a directed sink absorbs (its column leaks, scores can sum under 1) rather than redistributing — documented, simpler on both executors, irrelevant on undirected bio graphs. seeds is required for the seed form and uniform over its nodes; the fixed point is c(I − (1−c)W)⁻¹p₀, pinned in specs by the two-node closed form c/(1−(1−c)²).
  • Heat: the combinatorial weighted Laplacian, undirected, positive weights enforced (a negative conductance is not a heat problem — TypeError). Both executors share the same approximation constants (‖tL/2^s‖∞ ≤ ½, ten Taylor terms); the CPU applies the scaled operator 2^s times per column, the GPU squares s times — the same power. Specs pin the pair and triangle matrix exponentials in closed form and heat conservation through the scaling path at t=10.
  • SimRank: Jeh–Widom with in-neighborhoods under directed: true, all neighbors otherwise (the library's undirected default; bio graphs are undirected). Diagonal pinned to 1 per iteration; empty neighborhoods answer 0. The 4-cycle fixed point x = C(1+x)/2 → x = 2/3 at C = 0.8 pins the maths in specs.
  • Census: sixteen closed forms over seven trace primitives (S₁ = ΣC²∘C … S₇ = ΣCCᵀ∘M over the asymmetric and mutual masks), the dyad totals and six degree-pair sums, shared verbatim by both executors (censusFromPrimitives) — the executors can only disagree if a matmul disagrees with a wedge walk. The formulas themselves are the risk, so the load-bearing spec is a brute-force differential: an independent classifier written from the class definitions, run over every triple of six random digraphs sweeping sparse to dense, exact equality demanded per class (plus Σ = C(n,3)). It passed on the first complete run of the closed forms, and it is the spec that would catch a sign or orientation error nothing else can see. directed: false files every edge as mutual, so the undirected census (empty / one-edge / path / triangle) is the same code path reading 003/102/201/300.

70.3 — verification

Node tier: test/algorithms-propagation.mjs (24 specs) and test/algorithms-motifs.mjs (15 assertions per seed across six seeds), all closed-form or brute-force; the executor sweep extended by seven entries; throw gate at zero unrun (six new guards: seeds, restartProbability, time, two positive-weight conductance guards, dampingFactor); JSDoc gates 100%; types and full test:js green.

Parity tier: five new live specs — simRank (1e-4 plus an exact-1 diagonal, both directions), rwrProximity (1e-4 plus column conservation ≥ 0.999), heatKernel (1e-4, symmetry, row conservation, at t = 2 so several squarings run), effectiveResistance (relative 5e-3, the unit-resistor identity, and exact Infinity agreement across components on a fixture that has both), motifCensus (all sixteen counts exactly equal on a 64-node random digraph, with a populated-classes discrimination check). Every spec was run once with its kernel deliberately degraded and failed: simRank with C skewed 1%, rwrProximity with the restart diagonal skewed, heatKernel with the k=2 Taylor term dropped, resistance with the Newton–Schulz 2I skewed to 2.01I, the census with S₆ folded against the wrong mask. All five controls failed; all 20 specs green restored.

70.4 — benchmark rows, and a sizing lesson

Five families joined algorithms-gpu-bench.mjs: resistance on the plain fixture (both sides O(n³) — the MCL-class row), the four iterated-product families on the dense fixture. The first draft priced the dense families at n = 2048 and had to be walked back: a bench cell pays REPS×(cpu+gpu) calls, and the dense CPU references are MCL-cost already at n = 1024 (rwrProximity is one sparse solve per column; heatKernel's scaling exponent grows with t·degree, so its bench row pins time = 0.02). Sizes stop at 1024 for those three (census stays to 2048 — its CPU walk is O(Σ deg²), far cheaper), and the iteration knobs are pinned in the rows per the round-33.2 rule. The suite edit moves the harness fingerprint again; the round-69 note about EQUIVALENT_HARNESSES applies to the new hash the same way, and only one entry is needed once a run under the final hash is published.

Open: the crossover sweep on the benchmark machine (density-gate constants re-tuned from it, and the resistance family's parity bound revisited on the RX 580's f32); a normalized Laplacian option for the heat family; motif significance tooling (the census exists so it can be run per randomized network — the ensemble driver itself is app-level and stays out of scope).

70.5 — measured on the M2 (Metal, one-off; the archive run stays the RX 580's)

family n=256 n=512 n=1024
effectiveResistance 3.3× 7.8× 9.0×
simRank (10 iters, dense) 14× 33× 45×
rwrProximity (dense) 21× 68× 119×
heatKernel (dense) 82× 291× 932×
motifCensus (dense, to n=2048) 3.9× 8.7× / 12.3× @2048

heatKernel's 932× is the largest ratio any family has measured — its CPU reference pays 2^s operator applications per column while the GPU pays s squarings total, so the scaling exponent multiplies the CPU side only. Two findings from the measurement worth their notes:

  • A converge tolerance below f32's noise floor buys nothing and costs everything. Newton–Schulz at an absolute 1e-5 ran 96 of 96 encoded iterations at n=1024 (642 ms): the inverse's entries grow as 1/λ₂, so on any weakly-connected graph the iterate's float noise exceeds an absolute bound forever and the no-diff converge never fires. The compare is relative now (NS_COMPARE, |Δ| > tol·max(1, |x|)) — 642 → 200 ms at n=1024, 9× over the f64 elimination, and the parity spec still passes at 5e-3 relative.
  • A bench cell's budget is REPS × the slow side. The first row draft priced the dense families at n = 2048 and a single rwrProximity CPU call there runs minutes; sizes stopped at 1024 and the heat row pinned time = 0.02 (its CPU cost scales with 2^s).

cyext: the extension toolchain

v4's extension story is round 17's contract — cy.layout({ impl }), no registry, no cytoscape.use() — and it is complete as an API but naked as a workflow: v3's documentation sent extension authors to rollup-starter-lib, and nothing scaffolding-shaped exists anywhere in this repo. The ask is a create-react-app for Cytoscape.js extensions: an npm package cyext (verified unclaimed on the registry 2026-08-14 — npm view cyext answers E404), living in this repo and published on its own, whose consumer's package.json is nothing but "build": "cyext build", "test": "cyext test", and so on. The toolchain — rolldown, rolldown-plugin-dts, oxlint, oxfmt, tsx + node:test, the same tools this repo builds itself with — is a dependency of cyext, exact-pinned, so upgrading an extension's whole toolchain is bumping one devDep, first-party extensions included. Scope, decided by the maintainer at the round's raising: the full command set including release; cyext init scaffolding; and a real example extension in-repo built entirely by cyext, which becomes round 51's "external layout through the round-17 contract" smoke vehicle.

Design calls

  • A nested package at cyext/, on the v3 precedent — not npm workspaces. Workspaces would put a workspaces field in the root manifest, hoist cyext's dependencies into the root node_modules, and change what a root npm ci means for every existing job. Three invariants forbid that: v3's independent install must not break; ci-node stays v3-free and now cyext-free (the Node tier runs against a root-only install, restored deliberately 2026-08-06); and cyext's reason to exist is that its dependency tree is its own, resolved from its own tracked lockfile, identical in-repo and in a consumer's app — hoisting is precisely the mechanism that would make in-repo cyext resolve different tool versions than published cyext. Driven from root via npm --prefix cyext, like v3.
  • cyext's own source is plain .mjs — no build step, no TypeScript. The .mts-through-.mjs-specifiers convention is a src/ convention for the bundled library; the repo's own tooling under scripts/ is already plain .mjs, and cyext is a Node CLI in that family. A toolchain package that needs its own toolchain to build is circular, and shipping source means npm publish ships exactly what the tree tests. Internal shape follows scripts/build-dts.mjs: pure exported functions plus a pathToFileURL( process.argv[1] ) main guard, so every command's logic is spec-able without spawning. Rejected: authoring in .mts — types at the cost of a bootstrap build and a dist tree.
  • The tools are dependencies of cyext, exact-pinned — not carets. A cyext version is a toolchain version, and a caret lets a patch release skew two extensions built "with the same cyext". Upgrades happen by releasing cyext. A root spec (toolchain-skew, below) asserts every cyext pin satisfies the root's devDep range for the same tool, so the main lib and its extensions cannot silently diverge. chai stays out of cyext's dependencies and in the template's devDeps — a test library the consumer's specs import should be a dependency the consumer declares.
  • Library API where one exists, resolved spawn where not. build/watch/build:types use rolldown's JS API with the config built in memory by lib/rolldown-config.mjs (pure) — no temp config file is ever written into the consumer's project, and the root config's env plumbing (FILE/VERSION/NODE_ENV) becomes plain function parameters, so cross-env is not needed at all. oxlint, oxfmt and tsc are spawned: createRequire( import.meta.url ).resolve( '<tool>/package.json' ) resolves each tool from cyext's own tree wherever cyext is installed, reads its bin field, and runs process.execPath <binpath> <args...> via array-argument execFileSync. Rejected: shelling npx <tool> — it resolves from the consumer's tree first (defeating the pinning) and hits the network on a miss. cyext test spawns node --import <abs tsx entry> --import <abs shim> --test "test/*.mjs" with both --import targets resolved to absolute paths from cyext's tree, because a bare --import tsx resolves from the consumer's CWD and only works when npm happens to hoist it.
  • The shim and the configs ship as drift-gated copies. cyext/lib/node-test-setup.mjs is a byte-for-byte copy of the root's 46-line BDD shim, spec-pinned equal; the shipped oxlint rules equal root's .oxlintrc.json modulo its repo-relative $schema (which breaks when copied verbatim); oxfmt's two keys are copied. Rejected: importing across the package boundary — cyext must be publishable and self-contained.
  • Extensions emit straight to dist/ — no build/ + dist:copy. The root's copy list is a hand-maintained coupling round 44 exists to police, and an extension has no dev harness consuming build/, so the two-directory scheme buys nothing and adds the exact drift point. Outputs: dist/<name>.{umd.js,min.js,cjs.js,esm.mjs,esm.min.mjs} plus dist/<name>.d.ts.
  • cytoscape is external in every format, with output.globals: { cytoscape: 'cytoscape' } — even though v4 extensions import only types. Under verbatimModuleSyntax a type-only import is elided, so the external is usually moot — but declaring it means an extension that imports a runtime value (deliberately or by accident) gets a correct external reference instead of a silently inlined copy of cytoscape. A bundle-level spec asserts the non-inlining.
  • Names are derived, not configured. Package cytoscape-layout-spiral → files dist/cytoscape-layout-spiral.*, UMD global cytoscapeLayoutSpiral (scope stripped for @org/…, then camelCase). Escape hatch: an optional "cyext" field in the consumer's package.json — { globalName, entry, external: [...] }, all optional. Rejected: a cyext.config.mjs file — a config module invites per-project divergence, which is the disease this package treats; the manifest field is enough for v1.
  • Templates spell scripts "build": "cyext build", not "npx cyext build". Inside an npm script node_modules/.bin is already on PATH; the npx form adds cold-start and a network fallback for no gain. npx cyext … still works from a shell, as the raising described. (A deliberate refinement of the raising's literal example — flagged, not silent.)
  • Root .npmignore gains cyext and extensions. It is a denylist, so both new top-level directories would ship in the cytoscape tarball otherwise — and round 44's tarball spec carries an allowlist, so the omission fails test/modules/packaging.mjs loudly. That failure is one of the round's controls, run deliberately before the .npmignore edit lands.
  • CI: a new ci-cyext job, modeled on ci-v3 (.github/workflows/tests.yml:88): root npm ci + npm run build + npm run dist:copy (the e2e specs and the example resolve cytoscape to the repo root, whose runtime entries live in dist/; the committed dist/cytoscape.d.ts covers types), then npm --prefix cyext ci && npm --prefix cyext test, then the example extension's install + cyext check. ci-node is untouched. Root gains a test:cyext convenience script for humans; root npm test is not extended (rejected: folding into test:node — it needs a second install and root dist artifacts, exactly the coupling the round-53 split removed).
  • cyext release is deliberately minimal, and round 50 is not front-run. release [patch|minor|major|<version>] [--dry-run] [--tag <disttag>]: refuse a dirty git tree → run cyext check in full → roll the changelog (## Unreleased, which must exist and be non-empty, becomes ## <version> — <date> with a fresh Unreleased inserted; a pure function in lib/changelog.mjs, spec'd on strings) → npm version (commit + tag) → npm publish. What it deliberately does not do: provenance, signing, release-notes generation, GitHub releases. Round 50 owns release engineering for the main lib; when it settles provenance, retrofitting --provenance here is a one-flag follow-up — and cyext's own publishes go through this same command, so cyext releases itself.

The command set

command does
cyext init [dir] Scaffold a new extension; --name for non-interactive; refuses a non-empty dir without --force.
cyext build Five bundles into dist/: entry ./src/index.mjs (the root's extensionAlias trick resolving .mts), transform.target 'es2018', replace-plugin for process.env.VERSION/NODE_ENV, license banner from the consumer's LICENSE when present, no wgsl plugin (cytoscape-specific), cytoscape external + globals.
cyext build:types rolldown-plugin-dts against the consumer's tsconfig → dist/<name>.d.ts, then a generalized finalize appending export as namespace <globalName> — the build-dts.mjs pattern with the name parameterized.
cyext test node:test + tsx + the shipped shim over "test/*.mjs", extra argv passed through (--test-name-pattern etc.).
cyext lint / format / format --check The shipped configs on src test; a consumer .oxlintrc.json/.oxfmtrc.json wins if present.
cyext watch (alias dev) rolldown watch on the unminified esm + cjs bundles, inline sourcemaps, development. No dev server — an extension has no page; authors npm link into an app or the debug harness.
cyext check The CI aggregate, in order: tsc --noEmit → lint → format --check → test → build → build:types → pack agreement — every dist/ path named by the consumer's main/module/types/exports/unpkg/jsdelivr was actually produced, and npm pack --dry-run --json ships each of them and nothing stray. Round 44's gate, productized: every extension gets it for free.
cyext release The design call above.

The package layout

cyext/
  package.json         bin { cyext: bin/cyext.mjs }, exact pins,
                       files allowlist, engines >= 24
  package-lock.json    tracked (the v3 lesson of 2026-08-06)
  README.md            the manual — written first, in 71.0
  bin/cyext.mjs        argv parse + dispatch; unknown command exits 1
  lib/                 context.mjs (project discovery),
                       resolve-tools.mjs, run.mjs,
                       rolldown-config.mjs, dts-finalize.mjs,
                       changelog.mjs, node-test-setup.mjs,
                       commands/*.mjs
  configs/             oxlintrc.json (minus $schema), oxfmtrc.json,
                       tsconfig.base.json (nodenext /
                       verbatimModuleSyntax / strict, matching root)
  templates/           the init scaffold
  test/                cyext's own suite, incl. the tmp-dir e2e
extensions/
  cytoscape-layout-spiral/   the example extension

The templates (cyext init generates nine files)

package.json — name, 0.1.0, entries naming the five bundles + d.ts, files: [ "dist" ], scripts all cyext <cmd>, peerDependencies: { cytoscape: "^4.0.0-0" }, devDeps cyext + cytoscape + chai; no engines field — an extension's engines binds its app consumers, whose runtime never touches cyext; the template's .nvmrc (24) carries the dev-time floor instead. src/index.mts — a commented LayoutImpl class: import type { LayoutContext } from 'cytoscape', a run( ctx ) using ctx.nodeSlots() / ctx.setPositions(), an options knob read off ctx.options, default-exported — deliberately a ring layout, not the spiral, so the template does not collide with the shipped example. test/layout.mjs — a headless spec in the repo's Mocha shape, cribbed from test/layout-contract.mjs (which src/README.md already names as the template external authors should crib): run, await layout.promise(), assert positions and the layoutstart/layoutready/layoutstop triple on the core. tsconfig.json extending cyext's base; README.md; CHANGELOG.md with an ## Unreleased section release consumes; .gitignore; LICENSE (MIT). Placeholders (__EXT_NAME__, __EXT_GLOBAL__, __CYEXT_VERSION__, __YEAR__) appear only inside strings, JSON values and markdown, so every template stays parseable and the template dir can itself be linted.

The example extension — extensions/cytoscape-layout-spiral/

The dogfood: a real package whose scripts are nothing but cyext <cmd>. The source is debug/init.js's SpiralLayout ported to .mts — the sqrt-spiral over ctx.nodeSlots()ctx.setPositions(), a spiralStep option, default export — minus its cy.fit() call, which the debug page could only write because it closed over cy; a contract-clean impl talks to ctx alone (whether the contract offers a fit affordance is checked in 71.6, not assumed). DevDeps use directory links so the in-repo build needs no registry: "cyext": "file:../../cyext", "cytoscape": "file:../.." — npm installs directory file: deps as symlinks, and Node resolves cyext's imports from cyext's real location, so the pinned toolchain is the one that runs. Lockfile tracked. debug/init.js keeps its inline copy — the harness must not depend on an installed extension — with a comment pointing at the canonical package. As round 51's smoke vehicle, with the caveat stated for the record: in-repo this package consumes file:-linked cytoscape and cyext; round 51's bake re-runs the same package against the published pair, which is the half only a real publish can test.

Controls the round owes (every spec run once deliberately broken)

cyext's own suite: dispatch — remove a table entry; the pure rolldown-config builder — drop external; bundle-level externals on a fixture with a deliberate runtime cytoscape import (esm keeps the specifier, UMD references the global, never inlines) — comment out external in the builder; shim drift — edit one byte of the copy; config drift — flip one shipped rule; dts finalize (appended once, idempotent, throws on a declaration with no default export) — feed it a bad declaration; changelog roll (refuses an empty Unreleased) — delete the refusal branch; the init e2e, the key control — scaffold into mkdtemp, hand-symlink node_modules/{ cyext, cytoscape, chai } + .bin/cyext (no network; ensure root dist/ runtime files exist first — the round-53 fresh-checkout lesson), run cyext check green end to end, with two controls: (a) corrupt the template's setPositions call — the generated test fails; (b) drop a template file from the scaffolder — check fails; release preflight — neuter the dirty-tree check; cyext's own pack agreement (round-44 pattern, npm pack --dry-run --json against its files allowlist) — drop templates from files.

Root-suite additions (test/modules/): toolchain-skew — each cyext pin satisfies root's devDep range for the same tool (control: bump a pin out of range); example-extension shape — manifest scripts all invoke cyext, and its src/ has no runtime cytoscape import, types only (control: add one); the root tarballpackaging.mjs run once before the .npmignore edit, and the observed failure is the control.

Pass split (tests-first; docs in-commit; each pass its own commit(s))

  • 71.0 Docs-first — this section; cyext/README.md written as the manual before the code (command table, the "cyext" manifest field, the pinning philosophy); AGENTS.md gains cyext/ + extensions/ in the structure section; src/README.md's extension-contract prose gains the tooling pointer.
  • 71.1 Package skeletoncyext/package.json (pins, bin, files), lockfile, dispatch, resolve-tools/run, the shim + config copies with their drift specs, cyext's own test harness, the root .npmignore edit (control first), root test:cyext, the ci-cyext job running the cyext suite.
  • 71.2 build / build:types / watch — the pure config builder spec'd first, the programmatic build, dts + finalize, naming/externals/globals, the bundle-level fixture specs.
  • 71.3 test / lint / format — spawn wiring with absolute --import paths, config precedence, argv passthrough.
  • 71.4 check — the aggregate + the consumer pack-agreement gate.
  • 71.5 init — templates, the scaffolder, the tmp-dir e2e with both controls.
  • 71.6 the example extension — port SpiralLayout, file: links, extend ci-cyext, the root shape/skew specs, the debug/init.js comment.
  • 71.7 release — preflight, changelog roll, bump, publish --dry-run; specs.
  • 71.8 Closing sweepsrc/README.md, MIGRATING.md (an "authoring an extension" pointer), CHANGELOG.md, AGENTS.md final pass, EXECUTIVE_SUMMARY.md rewritten from this file.

Risks tracked

  1. npm name squattingcyext is unclaimed today; every unpublished day is exposure. Recommend a placeholder publish right after 71.1 (a maintainer action — listed under Open).
  2. Toolchain skew vs root — spec-gated; "upgrade the toolchain" becomes a two-manifest change CI notices rather than a drift.
  3. Node floor — cyext carries engines >= 24 (it runs the same tools the repo does); generated extensions deliberately carry .nvmrc, not engines (the reasoning in the templates section).
  4. Windows — array-argument execFileSync, node:path throughout, pathToFileURL for --import arguments; no Windows CI here — round 49 (reserved) owns that runner and inherits cyext, noted so it does.
  5. rolldown JS API stability — the exact pin makes it a deliberate, per-cyext-release exposure; the bundle-level specs are the canary.
  6. file:-link fidelity — in-repo the example consumes links, not tarballs; the published-pair question is explicitly round 51's bake, not silently assumed covered.

Open: the placeholder publish of cyext (maintainer action, recommended immediately after 71.1); whether LayoutContext offers a fit affordance the spiral example should expose (checked in 71.6, not assumed); whether extensions/ eventually gains the force layout as a second first-party package (out of scope, noted for round 51's triage); provenance on cyext release, parked for round 50.

The tenth design sitting — the idea backlog swept into a shortlist

Three research sweeps ran before this sitting: the repo's own record (this file's ledger and follow-up notes, src/README.md's hooks), the GitHub tracker (open and closed — the tracker is aggressively triaged at 15 open issues, so demand signal lives almost entirely in closed ones), and the ecosystem (Cytoscape Web, EnrichmentMap web, GeneMANIA, Cytoscape desktop parity, the comparison literature). The finding that organises everything else: the strongest demand signal is what the ecosystem apps had to build around the library. Cytoscape Web ships three layout engines, two export extensions, a layered-canvas annotation renderer and a full CX2 visual-style conversion layer; EnrichmentMap web ships five extensions (fcose, layers, bubblesets, automove, pdf-export). Each of those is a candidate feature with a guaranteed first consumer.

Checked against source before listing, because several perennial tracker asks turn out to be done in v4 already: combined pan+zoom/fit viewport animation in one animate() (#2966 — the viewport channel in AnimateOptions), source/target edge labels (#382), and of course the GPU tier itself. Declined territory was not re-proposed (round 40's warning policy, 41.5's preventDefault, z-index, classes, selector strings, style functions).

The shortlist — fifteen items kept by the maintainer:

  • SVG vector export — #639 (2014, the most-demanded export feature ever filed); both flagship apps bolt on third-party svg/pdf extensions for publication figures. v4 computes all geometry CPU-side (routing, arrows, labels), so this is a serializer, not a second renderer.
  • Official JSON schemas for elements/styles/layouts — #3487.
  • Headless Node image generation (obsolete cytosnap) — #2561, #954; research-gated (Dawn-in-Node vs SVG-in-headless).
  • Annotations layer — model-space text/shapes/images, z-ordered with the graph; the desktop-parity item with the longest paper trail (Cytoscape Web renders desktop annotations read-only via a layered canvas today).
  • More node chart kinds — ring/bar/heat-strip, high slice counts, on the round-23 surface (whose record already says "consider other charts"); EnrichmentMap's core visual.
  • Cluster hulls/bubble sets + expand-collapse aggregation — the AutoAnnotate/EM idiom plus issue #3486's own "cluster or component proxies" LOD plan.
  • GPU edge bundling — #2332; desktop parity; headline large-graph visual.
  • WebGL fallback renderer — ledger 18b, scheduled below as a scoping round first.
  • Worker-pool CPU executor — ledger 29, measure-first gate as written there.
  • Algorithm perf follow-ups — scheduled as round 72, plan below.
  • Worker/OffscreenCanvas-hosted renderer — #1350/#2799; frees the main thread entirely.
  • DX polish bundle — container auto-resize (#2401), font-load re-raster (#3408 + the round-9.7 logged follow-up), iterable collections (Symbol.iterator, verified absent), a public elements-at-position API (#1209 — pickNodeAt and the GPU pick exist, unexposed), wheel/gesture tuning toggles (#1905 family — explicit toggles are the sanctioned mechanism; 41.5 declined preventDefault, not toggles), viewport counts (#2283).
  • Small style wins bundle — gradients (#2091/#3407), zoom-invariant screen-space sizing (#789), text-border-style (the one recorded not-yet style gap), and the ledger-23 arrow-precision/17th-arrowhead decision.
  • Attribute-table / filter affordances — columnar column views plus fast predicate/degree/topology filters, feeding table UIs (Cytoscape Web's TableModel/TableBrowser/FilterModel; desktop filters parity). The columnar store is uniquely positioned here.
  • Layouts — radial tree (#2493), constraints on the force layout (absorbs fcose's main draw — fcose #54/#53), edge-length control (#1514), per-side compound padding (the logged hook).

Not shortlisted: CX2 import/export with visual styles (the research's top cross-source candidate, set aside by the maintainer at this sitting), multilevel force refinement (stays a logged direction), file splitting (ledger 26, stays a direction).

Two sequencing decisions, made at the sitting:

  1. The WebGL fallback's pre/post-4.0.0 positioning is decided after a scoping round, not now. It is the one shortlist item that changes what 4.0 is — WebGPU-only at launch versus reach on Firefox stable and weak GPUs — and the call gets made with data on what WebGL2 can and cannot carry of the v4 contract (instancing paths for the pipelines, compute-culling and picking substitutes, the SDF label pipeline). The scoping round produces a written feasibility record, not code.
  2. Round 72 (algorithm perf follow-ups) runs first — a natural continuation of rounds 65/69/70 while their context is fresh, with every input already logged as a follow-up.

The algorithm perf follow-ups, gathered

Rounds 65.8, 69.6 and 70.4 each closed leaving logged follow-ups; this round gathers them. Planning re-read the sources rather than the logged sentences, which mattered twice: one item is mostly stale (the "two-stage reductions" — 65.8 already landed workgroup-per-line tree reductions; the live remainder is a coalescing defect), and one is bigger than its sentence (the closeness BFS path changes the CPU side's asymptotics, not just the GPU's). What the code does today, verified:

  1. pageRank/Katz GPU SpMV is genuinely densealgo-gpu-pagerank.mts uploads the full n×n f32 matrix from buildPageRankMatrix and runs the workgroup-per-row MATVEC; algo-gpu-katz.mts reuses the same kernel. The CPU sides went sparse in 65.10 (O(E+n)/iteration), which is why both wrappers pass minGpuN: Infinity (page-rank.mts:54, katz-centrality.mts:119) — the kernels serve only explicit 'gpu' and the parity suite.
  2. AP's A_UPDATE walks rr[j*n+i]/a[j*n+i] with stride n — uncoalesced column access, ~2.6 ms/iteration at n=1024 per the 65.8 record. The surviving single-workgroup epilogues (PR/Katz epilogues, AP_CONVERGE) cost microseconds.
  3. Closeness is Floyd–Warshall on both executors — O(n³) even unweighted. The GPU already owns a batched level-synchronous BFS in algo-gpu-brandes.mts whose forward half is exactly the distance computation closeness needs, but it is not exported as a reusable plan the way fwRelaxPlan is.
  4. The heat family is combinatorial-Laplacian onlybuildHeatStructure builds L = D − A; no laplacian option. A normalized Laplacian (‖L_norm‖ ≤ 2) would bound squarings by t alone instead of by weighted degree.
  5. No device-side timing exists on the algo pathacquireAlgoGpu requests no features; the machinery exists on the render side (src/render/gpu-timer.mts).

Standing debt the round absorbs: the 69.6/70.4 crossover sweep on the benchmark machine never ran — the density gates (n²/32, n²/16), closeness's GPU_MIN_N and the resistance parity bound are still starting figures — and the EQUIVALENT_HARNESSES entry for the bench-file moves is unaddable until a run under the new hash is published.

72.1 — sparse CSR SpMV for pageRank + Katz

The one change that could flip the 65.10/69.4 'auto' verdicts. A shared CSR plan (new algo-gpu-spmv.mts): rowPtr/colIdx/vals upload (O(E) instead of O(n²) — which also lifts the n²-buffer assertFits ceiling), a workgroup-per-row CSR kernel replacing MATVEC for these two families only (the dense matmul families are untouched). Katz is the easy half: CSR of α·Aᵀ (both directions when undirected, as the CPU gather does) plus the existing KATZ_EPILOGUE. pageRank needs the structural split the CPU made in 65.10: the kernel computes only the edge-gather term; the teleport and dangling rank-1 terms move into the epilogue (a dangling-index buffer; the epilogue already tree-reduces Σtmp, it gains Σv and Σ_dangling·v). The dense builders become GPU-dead and are removed; d.ts regenerated.

Verified by the existing pageRank/Katz parity specs — tolerance absorbs the summation-order change — after one control run with the CSR vals deliberately skewed proves they discriminate the new path. Measure-first gate: the CPU is 0.3–0.6 ms on the sparse fixture and per-iteration dispatch overhead alone may exceed that at bench sizes. If the SpMV wins only past some edge count, 'auto' gains an edge-count gate (more edges favor the GPU here — the inverse of the triangle family's density gate), machine-stamped; if it never wins, Infinity stays and the wrapper comments carry the measured number — the losing configuration stays measured, the pageRankDense precedent.

72.2 — AP coalescing, and the reduction item closed honestly

(a) Close the stale half on the record: measure the surviving single-workgroup epilogues once in isolation, record the µs figure, and decline a true two-stage reduction (it adds a dispatch per iteration to save microseconds). (b) The live remainder: coalesce A_UPDATE's stride-n walks via a transposed R copy — either R_UPDATE writes rrᵀ alongside rr (it already owns the row) or a tiled transpose kernel joins the iteration; A_UPDATE then reads rows of rrᵀ/aᵀ coalesced. Measure-first gate: confirm the ~2.6 ms/iteration figure still stands locally before writing the kernel; land only if iteration time moves ≥ ~20%, else record and drop. Verified by the AP parity spec (identical-partition, discrete — it reruns green or the transpose is wrong) plus one skewed-transpose control; the affinityPropagation bench row re-prices it; the crossover (currently 256) re-tunes in 72.6.

72.3 — closeness centrality: the unweighted BFS path

Decision: build it, CPU first — the BFS reference is a guaranteed asymptotic win (O(n·m) vs O(n³)) independent of any GPU question, and it is the 65.10 pageRank story again: once the CPU goes sparse, the honest 'auto' gate changes shape.

(a) CPU: when weight is absent, per-source BFS accumulating row sums directly (reuse closenessOfRowSum/closenessResultFrom); weighted inputs keep FW on both executors (the weighted-betweenness contract precedent). Unweighted distances are exact integers on both paths, so plain-mode sums are bit-identical and harmonic sums differ only in f64 summation order — pin with a Node spec comparing BFS vs FW rows on unweighted fixtures, plus a disconnected fixture. (b) Measure: if the CPU BFS beats the GPU-FW route at every bench size on sparse graphs (expected), the sparse-unweighted 'auto' route to GPU-FW is already wrong and gets density-gated regardless of (c). (c) GPU, gated on (b)'s numbers: extract the forward-BFS half of algo-gpu-brandes.mts as an exported plan (the fwRelaxPlan refactor pattern from 69.1) — batched level-sync distances, frontier-empty probes, no sigma/delta sweep — plus a per-batch row fold with the existing unreachable-sentinel discipline. If the sparse CPU owns the family the way it owns pageRank, the GPU BFS serves only explicit 'gpu' or is logged with the measurement attached.

Bench: the existing unweighted closeness row silently switches paths — a src/ change, correct per the fingerprint rules, but the round record must say the row's meaning moved. Add a weighted closeness row so the FW route stays measured rather than asserted; extend the unweighted row's sizes upward within the REPS×slow-side budget rule from 70.4. Parity spec addition for the GPU path if built (sparse unweighted multi-component fixture, both modes), with a control run red (drop the unreachable mark or skew the level increment).

72.4 — laplacian: 'normalized' for the heat family

A laplacian?: 'combinatorial' | 'normalized' option (default 'combinatorial') on the heat options, honored by heatDiffusion and heatKernel. Implementation lands almost entirely in buildHeatStructure: normalized scales ws[a] → w/√(d_s·d_t) with a 1/0 diagonal (an isolated node's heat stays put), norm bound ‖L_norm‖∞ ≤ 2 so squarings = ceil(log2(4t))⁺ independent of degree; diffuseVector and the GPU dense build consume the same struct unchanged. One new throw (invalid value) with its spec. Verified by closed-form Node specs on a weighted pair — weight 4 discriminates the modes: combinatorial L = [[4,−4],[−4,4]], normalized [[1,−1],[−1,1]] — plus a triangle; the parity spec runs heatKernel normalized (1e-4, symmetry — and asserts the absence of row conservation deliberately, a combinatorial-only invariant); control with the √-scaling skewed. JSDoc, d.ts, src/README.md, MIGRATING/CHANGELOG rows. No new bench row — same cost shape, and changing the existing row's mode would break cross-run comparability; its comment notes it prices combinatorial.

72.5 — device-side bench rows (optional)

Adopt only if 72.1–72.3's tuning needs the kernel-vs-transfer split. acquireAlgoGpu requests 'timestamp-query' when the adapter offers it; a bench-only timer hook on AlgoGpu consulted by submitPass (reuse gpu-timer.mts's querySet/resolve machinery, accumulating across the multiple submits Brandes-style runs make); the bench gains a gpu (device) row per family beside cpu/gpu/gpu first call, each asserting 0 < device ≤ wall-gpu — its purpose is attribution, exactly what an SpMV/BFS crossover argument needs. Batch all bench-file edits (this plus 72.3's rows) so the algorithms-gpu fingerprint moves once.

72.6 — the sweep, the re-tune, and the close (hardware-gated)

On the benchmark machine: full benchmark:algorithms-gpu (SwiftShader refused, adapter identity reported), published --repeat 3 serial. From the measurements, one commit re-tunes every touched 'auto' constant — the 72.1 edge gate or Infinity, the AP crossover, the closeness gates for both routes — plus the standing 69.6/70.4 debt: the triangle/similarity density gates, closeness GPU_MIN_N, the resistance parity bound on that card's f32. Every constant's comment machine-stamped. Then the EQUIVALENT_HARNESSES entries the published run now permits. Standing close: this record, src/README.md, MIGRATING/CHANGELOG rows for the public surface (the laplacian option, the closeness path note), EXECUTIVE_SUMMARY.md rewritten from this file, d.ts regenerated, gates green (test:js, test:modules, test:throws at zero, JSDoc 100%, the Playwright algorithms-gpu project).

Risks named at planning

  • 72.1's epilogue restructure changes pageRank's summation shape — the tie-tolerant parity ordering invariant (65.5) exists for exactly this; do not tighten it.
  • 72.3 changes what an existing bench row measures without a harness edit. Correct per the fingerprint rules, but only the round record can say so — and the weighted row exists so the comparison page can attribute the move.
  • The stranded-doc-block hazard has fired in this tier before (65.4, 65.8), and 72.3's Brandes extraction is the same refactor shape — run the JSDoc gate before each commit.
  • Sequencing: 72.1–72.5 land before 72.6 so the sweep and the re-tune happen exactly once.

The WebGL2 fallback, scoped

Sequencing note (maintainer, 2026-08-19): the implementation round — if this scoping record says go — starts late: after most or all other rounds, or at minimum once the rendering design decisions are locked down (the render-affecting rounds 86, 88 and the 91–95 screen pass), because a fallback written against a moving render contract re-pays its port on every contract change. This scoping round itself can run any time.

Ledger 18b, run as the tenth sitting's sequencing decision 1 wrote it: the fallback's pre/post-4.0.0 positioning is decided after this round, on data, and this round produces a written feasibility record, not code. The direction entered the record beside item 18 (the sixth sitting, 2026-08-06): a possible WebGL fallback renderer for platforms that cannot support WebGPU — logged, never scoped. Planning read the renderer rather than the sentence, and the shape of the question changed twice: two subsystems assumed to need porting already have complete CPU fallbacks sitting in the architecture (tweens, force), and one assumed-hard piece (SDF labels) is the most portable thing in the renderer. What the code does today, verified:

  1. The contract is storage-buffer-shaped. src/contract.mts is the co-signed column layout; column-mirror.mts:13-14 uploads dirty spans as byte-for-byte copies into storage buffers. Several columns exist only to fit WebGPU's base 8-storage-buffer-per- stage budget — node.outerHalf (contract.mts:498-507), node.outerGeom (round 58), the edge.width mirror lane — and the curved-edge VS binds 6 columns + the curve blob + the visible list, exactly 8 (curved-edge-pipeline.mts:11-19). WebGL2 has no storage buffers in any stage; the substitute is vertex pulling via texelFetch from data textures (≥ 16 vertex texture units guaranteed; RGBA32F/R32UI textures, slot → texel addressing), since UBOs bottom out at 16 KiB. The budget that shaped the column layout dissolves and is replaced by a texture-encoding layer where ColumnMirror stands today.
  2. Culling and indirect draws have no WebGL2 form. cull.mts:6-28: a three-dispatch order-preserving compute compaction into visible lists + drawIndexedIndirect args per group, reused by the pick pass for cursor-region culling. WebGL2 has neither compute nor indirect draw. Two substitutes exist: CPU compaction per dirty frame (writing a visible-index texture; the store's columns are CPU-canonical, so the predicates can run where cpu-pick.mts already runs), or draw-every-slot with a vertex-stage collapse of invisible instances. Cost unknown — this is 73.2 (b).
  3. Picking is three stages today — pick-cull compute, a 64×64 r32uint cursor-tile draw, a 3-buffer readback ring doubling as a pick cache (picking.mts:4-34) — and nodes never touch it: the sync CPU pick (renderer.mts:580-615, cpu-pick.mts:36-49) reads the CPU-canonical columns and carries over unchanged. The WebGL2 substitute for the edge tile: R32UI is color-renderable in core WebGL2, and readPixels into a PIXEL_PACK_BUFFER polled through fenceSync gives a non-blocking readback of the same tile; region culling comes from the CPU cull or a scissor. Latency delta unmeasured — 73.2 (c).
  4. SDF labels are portable. glyph-atlas.mts:1-10: canvas-2D raster + Felzenszwalb–Huttenlocher EDT into one r8unorm atlas; the FS smooths at 0.5 with fwidth. Derivatives are core ESSL 3.00, R8 is core, writeTexture maps to texSubImage2D, and the LOD inputs (labelFadePx, minZoomedFontSize) are uniform math. Glyph instancing rides the same vertex-pulling answer as every pipeline. Low risk; not spiked.
  5. The adaptive scale controller already runs blind. It is pure and clock-injected (scale-controller.mts:20), and its stall-ratio fallback (scale-controller.mts:8-9, 32-35) exists for exactly the case WebGL2 makes common: EXT_disjoint_timer_query_webgl2 is widely disabled (to-verify per browser at 73.3), so gpu-timer.mts mostly has no substitute and stats().gpuFrameMs reports 0, which public-types.mts:571 already documents. The Catmull-Rom upscaler is a straight GLSL port.
  6. The tween fallback already exists, structurally. The GPU tween is a pure executor; the CPU is the reference and re-derives on settle with no readback (gpu-tween.mts:10-35), geometry channels are CPU-side even today, and the AnimationManager routes to the GPU only if (this.sink != null && ani.gpuEligible) (animation.mts:2175) — with no sink it runs wholly on the CPU. The fallback's cost is per-frame column writes + span uploads, which is precisely the cost rounds 24/25 built the offload to avoid; the record quotes those benchmarks rather than remeasuring.
  7. The force integrator has no WebGL2 equivalent — grid scatter by atomics, a monopole pyramid, an 8-storage-binding gather (gpu-force.mts:1-54); transform feedback has no scatter and no atomics. But the CPU reference simulation (round 18.1, layout/force-sim.mts) is the documented spec, and the layout duck-types renderer.startForce (layout/force.mts:426-445) — absent it, force runs on the CPU. Fallback = CPU sim; the measured CPU-vs-GPU gap is the recorded cost.
  8. The mapper eval pass is the same shape: a GPU offload of paint-channel data mappers (mapper-runtime.mts:19-24) over a CPU-canonical restyle path that headless instances exercise daily. Fallback = the CPU path, priced by existing rows.
  9. The algo GPU tier needs no port and gets none. It is compute-only by construction (algo-gpu.mts:1-22), and the executor contract already covers absence: 'auto' falls back to CPU on acquisition failure only, 'gpu' rejects loudly (executor.mts header). On a WebGL-fallback platform that is exactly the behaviour today's contract specifies — unchanged. Transform-feedback rewrites of matmul/BFS kernels are declined as a research project with no consumer.
  10. Device loss recovery translates. The core re-mounts on an external loss (core.mts:2909-2960); WebGL2's webglcontextlost/restored events drive the same re-mount shape.

The cross-cutting fact the decision framework turns on: rounds 12+ built dual CPU/WGSL implementations that agree by construction, and a WebGL renderer makes every drawn thing a triple. shaders.mts is 183 KB; that, not any single subsystem, is the maintenance headline. Standing item served in passing: ledger 18 (tween warm-up, "revisit with data" — who runs software adapters?) is answered by the same reach data 73.3 fetches; the record notes it.

73.1 — enumerate the contract surface consumed per pipeline

Read-only. For each pipeline — node, node-layer (overlay/underlay), ghost, edge, curved-edge, arrow, curved-arrow, chart, image, label (nodes + edge + the two end streams) — plus the four compute subsystems (cull, picking, tween, mapper eval, force): the exact columns/blobs bound per stage (the VERTEX_COLUMNS/ FRAGMENT_COLUMNS constants each pipeline declares), the WebGPU features assumed (storage buffers, compute, indirect draw, unpack4x8unorm, r32uint/r8 formats, texture arrays + the mip blit chain of image-arrays.mts:1-30 — WebGL2 note: generateMipmap is native there, one of the few places the port is simpler), and the WebGL2 substitute with a cost class: free / texture-pull / CPU-per-frame / absent-with-CPU-fallback / absent. Verified by checking every table row against the pipeline source and contract.mts's own budget notes — a row the source contradicts is the enumeration failing its control. Output: the table, in this round's record.

73.2 — spike-measure the three riskiest substitutes

A disposable spike, marked as such: branch spike/webgl2-fallback, never merged, deleted once the record quotes it; a standalone page, deliberately outside debug/ and playwright-page/ (no parallel harness joins the repo). Three measurements, chosen because each alone could flip the verdict, and decided against spiking anything else (labels and tweens: portability known, fallbacks exist — spiking them would measure the calendar):

(a) Vertex pulling at scale — instanced edge quads pulling positions/endpoints/width from RGBA32F textures via texelFetch, at ndex-x-large scale (465k edges), against the WebGPU renderer's measured frame cost on the same box and scene. (b) The cull substitute — CPU compaction per frame at 100k-800k elements (predicate walk + visible-index upload, ms measured separately) versus draw-everything-with-VS-collapse, both against the compute-cull frame. This also prices the loss of pick-region culling. (c) Pick readback — the R32UI tile + PBO + fenceSync path's hover latency versus Picking.lastLatencyMs on the same scene.

Rules: a real adapter — SwiftShader refused for the perf claims (the benchmark:algorithms-gpu rule), adapter identity and machine recorded beside every number; probe from a served page (the 18.5/27.9 rule); nothing enters benchmark/published/ — the harness fingerprint discipline exists to keep that archive honest, and a disposable page has no place in it. Each spike scene runs once with its substitute deliberately degraded, so a number that cannot move is caught before it is believed.

73.3 — the record, the sizing, the recommendation

The written feasibility record, assembled from 73.1's table and 73.2's numbers, one section per subsystem: what WebGL2 carries, at what measured or quoted cost, what is absent, and the user-visible degradation (no 'gpu' algo executor, CPU force, CPU tween cost curve, gpuFrameMs 0). Then:

  • Reach data, fetched not assumed: WebFetch caniuse (webgpu, webgl2, offscreencanvas) + vendor release notes, into a per-browser/per-OS table stamped with the fetch date — Firefox stable and blocklisted/older GPUs are the population the fallback exists for. Include the in-worker availability columns for both APIs, so round 86 reads this table instead of refetching.
  • Sizing the real fallback arc: a rounds-scale estimate — GLSL ports of the shader families, the texture-pull data layer standing where ColumnMirror stands, CPU cull, the pick port — plus the standing tax: triple-implementation upkeep on every future drawn feature, and the parity-suite implication (the honest gate is a live v4-webgl-vs-v4-webgpu diff project on the v3-parity harness shape, plus its own goldens and CI browser cost).
  • The recommendation, framed for the maintainer: pre-4.0 (reach at launch; delays 4.0 by the arc; two renderers forever), post-4.0 (WebGPU-only launch, fallback as 4.x if the reach table demands it), or never (document headless + the reach data). The call is the maintainer's, made on this record — sequencing decision 1.
  • Where the record lives: appended to PLAN.md as this round's record — the repo's design records (sittings, round records) live here — with a short entry added to src/README.md's "Design decisions" section pointing at it, because a fallback decision changes what the package claims to require and src/README.md is the maintained scope doc. Closing sweep as standing: EXECUTIVE_SUMMARY.md rewritten, the ledger annotated (18b gains its data; 18's reach question gains the same).

Risks named at planning

  • A spike measures its own naivety unless it replicates enough of the render-on-dirty structure to discriminate — hence the degraded-substitute control on each scene, the same rule the parity suite lives by.
  • No src/ changes land, so the code gates don't bind this round; the docs-sweep and summary-rewrite rules still do.
  • Browser-support claims go stale fastest of anything this repo records: every reach figure carries its fetch date, and the record says explicitly that they are claims to re-verify at decision time, not facts to build on.
  • The temptation this round must resist is scoping by porting: the moment a spike file starts resembling a pipeline, it has exceeded its mandate — the branch name and the deletion rule are the guard.

Open: confirmation of the record's home (PLAN.md record + src/README.md pointer, as proposed, versus a standalone document); whether the spike runs on the benchmark machine (the numbers want the same adapter the published renderer profile uses); whether the record should also price a partial fallback (nodes/edges/labels only — no charts, images, ghosts at first) as a third positioning option; and the framework weights — reach versus two-renderer maintenance versus parity-suite cost — which are the maintainer's to set at the sitting that consumes this record.

The worker-pool CPU executor

The seed is ledger item 29 (raised at 65.10): the per-source-parallel algorithms behind a pool of plain workers — Node worker_threads, browser Worker, transferable typed arrays, no SharedArrayBuffer and so no COOP/COEP, deliberately not wasm (SIMD reorders float sums, wasm threads inherit the SAB constraint, and a toolchain fights code standard 7). Planning re-read the sources rather than the ledger's sentences, which mattered twice: the ledger's bit-reproducibility claim is half wrong (fact 5 below), and the per-source-parallel family is larger than the one algorithm the ledger names (fact 3). What the code does today, verified:

  1. Weighted betweenness is contracted CPU-only and is n independent Dijkstrasbetweenness-centrality.mts:52-57 passes gpu: null with the no-GPU-path rejection message whenever weight is given, and the reference loop (:207-277) runs one heap-driven Dijkstra per source, accumulating C[w] += e[w] (:270) across sources. The only cross-source coupling is that sum.
  2. The weight is a user closure and cannot cross a workerweightAt (algo-shared.mts:198-207) calls the caller's fn on an interned element per edge slot. A worker snapshot must pre-evaluate weights on the main thread into a Float64Array, one per representative edge, with the same edge choice buildBrandesNeighbors makes (betweenness-centrality.mts:70-134 — which also returns number[][], so the snapshot flattens to CSR rowPtr/colIdx anyway).
  3. The per-source-parallel family is five wide, not one. Beyond weighted betweenness: unweighted betweenness (same loop, but it has a GPU lane with a measured 512 crossover, betweenness-centrality.mts:43-49); heatKernel's n independent diffuseVector columns (heat-kernel.mts:344); rwrProximity's n independent solveWalk columns (random-walk.mts:402); and — only once round 72.3 lands — the closeness per-source BFS (today closeness is Floyd–Warshall on both executors, closeness-centrality.mts:146-148). motifCensus's s-loop (motif-census.mts:160) shares dyad counters mid-walk and is not independent; excluded.
  4. The executor contract has the slotAlgoExecutor is 'cpu' | 'gpu' | 'auto' (executor.mts:36), runAlgo routes with per-family minGpuN and falls back to CPU only on acquisition failure or GpuUnfitError (executor.mts:116-141), and the GPU side caches a lazy module singleton with a _resetAlgoGpu test hook (algo-gpu.mts:32, :95) — the exact lifecycle shape the pool should copy.
  5. The ledger's bit-identity claim does not survive re-derivation. Contiguous source ranges merged in range order give (c0+c1)+(c2+c3), the sequential reference ((c0+c1)+c2)+c3 — different f64 grouping wherever an output sums across sources (betweenness's C[w]). What contiguous ranges do buy, exactly: any family whose output element is computed whole from one source (closeness scores, heat/RWR columns) is bit-identical to the CPU reference, and betweenness is deterministic per input if the partition is a pure function of n (never of pool size), with CPU parity at f64-tight tolerance — far tighter than the GPU's f32 invariants, but not bits.
  6. No worker exists anywhere in the tree today (grep: zero hits for worker_threads/new Worker outside node_modules), and the build emits five single-file bundles (rolldown.config.mjs) whose agreement the packaging chain gates — a second emitted worker chunk would break every one of them.

Entry condition — the measure-first gate, as ledger 29 wrote it: weighted betweenness at n=2048 through 4- and 8-worker pools against the sequential reference on the same box; the round proceeds past 74.1 only if the 8-worker speedup clears ~3× after pool-startup amortization, and the per-worker CSR copy cost is re-checked against SAB before concluding SAB is unnecessary. If the gate fails, the numbers go in this record, ledger 29 closes as measured-and-declined (the pageRankDense losing-configuration precedent), and 74.2–74.5 do not run.

Round-72 interaction, stated up front: round 72 is planned the same sitting and sequenced first (the round-71 sitting's decision 2). The closeness workers lane depends on 72.3's BFS path existing; if 74 runs before 72.3 lands, closeness stays out of 74.3's wiring and is logged. Both rounds edit the algorithm bench files, so their fingerprint moves land batched per suite, not interleaved.

74.1 — the gate measurement

A standalone probe, scratch at the root (bc-workers.scratch.mjs — the d2.scratch.mjs shape; disposable, its numbers live here in the round record), run as plain .mjs without tsx: the sequential baseline goes through build/cytoscape.esm.mjs, because a per-source loop that builds closures is exactly the shape the __name lesson says tsx distorts. The worker half hand-rolls what 74.2 would ship: a self-contained body function (CSR Dijkstra–Brandes over rowPtr/colIdx/weights, indexed heap inline), spawned via worker_threads. Fixture: the deterministic degree-4 graph at n=2048 with a data-driven weight (sizes from bench-size.mjs, never graph.mjs — the v3-free rule). Measured, each --repeat 3-style thrice: sequential reference; 4- and 8-worker warm runs (pool reused, per-call snapshot included); cold first call (spawn + first run, the amortization the gate names); the snapshot build + per-worker structured-clone cost in isolation; and the same clone measured against a SAB variant (Node needs no isolation flags, so the probe can price both). Record: the 3× verdict, the 4→8 scaling shape, the clone-vs-SAB delta (expected trivial at ~100 KB CSR; if it is not, that finding goes to the maintainer before 74.2), and a browser spot check of the same body through a Blob worker on the debug page — manual, numbers noted, because 74.2's one-code-path claim rests on it.

74.2 — the pool: lifecycle and the worker entry

Two new files. src/algorithms/algo-worker-body.mts: the worker body as one exported, fully self-contained function — no imports, no outer references, no class syntax — that receives jobs and answers results through a tiny injected port adapter. It computes one contiguous source range of Brandes–Dijkstra over a CSR snapshot and returns the range's partial C as a transferred Float64Array. src/algorithms/algo-workers.mts: the pool — a lazy module singleton (the algo-gpu.mts:32 shape) with _resetAlgoWorkers() beside it (the :95 precedent); size min(availableParallelism() - 1, 8) (Node) / hardwareConcurrency - 1 capped likewise (browser) as a starting figure, 74.5 stamps it; Node workers unref()ed so a pool never holds a process open.

The bundle carries the worker entry as source text, not a chunk: the pool stringifies the body (Function.prototype.toString), wraps it in an environment preamble, and constructs Node workers with new Worker(src, { eval: true }) and browser workers from URL.createObjectURL(new Blob([src])). This keeps all five single-file bundles single-file and asks nothing of rolldown's worker bundling (which exists for chunk-emitting outputs but is declined here by shape, not capability — to-verify only if the string path fails). Three hazards named now: tsx injects __name wrappers on closure creation (the AGENTS hot-path lesson), so the preamble defines a no-op __name before evaluating the body, and 74.4's bare-scope spec is the tripwire for any other helper a transpiler sneaks in; Node's eval-mode worker source is CJS-flavored (the preamble requires node:worker_threads for its port) — to-verify on the pinned Node; and a CSP worker-src policy can refuse blob workers — construction failure under 'auto' falls back to CPU exactly as GPU acquisition failure does (executor.mts:124-128), and an explicit 'workers' rejects loudly.

Determinism is designed here, honestly (fact 5): the partition is a pure function of n alone — fixed range count, never the pool size — and the merge adds range partials in range order. So the workers executor is bit-stable across runs, machines and pool sizes; the per-column families are additionally bit-identical to 'cpu'; and betweenness is documented as f64-tight-but-not-bit-equal to the reference, which corrects ledger 29's sentence on the record. Weights are pre-evaluated main-thread into the snapshot (fact 2); the CSR is structured-cloned per worker (priced in 74.1), results come back transferred.

74.3 — the 'workers' executor, wired

AlgoExecutor grows 'workers' (executor.mts:36), resolveExecutor accepts it and its throw message updates (:66-68), and runAlgo grows a workers lane with a per-family minWorkersN: explicit 'workers' rejects loudly when the family has no workers path or the environment cannot construct one; 'auto' keeps its GPU preference where a GPU lane exists and fits, then takes workers when there is no GPU path, no adapter, or a GpuUnfitError — and n clears WORKERS_MIN_N (a starting figure from 74.1, stamped in 74.5) — then CPU. Wired this round: weighted betweenness (the headline — its gpu: null slot at betweenness-centrality.mts:52 gains the workers lane) and unweighted betweenness (workers behind the GPU's 512 crossover until 74.5 measures the three-way ordering). Enumerated and scope-gated: heatKernel and rwrProximity join only if 74.1's scaling generalizes to the column shape (each is a mechanical column partition, bit-identical by fact 5); closeness joins only after 72.3's BFS lands. Whatever is not wired is logged with its reason. JSDoc on every executor option doc that now names four values, d.ts regenerated, src/README.md's executor section extended, MIGRATING/CHANGELOG rows for the public value.

74.4 — tests: parity, throws, soak

Node parity (test/algorithms-workers.mjs): cpu-vs-workers weighted betweenness at tolerance with the tie-tolerant ordering invariant (the 65.5 shape — do not tighten it past fact 5's grouping analysis); exact bit-equality asserted where it truly holds — the per-column families if wired, and closeness plain sums post-72.3 (integer distances, exact in f64). Pool-size independence is its own spec: forced pools of 1, 2 and 3 workers answer identical bits. Controls before trusting any of it: skew one range's partial, drop a range — each spec goes red or it discriminates nothing. Every new guard is pinned in test/ (the throw gate reads neither test/modules/ nor test/soak/): the invalid-executor message, the explicit-'workers' rejection under a stubbed Worker constructor (the stubbed-navigator precedent in test/algorithms-executor.mjs). A module spec proves the entry-carriage claim where it can actually break: extract the stringified body from the built ESM and UMD and under tsx, evaluate it with new Function in a bare scope (a stray __name or helper is a ReferenceError), and ping a real worker_threads worker through it. Soak (test/soak/workers.mjs, under --expose-gc): repeated runs grow neither worker count nor reachable instances (WeakRef collection, not bytes — the 48.1 rule, with the probe's own control first); _resetAlgoWorkers lets workers collect; two instances interleaving runs stay isolated (isolation.mjs shape); and a spawned child process exits cleanly after a workers run — the unref() claim, asserted rather than believed. Browser: one Playwright spec beside algorithms-gpu.spec.js running cpu-vs-workers through the built UMD — it needs no adapter and carries no hasAdapter skip (the routing.spec.js precedent), and it is the one place the Blob path and the minified stringified body are exercised for real.

74.5 — bench rows, crossover constants, and the close

New standalone benchmark/algorithms-workers.mjs under its own profile (the 65.9 precedent — standard results shape, promoted by benchmark:publish): per wired family, cpu / workers / workers first call rows, sized where 74.1 showed separation so the rows discriminate; each row asserts it ran where its name says via a pool stats hook (runs, worker count — the row-asserts-its-property rule) and the cpu row asserts the pool stayed untouched. The suite saturates cores, so it schedules exclusive under --jobsschedule.mjs:209 keys exclusivity on browser: true today, so the job table gains the flag under an honest name; a new file has no archive, so no EQUIVALENT_HARNESSES question arises. On the benchmark machine: publish --repeat 3 serial, then one commit stamps WORKERS_MIN_N per family and the pool-size cap with the machine and figures, and — where GPU and workers lanes coexist — re-checks the three-way 'auto' ordering against the GPU crossovers (jointly with 72.6's sweep if round 72 has landed, so each suite's fingerprint moves once). Standing close: this record, the ledger-29 entry closed with its correction noted, src/README.md, MIGRATING/CHANGELOG, EXECUTIVE_SUMMARY.md rewritten from this file, d.ts regenerated, npm run format, gates green (test:js, test:modules, test:soak, test:throws at zero, JSDoc 100%, the new Playwright spec).

Risks named at planning

  • The stringified-body scheme is the round's novel mechanism and its failure mode is environmental: a transpiler helper in the body, an eval-mode semantics change, a CSP refusal. Each has a named tripwire (the bare-scope spec, the pinned-Node verify, the loud explicit-'workers' rejection); the residual risk is 'auto' silently never using workers on locked-down pages — accepted, as the exact symmetry of GPU acquisition failure, and documented.
  • Fact 5 corrects a ledger claim this plan was asked to build on. The parity specs must not overclaim: bit-equality only where derived, tolerance elsewhere, and the executor-contract comment in executor.mts updated so the determinism ladder (cpu > workers > gpu) is written where callers read it.
  • Oversubscription: a pool at cores−1 beside the test runner's own parallelism is the Playwright half-cores lesson in Node form — the soak and parity specs force small pools, and only the exclusive bench job runs wide.
  • Sequencing: 74.1 gates everything; 72.3 gates closeness; bench edits batch with 72's so each fingerprint moves once.

Open: four maintainer decisions. (1) The public name of the executor value — 'workers' as planned, or 'threads'; it ships in d.ts and is expensive to rename later. (2) Whether 'auto' may ever prefer workers over a present GPU where both lanes exist — the plan keeps GPU precedence until 74.5 measures a family where workers win on real hardware, but that default is a policy, not a measurement. (3) Whether betweenness's not-bit-identical merge is accepted as designed (fixed partition, f64-tight parity — recommended), or exact bit-identity is bought with per-source delta replay at O(n²) transfer — the cost that made the ledger's claim tempting and wrong. (4) Whether a poolSize option ships publicly now or the cap stays an internal stamped constant until someone asks — and, smaller, whether the 74.1 probe is committed as a scratch (the d2.scratch.mjs precedent) or discarded once its numbers are in this record.

The DX polish bundle

Six small items from the backlog's DX bundle, each re-verified against the source before planning — which mattered immediately: two of the six are further along than their backlog sentences say. The auto-resize observer already exists, unrecorded and untested; the font re-raster hook already lands the hard half (the round-10 loadingdone listener re-measures and re-rasters everything), and what remains are its edge cases. What the code does today, verified:

  1. Container auto-resize (#2401) is two-thirds landed. The renderer installs a ResizeObserver on the container at construction (src/render/renderer.mts:356-360, arriving with the round-42 restructure blob; no PLAN.md round records it) and disconnects it on destroy (renderer.mts:458); resize() itself (renderer.mts:402-410) re-applies size and schedules a redraw, and its own doc comment already describes the observer. Headless is a structural no-op — no renderer exists to observe. The gaps: the observer calls renderer.resize() directly, so the public 'resize' event fires only from manual cy.resize() (src/core.mts:2008-2013); no option gates the observer; and no spec anywhere exercises the observer path — grep playwright-tests/ for resize: nothing.
  2. Font-load re-raster (#3408 + the round-9.7 follow-up) landed its core in round 10. renderer.mts:302-317 hooks document.fonts 'loadingdone'labelLayer.reraster() (src/render/label-layer.mts:70-74: atlas reset, shaping memo cleared, markAllLabelsDirty — a full re-measure, not just new pixels), removed on destroy (renderer.mts:462-468), pinned by a Playwright spec (renderer.spec.js:5102). The verified gaps: (a) any loadingdone — an unrelated icon font — costs a full atlas re-raster and every-label re-layout; (b) the spec's own comment names the constraint — "a set-initiated load, so the FontFaceSet fires 'loadingdone'" — and the other order, face.load() then document.fonts.add(face), fires no event at all, leaving fallback glyphs forever; (c) no document.fonts.ready belt exists; (d) there is no user escape hatch — setFont with the same family early-returns (src/render/glyph-atlas.mts:243-249).
  3. Collections are not iterable. Symbol.iterator appears nowhere in src/; iteration is forEach/each (src/collection.mts:683,6321), toArray, and numeric index + length (collection.mts:232-238, interned singleton handles per slot). cy.add takes ElementsInput (src/public-types.mts:134-139): defs (array or {nodes,edges}), columnar, or a wire ArrayBuffer/view — no generic iterables. Hazard verified for the design: typed arrays are themselves iterable, so any iterable branch must sit after the wire-payload checks.
  4. The elements-at-position internals are public in one half already. cy.pick(x, y) — async, CSS px, exact, null when headless — has shipped since the pick work (core.mts:1980-1984), resolving through the 3-stage renderer pick (renderer.mts:521-568: sync CPU node pick → cached tile → GPU edge tile). The sync CPU node pick pickNodeSync (renderer.mts:580) is what the pointer layer uses for pan-vs-grab (src/interact/pointer.mts:371) — but it is absent from RendererLike (core.mts:82-95), so no public surface reaches it. #1209 reduces to: expose the sync half, and name the pair.
  5. Gesture toggles: the quartet + tuning options exist (public-types.mts:610-672), the wheel is hardwired, and one real deviation surfaced. onWheel (pointer.mts:235-277) calls e.preventDefault() unconditionally, before the userZoomingEnabled check — so a zoom-disabled v4 canvas still swallows page scroll, where v3 preventDefaults only when all four pan/zoom toggles allow it (v3/src/extensions/renderer/base/load-listeners.mts: 1130-1131). Trackpad pinch arrives as ctrl+wheel through the same path (pointer.mts:50-51). Drag-from-element panning needs no new toggle: per-element pannable exists (public-types.mts:33, edges default true), panify/ unpanify exist (collection.mts:3772,3782), pannable overrides grabbable in canDrag (pointer.mts:494), and a press on any non-draggable node already pans (pointer.mts:385-387). The 41.5 decree stands: toggles are the whole story, preventDefault() is browser-level only — new options are the sanctioned mechanism.
  6. Cull counts are on-device only. The compaction's scan writes instanceCount into the indirect args (src/render/cull.mts:445) in a buffer created STORAGE | INDIRECT — no COPY_SRC, and no readback of it exists anywhere (cull.mts:862); stats() reports store counts, not visible ones (renderer.mts:382-383). So the decision the backlog left open is decided by the code: exact viewport counts need a readback, so the API is async — the picking.mts staging/coalescing pattern is the in-repo precedent. The model-space sync form already exists as a composition: cy.elementsInBox(...) (core.mts:1274) over cy.extent() (core.mts:1824) — geometric containment, which deliberately does not see draw-tier hide/LOD.

Sequencing: the items are independent, but three of them edit renderer.mts; those land in sequence (75.1 → 75.2 → 75.6) while the core/pointer items (75.3, 75.4, 75.5) interleave freely. Each pass lands whole — specs, JSDoc (@param/@returns/@throws at 100%), d.ts regen, MIGRATING/CHANGELOG rows where public surface moves — so an item slipping does not hold the bundle.

75.1 — auto-resize, recorded and closed honestly

No new machinery; the round makes the existing observer true and proven. (a) Route the observer through the core: the callback becomes this.cy.resize() so observer-driven resizes fire the public 'resize' event exactly as manual calls do — one path, one emit (no loop: applySize touches the canvas, and the canvas's CSS size is 100% of the container, so the observer does not re-fire from its own work). (b) Decline the opt-out option and the debounce, in writing: the observer is the v4 contract (the resize() doc comment already says so), ResizeObserver batches per frame and applySize is cheap, and cy.resize() stays for what an observer cannot see (a devicePixelRatio change). (c) The missing coverage: a Playwright spec that resizes the container element, awaits the 'resize' event and asserts the canvas's device-pixel dimensions followed — control: the same scene with the observer disconnected must time out the event. (d) Record the observer's existence and provenance in src/README.md; MIGRATING row ("no manual cy.resize() on container resize — v3 needed one").

75.2 — the font edge cases, and the escape hatch question

Three verified gaps, smallest honest fix for each. (a) Family-filter the re-raster: onFontsLoadingDone reads the event's fontfaces and re-rasters only when a loaded face's family matches the atlas font (normalized: case, quotes) — an unrelated font no longer costs an every-label re-layout. Conservative fallback: an event with no readable face list re-rasters as today (WebKit's event payload here is to-verify). The match logic lands as a pure exported function with a test/modules/ spec; control: force the filter to answer false and the round-10 Playwright late-font spec must fail. (b) A document.fonts.ready one-shot belt: the atlas marks itself provisional when it rasters any glyph while document.fonts.check(font) is false; document.fonts.ready resolution re-checks and re-rasters once if still provisional — covering a face that finishes between atlas construction and listener traffic. (c) The load-then-add orphan (face.load() before document.fonts.add): no event exists for it, period. The plan's mechanism: while provisional, each rendered frame re-checks document.fonts.check (a sync string lookup) and re-rasters on the flip — this catches the orphan on any page that is animating or interacting; a page fully at rest with a post-hoc font add stays wrong until any redraw, which the JSDoc states, with "add before load" as the documented order. Whether that residual deserves a low-frequency timer is left to the maintainer (Open). New Playwright spec pinning the orphan order end-to-end; control: provisional flag forced false.

75.3 — iterable collections

Collection.prototype[Symbol.iterator] yielding the interned singleton handles off _arr() — so [...eles][0] === eles[0] (the identity story is already the interning contract, collection.mts:226-229), for..of, spread and Array.from all work, and iteration sees the members at collection creation (refs are immutable; dead members yield as stale handles, exactly as forEach passes them). d.ts: Collection implements Iterable<Collection>. cy.add gains generic iterables of element definitions, materialized with Array.from — the branch sits after isSerializedElements/ArrayBuffer.isView and the columnar discriminant, because typed arrays are iterable and a wire payload must never be walked as defs; a bare string (also iterable) falls through to the existing def-validation errors, so no new throw site is expected — if one proves necessary, it lands with its spec for the zero-tolerance gate. Specs extend test/collection-iteration.mjs (identity, order, spread, Array.from, empty collection, post-removal iteration) plus a cy.add(generator) spec and a cy.add(wireBufferAsUint8Array)-still-decodes regression; control: an iterator yielding reversed order must fail the order assertion. MIGRATING row (v3 collections were not iterable either — this is a v4 addition, not a parity item).

75.4 — the public sync node pick

Expose the half that interaction already trusts: pickNodeSync joins RendererLike, and the core gains the sync counterpart of cy.pick — CPU, nodes only, exact (halo-free, like cy.pick; the 57.9 halos stay the gesture's), CSS px relative to the container, Collection | null, null when headless (matching cy.pick's headless null — the draw-tier frame parameters belong to a renderer). Working name cy.pickNode(x, y), final name a maintainer call (Open). The two-API shape is justified by how interaction itself uses picking: pan-vs-grab must answer in the same microtask (pointer.mts:369-374), edges genuinely need the GPU tile — so the public surface mirrors the real split rather than inventing a third path. JSDoc cross-references the pair and the halo note; d.ts regen; MIGRATING row (#1209 — v3 never had a public form). Specs: Playwright (hit, miss, topmost-overlap agreement with cy.pick, an edge answering only through cy.pick); Node spec for the headless null. Control: the shape-aware assertions from test/cpu-pick.mjs are the template — a spec named for slanted-outline picking must fail with the shape test swapped for the bounding box (the round-27 lesson, verbatim).

75.5 — wheel behavior, scoped to two changes

Both inside the 41.5 settlement: options-level toggles, and the library only ever preventDefaults events it consumes. (a) Fix the deviation: onWheel preventDefaults only when it will act (zooming enabled, or the configured behavior consumes the event) — restoring v3's contract that a zoom-disabled canvas lets the page scroll. This is a behavior fix, not an option. Playwright spec on a scrollable page: userZoomingEnabled(false), wheel over the canvas, assert window.scrollY moved and zoom did not; control: revert the ordering. (b) One new option + accessor, wheelBehavior: 'zoom' | 'pan' | 'modifier-zoom' (default 'zoom', the current behavior; accessor in the wheelSensitivity pattern, settable at runtime). 'pan': wheel pans by delta (gated by panningEnabled/ userPanningEnabled), ctrl+wheel — the pinch encoding — still zooms, so trackpads keep pinch-zoom. 'modifier-zoom': plain wheel is not consumed (no preventDefault; the page scrolls — the embedded-map idiom of #1905/#3287), ctrl- or meta-wheel zooms; pinch keeps working for free since it arrives as ctrl+wheel. Whether wheel-pan emits a 'scrollpan' sibling to 'scrollzoom' in the 17.4 vocabulary is a maintainer call (Open). Deliberately excluded, named: a wheel-pan speed multiplier, axis swap/inversion options, configurable modifier keys, touch-gesture remapping, any preventable-gesture mechanism (41.5, permanently), and a drag-from-element panning toggle — verified already expressible (per-element pannable/panify, autoungrabify, and the press-on-undraggable-pans default at pointer.mts:385-387), so a new option would duplicate surface. MIGRATING table row + test/modules/migration-guide.mjs update; Node specs for the accessor plumbing; Playwright specs per mode; control: swap the mode branch and each mode's spec must fail.

75.6 — viewport counts, async because the code says so

cy.viewportCounts(): Promise<{ nodes, edges } | null> — null when headless (the cy.pick precedent; Open to a reject instead). Implementation: the cull groups' indirect args gain COPY_SRC (to-verify against Dawn's usage validation, expected fine); the renderer copies the instanceCount u32 of the four element groups (node + parentNode, edge + curvedEdge) into one small staging buffer — the copy encoded after the cull dispatches in the same submission, so the numbers are exactly the frame's — then mapAsync. When the scene is clean, the request schedules a frame (render-on-dirty stays intact; a pick-style count-only frame is the cheap path). Concurrent requests coalesce latest-wins and resolve together; destroy/device-loss resolves null (both patterns from picking.mts). Glyph streams are excluded and the JSDoc says why: those counts are glyph instances, not labels. The JSDoc also names the sync alternative — cy.elementsInBox(...cy.extent()) — and the deliberate difference (model-space geometry; no hide/LOD). The mask form (visible-list readback → a Collection) is logged as a follow-up, not built: counts serve #2283's stated need, and the list is a larger readback with a compaction-order contract worth its own round. Specs: Playwright — known scene, exact count; pan half the graph out, count moves; visibility: hidden excluded; control: read args offset 0 (indexCount, constant 6) instead of offset 4 and every count spec must fail. Node: headless null; throw specs for any new guard, gate at zero.

75.7 — the close

MIGRATING/CHANGELOG rows for the five public-surface moves (pickNode, wheelBehavior + the preventDefault fix, viewportCounts, iterables, the auto-resize event note), src/README.md sweep (the observer's provenance, the font contract, the pick pair), d.ts regenerated and committed, EXECUTIVE_SUMMARY.md rewritten from this file, gates green: test:js, test:modules, test:throws at zero, JSDoc three-tag 100%, oxfmt, the renderer Playwright project, and goldens untouched (nothing here should move a pixel; if one moves, that is a finding).

Risks named at planning

  • Every new Playwright spec runs on SwiftShader behind the frame driver: wheel and resize specs must poll for state (untilMidFlight's rule), never sleep to an offset, and the first animate()-free frames still pay first-use pipeline compilation.
  • The font family filter can suppress a legitimate re-raster if normalization misses a spelling (quoted families, fallback lists); the conservative no-face-list fallback bounds the damage, and the control run proves the filter can fail red.
  • Routing the observer through cy.resize() emits 'resize' from a browser callback — any listener that itself resizes the container can oscillate. That is app behavior, but the spec suite should include the guard case (observer fires during an in-flight applySize), and the plan asserts no feedback loop from the canvas's own sizing.
  • STORAGE | INDIRECT | COPY_SRC is expected valid everywhere WebGPU ships, but it is marked to-verify; if a backend rejects it, the fallback is a tiny dedicated copy of the args via a compute pass, not a feature cut.
  • The stranded-doc-block hazard: 75.4 and 75.5 insert members into the two largest doc surfaces (core.mts, pointer.mts) — run the JSDoc gate before each commit, and read the d.ts hover text for the neighbors of every insertion.

Open: the maintainer decisions this plan parks rather than guesses. (1) Naming: cy.pickNode vs cy.nodeAt for the sync pick, and 'modifier-zoom' vs 'ctrl-zoom' for the wheel value. (2) Headless answers: null (planned) vs reject for viewportCounts, and whether the sync node pick should compute headless from the viewport at dpr 1 instead of answering null — feasible, since the CPU pick reads only store columns and frame params. (3) Whether wheel-pan joins the 17.4 event vocabulary as 'scrollpan' or emits nothing. (4) The font orphan residual: is the rendered-frame re-check plus documented ordering enough, or does a low-frequency provisional-state timer earn its keep? (5) Confirming the two declines recorded here as decisions: no auto-resize opt-out option, and no debounce.

The small style wins bundle

Four items gathered from the demand log. Planning re-read the sources rather than the demand's sentences, which mattered twice: the gradients item is already landed — round 13 C2 shipped the whole surface the demand asks for, with a golden and a live v3 parity scene, because v3 has had background-fill/line-fill gradients since 3.6/3.7 and the demand's premise that they have no v3 counterpart is itself stale — and the screen-space-sizing item is bigger than a bundle slot. What the code does today, verified:

  1. Gradients exist. background-fill (solid | linear-gradient | radial-gradient) with background-gradient-stop-colors/-positions/-direction (src/style.mts:812-814) and line-fill with stops along the drawn span (src/style.mts:903-904), packed into the node.gradient/edge.gradient Uint32Array×8 columns with the recorded 5-stop cap (src/contract.mts:565-575); sRGB interpolation, stop lists constants-only, enums mapper-capable (src/README.md:853-862). Pinned by the gradients golden (playwright-tests/visual.spec.js:2211) and a live parity scene that drives v3's own gradient props (visual.spec.js:5996). What does not exist: no benchmark row prices the gradient fragment path, anywhere.
  2. text-border-style is the one honest gap. text-border-width/-color/-opacity exist (src/style.mts:874-876) and draw in fsLabel as a band inward from the padded box on solid quads (src/render/shaders.mts:4411-4419); the style keyword is recorded not-yet (CHANGELOG.md:244, MIGRATING.md:314/548) by round 38's deliberate docs-first call (src/README.md:4042): the label box is a different pipeline and node-border dashing made nothing free there. The solid quad's shape id rides uv1.x (shaders.mts:4379); uv1.y appears unbound on solid quads (glyph-buffer.mts:219 — the spare lane a style id would take; to-verify at implementation).
  3. Screen-space sizing touches every model-px reader. boundingBox() reads node.size in model px (src/collection.mts:2899) with labels in the box by default (round 16.4, collection.mts:2846); fit derives zoom from that box (src/core.mts:1754) — a screen-px element's bounds depend on zoom, so fit becomes a fixpoint problem. The GPU cull computes extents as size × zoomDpr and bakes the label LOD floor as a zoomDpr threshold (src/render/cull.mts:153,189,389-419); the CPU pick scales model sizes by zoomDpr per candidate (src/render/cpu-pick.mts:106-107); arrow trims and curve geometry consume edge.width model px per vertex.
  4. Ledger 23's arithmetic, re-derived. edge.arrowShapes holds arrow-scale ×16 in bits 24..31 with bits 18..23 reserved (src/contract.mts:374-397); the measured cost is 1.8% on every arrow quantity at arrow-scale: 1.4 (ledger item 23). The round-56 SHOWS_LINE flags live at bits 18/19 of the mirror copy only, and the contract already warns they move if the reserve is spent (contract.mts:400-427). The part the ledger does not spell out: a full 14-bit ×128 spend uses all 32 bits (16 id + 2 hollow + 14 scale) and leaves the mirror word no room for its two flags; a 12-bit ×64 spend (bits 20..31) keeps mirror 18/19 free and still quarters the error.

76.1 — gradients: the stale item closed honestly

No new props. Scope: sweep the three demand issues (#2091/#3407/#2207) against the shipped surface and add the MIGRATING/CHANGELOG sentences that close them; correct the demand log (v3 has gradients — the parity scene is the proof); add the missing measurement — a render-bench pair scene, solid vs gradient fills at the 25k size (benchmark/render-bench.mjs, the solid/dashed hexagon-border pair's shape at :121-133; compare device rows). Measure-first gate: the pair is the gate — the border precedent says a fragment premium may be unmeasurable at scene level; record the number either way. A close-up scene is deliberately declined: a gradient error is a ramp, not a boundary effect, so magnification buys the diff nothing — record the reasoning. Batch the bench edit with 80.3's scene (below) so the renderer fingerprint moves once. Files: benchmark/render-bench.mjs, benchmark/render-bench.html, MIGRATING.md, CHANGELOG.md, src/README.md.

76.2 — screen-space sizing: read, decide, split

The reading supports a split, and this pass says so. The knock-ons are structural, not shader-local: the bb/fit fixpoint (a screen-px element's box must either evaluate at the current zoom — making boundingBox() zoom-dependent, a semantics change fit/animate and every caller inherits — or stay excluded the way v3 excluded labels), a per-element branch in the cull kernels, the CPU pick's scale term, the baked label LOD thresholds, and every per-vertex consumer of edge.width. That is a round, not a bundle slot. 76.2 delivers the design for the sitting: property scope proposal (first tranche: font-size only — fixed-px labels are the bulk of #789's nineteen comments, and labels are the one surface with an existing exclusion story; node size/edge width follow only if the label round proves the bb rule), and the API shape — per-property unit (a '12 screen' suffix string, the 'N%' precedent; no functions, the serializable-sheet rule) versus a per-sheet flag — plus the bb rule choice. The implementation is proposed as its own round with this skeleton attached. Nothing else lands in 76.

76.3 — text-border-style

Parse v3's enum (solid | dotted | dashed | double) into a computed field, carry it to the solid-quad glyph record (the spare uv1.y lane; if occupied in fact, the record grows a word — measure the glyph-buffer size cost first), and give fsLabel a dash-gated perimeter coordinate for the rect/round-rect box — round 38's closed-form tier shape, and the easy tier only: no polygon case exists here. Derivatives hoist above the branch (the chart-FS uniformity rule). Dash constants come from reading v3's drawText source, not from assumption; if v3's double is degenerate the way its outline double is (src/README.md:4035-4038), record and match. Verification: a golden with all four styles on labeled nodes and edges; a live close-up parity scene vs v3 at zoom ≥ 2 (round 38's lesson: at zoom 1 a solid border reads within a percent of a dashed one) with a feature-off control past the bound; Node specs for parse/readback/throw. No new bench row — dash-gated label-box fragments are a smaller frame share than the hexagon-border case that already measured unmeasurable; the record says so. Files: src/style.mts, src/render/glyph-buffer.mts, src/render/shaders.mts, test/ label specs, playwright-tests/visual.spec.js, MIGRATING.md, CHANGELOG.md.

76.4 — ledger 23, forced

This round puts the reserve on the sitting's table with the costs priced, and item 23 closes whichever way it goes. (a) De-quantize, two flavors: 14-bit ×128 (0.11% error; evicts the mirror's two SHOWS_LINE flags, which then need a new home — none is free in edge.width's two lanes) or 12-bit ×64 (bits 20..31; ~0.4% error; mirror flags stay put; bits 18..19 remain reserved). Branch plan: repack ARROW_SHIFT_SCALE, packArrowShapes, the shader unpacks, the mirror derivation; the routing ledger's two-sided bands fail by design and force the re-measure (the item's own note); arrow goldens regenerate — diff-read first, exact-goldens rule. (b) Hold the span for a 17th arrow shape: verified, no candidate shape is named anywhere in the ledger or the demand log — evidence the sitting weighs, not a decision. (c) Leave it: 1.8% is sub-pixel at most zooms; zero cost. Measure-first gate: before any repack, re-run routing-ledger.mjs and confirm the residuals still center where round 56 left them.

Risks named at planning

  • Goldens are exact; branch (a) moves every arrow golden with a non-representable scale — regenerate deliberately, never widen.
  • fsLabel gains a non-trivial branch: derivatives before non-uniform control flow, or the device-error guard fires.
  • Two render-bench scene additions this round (76.1, 80.3) — batch them so the renderer fingerprint moves once.
  • The stale-item lesson: 76.1's record must correct the premise (v3 has gradients) so no future round re-plans this.

Open: the ledger-23 call itself (a/b/c — and within (a), the 12-bit flavor that keeps the mirror flags vs the 14-bit flavor that moves them); whether the screen-space round is approved, its API shape (unit suffix vs sheet flag) and the bb rule (zoom-evaluated vs excluded); whether gradient stop lists should ever take the { data } passthrough (declined by default — no named consumer); text-border-style: double behavior if v3's proves degenerate.

SVG vector export

Issue #639 (2014) is the most-demanded export feature ever filed against this library, and both flagship consumers — Cytoscape Web and EnrichmentMap web — bolt on third-party cytoscape-svg + cytoscape-pdf-export for publication figures. The design sitting's thesis was that v4 makes this a serializer, not a second renderer, because the geometry is CPU-side. Planning verified that claim member by member, and it holds with one exception worth naming. What the code does today:

  1. Image export is png()/jpg() only, promise-only, and renderer-boundcore.mts:2053/2064 route through _exportImage (core.mts:2070), which throws on headless instances (core.mts:2087-2091), calls renderer.exportImage() (render/renderer.mts:657, offscreen render + GPU readback), and encodes through a DOM canvas (core.mts:2094-2102). The option surface is ExportOptions (public-types.mts:338-358): bg, full, scale or maxWidth/maxHeight, quality, output. The view math — full-graph bounds vs viewport, the scale fit — is computeExportView (renderer.mts:833).
  2. Edge routing is CPU-canonical. store/curve-index.mts derives every family's route (v3's derivation verbatim, per its header), and the public accessors read it with no renderer: controlPoints() (collection.mts:3347), segmentPoints() (collection.mts:3400), endpoints including the arrow points asx/asy recorded spacing behind each boundary (collection.mts:3442-3482, curve-geometry.mts:202, :811), and midpoint + tangent (curve-geometry.mts:1899). This is exactly the surface routing.spec.js compares numerically with no WebGPU adapter and no frame drawn.
  3. Arrow polygons are CPU tables; arrow rotation is the one GPU-only derivation. shape-points.mts:185 holds ARROW_POINTS in v3's arrow frame (tip at (0,0), lateral ±0.15), plus the compound tables and the computed ARROW_MAX_BACK/ARROW_AXIAL_DEPTH. The head's position+rotation quad is built in the arrow vertex shader; the CPU carries twins of boundaryOffset/arrowGap/arrowSpacing (curve-geometry.mts:66, :117-134) but no per-end angle accessor exists today (to-verify at build time: none found by grep; the serializer derives angles from the stored routes' end tangents, which is new CPU code with a WGSL twin to agree with).
  4. Label shaping is a pure CPU module with injected advances (label-wrap.mts:1-28): the renderer's LabelLayer runs it with real atlas advances (exact dims fed back to the store, label-layer.mts:169), the headless estimator with flat per-character advances (graph-store.mts:4029, a recorded approximation). Labels are model-space only, which is what makes export WYSIWYG (src/README.md, "Labels are model-space only"). The font is one global family/style/weight feeding one atlas (style.mts:493 default sans-serif; graph-store.mts:280) — SVG <text> can carry that exact CSS font string, and there is no per-element font to represent.
  5. Everything else the SVG needs has CPU style records: gradients (background-fill linear/radial with ≤5 stops, style.mts:268-273<linearGradient>/<radialGradient> defs), line/border dashes already normalized to two on/off pairs (style.mts:359-365, :250-257stroke-dasharray), pie/stripe chart records (style.mts:1685-1690), node shape polygon tables (shape-points.mts, the same tables the SDF and the exact CPU pick consume), corner radii, hollow/filled arrow fills (style.mts:378-381). Paint order is explicit in drawScene (renderer.mts:1710): parent bodies under everything, then parent decorations, edges, arrows, node underlays/bodies/images/charts/overlays, node labels, edge labels — SVG is painter's order, so the serializer emits in that sequence and the round-14 z-decisions carry over for free.

One trap verified so it is designed around: edge opacity is folded into the stored arrow alpha at style-write time (arrow-pipeline.mts header comment) — the serializer reads style records and store sidecars, never the GPU-mirrored columns, or it inherits render-encoding artifacts.

77.1 — the serializer core: nodes, paint order, viewport

New src/svg-export.mts: a pure string builder (no DOM — the round-78 headless path depends on this) over the store + style engine. Scope: the export view math extracted from computeExportView into a shared renderer-free helper (full = store.boundingBox(), viewport = container or headless dims; scale/maxWidth/maxHeight set the SVG width/height while the viewBox carries model coords — no texture cap applies, record that as the deviation it is), bg, node bodies from the shape tables (polygons as <path>, ellipse/rectangle/round-rect analytic), borders with style/dash, gradients as defs, opacity, visibility/display gating as the cull does. Files: src/svg-export.mts, src/core.mts (cy.svg()), src/public-types.mts (SvgExportOptions extends the shared subset of ExportOptions, minus quality, plus output: 'string' | 'base64uri' | 'blob'). Returns a promise for consistency with png()/jpg() even though the common path is synchronous — image embedding (77.4) is genuinely async, and one signature is kinder than two. New throws (empty-graph full, zero-sized viewport, invalid output) each with a spec — the throw gate stays at zero. Verification: Node specs asserting structure on the string (element counts, path data numerically against position()/size getters), each shown able to fail by skewing the serializer's transform.

77.2 — edges and arrows: the routes serialized

Scope: every curve family from the accessors' own data — straight (chord between the round-55 boundary endpoints), bundled bezier (quadratic Q through the control point), unbundled bezier, segments (L runs), round-segments/round-taxi (arc corners — see the gate below), taxi, haystack, loops — plus stroke-dasharray from the normalized pairs and the arrow heads: ARROW_POINTS polygons placed at the CPU arrow points with angles derived from the route end tangents (the one new geometry derivation, kept in curve-geometry.mts beside its WGSL twins so the agreement is by-construction like gap/spacing), hollow heads as stroked paths, mid-arrows at the midpoint+tangent. Measure-first gate: round-segments/round-taxi corners and loop curves can be emitted as true arcs/beziers or as sampled polylines at the shader's CURVE_SEGS; build the analytic form only if a sampled control scene shows visible divergence at close-up zoom — otherwise sampling wins on simplicity and matches the GPU by construction. Verification: Node specs diffing emitted path anchor points against controlPoints()/segmentPoints()/endpoint accessors numerically (the routing.spec.js discipline, in-process); control: skew the tangent derivation and watch the arrow-angle spec fail.

77.3 — labels: real text, pinned breaks

The fidelity call, stated as contract: SVG uses real <text>, not SDF glyph quads. Line breaking is pinned by the export — the serializer runs the same breakLines the renderer ran, with the renderer's atlas advances when a renderer exists, and emits one <text>/<tspan> per broken line with explicit x/y and text-anchor from the justification — so a viewer's font engine can never reflow the block; only intra-line glyph spacing may differ from the GPU raster. Scope: halign/valign anchoring, text-rotation incl. edge autorotate (a rotate transform at the midpoint tangent), text outline as paint-order stroke, text background rect, margins, end labels, min-zoomed-font-size/LOD evaluated at the export scale exactly as the GPU export does (src/README.md, round 9.6). The global font family/style/weight is emitted verbatim on a group. Verification: Node structural specs (line count and per-line text against estimateBlock's breaks); browser parity in 77.5.

77.4 — images, charts, compounds, the rest of the surface

Background images as <image> (async: data-URI embedding via the image registry when the raster is held, URL passthrough otherwise — the CORS/embedding split is an Open item), pie/stripe charts as arc-wedge/band paths clipped to the node shape, compound parent bodies in their draw position, ghost/underlay/ overlay quads, selection styling deliberately excluded (the canvas overlay rule: v4 exports never include the selection box — same here). A src/README.md section writes the fidelity contract as a table: representable exactly / representable with recorded deviation (text rasterizes through the viewer's font engine; overlapping translucent hollow arrows composite rather than erase — the round-56 recorded deviation carries over) / not represented (anything GPU-transient like mid-tween GPU-owned positions: the serializer reads the CPU model, so a mid-animation svg() sees the CPU-stale value — recorded, with the png() contrast stated).

77.5 — the parity suite: rasterize and compare, with controls

New playwright-tests/svg-parity.spec.js + a scene page under playwright-page/: for each scene, render live, cy.png() at scale 1, cy.svg(), inject the SVG inline into the page (an <img>-loaded SVG cannot reach the document's fonts; inline <svg> uses the vendored Open Sans the label harness already loads), screenshot the SVG element, pixelmatch the pair under a per-scene bound. Scenes are per-feature and built to expose, not conceal (the round-55/56 lessons: hollow and translucent heads, thick-line/small-head, many ends, close-up tier at zoom 3-4 where AA cannot hide geometry). Label scenes carry their own looser bound with the reason written down (Skia text vs SDF text is a real, accepted difference); geometry scenes stay tight because their ink is identical by construction. Every scene runs its control once, deliberately broken (serializer transform skewed, arrow angle negated, dash pattern dropped) and the mismatch must jump — a scene that cannot fail is deleted, per the standing rule. Measure-first gate: if the all-features composite scene cannot discriminate under any bound (AA noise dominating), it is dropped in favor of the per-feature set alone, and that finding is recorded rather than tolerated.

77.6 — close

JSDoc (@param/@returns/@throws at 100%), d.ts regenerated, src/README.md fidelity contract, MIGRATING/CHANGELOG rows (v3 had no core SVG; the cytoscape-svg extension's users are the audience), EXECUTIVE_SUMMARY.md rewritten, gates green (test:js, test:modules, test:throws at zero, Playwright incl. the new suite).

Risks named at planning

The SDF-vs-real-text divergence is the honest ceiling on pixel parity — the design accepts it as contract rather than chasing glyph-quad SVG output, and the per-line pinning bounds the damage to intra-line spacing. The arrow-angle derivation is new CPU geometry with a shader twin; it gets the gap/spacing treatment (shared constants, generated agreement) or it will drift. Output size on the 465k-edge fixture is unmeasured — measure before promising anything; a full export of ndex-x-large may be a string no viewer opens, and that is an editorial limit to document, not an engineering fix this round. The serializer reading style records means every future style prop has an SVG half to consider; the fidelity-contract table in src/README.md is where that debt becomes visible instead of silent.

Open: whether svg() throws on headless instances in this round (renderer-free by construction, but label advances degrade to the flat estimate — round 78 owns making that respectable, so the conservative call is to allow it and record the estimate deviation now); image embedding policy (data-URI embed vs href passthrough, a CORS and file-size trade the maintainer should pick); whether hollow mid arrows — already a recorded GPU deviation (open call 21) — are drawn trimmed in SVG or kept deviation-identical; PDF stays out of scope (SVG is the input every PDF toolchain wants — say so in the docs and close #639's sibling asks by pointer).

Headless Node image generation

The cytosnap replacement (#2561, #954): cytosnap spawns a whole Chrome to screenshot v3; the ask is figures out of plain Node. Round 77 makes the cheap route real — SVG is a string, and the serializer was built DOM-free on purpose. What the code does today, verified:

  1. The entire Node test tier runs headless instances — no container, no DOM, no adapter; a headless instance is ready immediately (core.mts:338) with headlessWidth/ headlessHeight standing in for the container (core.mts:284-285, public-types.mts:666-668). Routing, style, layouts (the CPU force included) and every accessor the round-77 serializer reads are exercised headless by test:js today.
  2. png()/jpg() are structurally browser-bound twice over: the readback needs a renderer (throws headless, core.mts:2087-2091) and the encode needs a DOM canvas (core.mts:2094-2102). The renderer needs navigator.gpu (gpu-context.mts:43) and a container element.
  3. Label measurement is the one degraded surface. The glyph atlas rasters through canvas 2D (glyph-atlas.mts header) and cannot exist in Node; headless label dims come from estimateBlock with flat per-character advances (graph-store.mts:4029), a recorded approximation (label-wrap.mts:9-11). So a Node svg() gets correct geometry, correct single-line label placement, and approximate wrapped-line breaks — the breaks are pinned into the output (77.3), so they are deterministic, just not identical to a browser's.
  4. Nothing else breaks. Background images never decode in Node (the registry's decode path is browser-only) — the SVG href-passthrough form still serializes; gradients, dashes, charts, arrows, compounds are all CPU records.

78.1 — svg() headless, made contractual

Scope: whatever 77 left renderer-coupled is severed (the export view helper reads headless dims; no document anywhere on the path — a Node spec imports the serializer and proves it); the label-estimate deviation is promoted from accident to documented contract, with a spec pinning that a wrapped label's Node output carries the estimator's breaks exactly. Files: src/svg-export.mts, test/svg-export-headless.mjs. Verification: a cross-environment structural spec — the same fixture serialized in Node (test/) and in the browser (Playwright, reusing 77's page) must agree byte-for-byte outside the label blocks, and the label blocks must differ only in line breaking (control: perturb the Node serializer's viewport math and watch the structural half fail). Measure-first gate: measure the Node-vs-browser wrapped-label divergence on the label-heavy debug fixtures first; if flat advances break lines grossly (not just slightly), 78.2's metrics option is justified — if the divergence is a few px of ragged edge, ship the estimate and record the measurement instead of building machinery.

78.2 — optional real metrics in Node (gated on 78.1's measure)

Only if the gate fires: svg({ advanceOf }) — an injected per-character advance function, the exact shape breakLines already takes (label-wrap.mts:82), so the library adds an option, not a dependency. The docs show wiring it to fontkit or a measured-once advance table; the library itself takes no font dependency and no new tool. A spec injects a fake metric and asserts the breaks move (its control: the same spec with the option ignored must fail).

78.3 — PNG in Node: the resvg question, structured for the maintainer

The route: rasterize 77's SVG with @resvg/resvg-js (Rust, no Chrome, font files loadable). Deliberately not a dependency of cytoscape in any form this round proposes by default — the deliverable is (a) a documented recipe (MIGRATING/README: headless instance → layout → svg() → resvg → PNG file, fonts supplied by the caller), and (b) a decision memo for the maintainer with the options priced: recipe-only / optionalDependencies / a small companion package (the cyext-round precedent of a nested package keeping the root install clean — ci-node must stay dependency-free either way, per the standing invariant). If the maintainer adopts a dependency form, a test/modules/ spec rasterizes one scene and structurally checks dimensions — kept out of ci-node's required path only if install cost demands it, and then loudly (the parity-suite rule: fail with instructions, never soft-skip).

78.4 — Dawn/WebGPU-in-Node: an investigation pass, go/no-go

True PNG parity (the real renderer, no browser) means WebGPU bindings in Node — Dawn builds (webgpu/node-webgpu packages) or Deno's WGPU. This is explicitly an investigation with a written record, the WebGL-scoping-round shape, not assumed work: stand up the renderer against a Node WebGPU device in a scratch tree and answer: does device/pipeline creation succeed on our shader set; does the readback path work; what replaces the container/canvas surface (the renderer draws to a texture for export anyway — the export path may need no surface, which is the interesting finding to chase); what are the platform-prebuild and maintenance costs. Go criteria written before the investigation starts: all pipelines compile, export readback bit-stable vs browser SwiftShader within the visual project's experience, prebuilds for the three CI platforms. No-go leaves the SVG route as the answer and the record as the reason — "blocked, no adapter here" has been wrongly concluded twice in this repo, so the record must show the actual probe output.

78.5 — close

Docs: a "headless figures" page section in src/README.md + MIGRATING (the cytosnap audience), CHANGELOG row, EXECUTIVE_SUMMARY.md rewrite, gates green. The cytosnap repository itself gets an issue comment/pointer only on the maintainer's say-so.

Risks named at planning

The flat-advance estimate is the load-bearing risk: if real-world labels break badly in Node, the "cheap route" ships figures people won't publish — which is why 78.1 measures before 78.2 builds, on the repo's own fixtures rather than toys. The resvg question is a dependency-policy question wearing a feature's clothes; structuring it as a memo keeps this round from making a packaging decision that is round 50's to own. The Dawn investigation can eat unbounded time; the pre-written go/no-go criteria and the scratch-tree constraint (no src/ changes from an investigation) bound it.

Open: the resvg adoption form (recipe / optionalDependency / companion package); whether cy.png() should ever exist headless (only meaningful if 78.4 goes — otherwise the honest answer is "no, use svg()", documented); the Dawn go/no-go criteria sign-off before 78.4 runs; whether the metrics option (78.2) is wanted even if the measurement says the estimate suffices.

Official JSON schemas

Issue #3487, the maintainer's own: JSON Schema documents for the formats consumers hand this library. v4 is unusually well-placed for this because of a round-8 decision the schemas inherit: no style functions — everything in a stylesheet is serializable, so the entire input surface is JSON-representable by construction. What the code does today, verified:

  1. The formats and where their truth lives. Element definitions: ElementDefinition/ElementsDefinition (public-types.mts:9-44; group inference and the accepted shapes in element-defs.mts:10-64). The columnar bulk form: ColumnarElements with PackedIds/DictColumn (public-types.mts:46-134). The object stylesheet: Stylesheet (public-types.mts:295) — but StyleProps is Record<string, StylePropValue> (public-types.mts:284), deliberately stringly, so the real acceptance surface lives in style.mts: the per-group property lists (style.mts:805+, :890+) and the per-property validators that throw on unknown names and bad values. Layout options: the per-layout interfaces (public-types.mts:370-525) with the name set enumerated — and enforced by a throw — in core.mts:703-737. The envelope: CytoscapeOptions (public-types.mts:610). The wire format is binary (wire.mts header) — no JSON Schema applies; its JSON-side equivalent is the columnar form.
  2. Generated-from-types is the wrong pipeline here. The docs generator reads JSDoc as text and is gated against the shipped declaration (scripts/docs-generate.mjs:1-40, test/docs-generate.mjs); the d.ts chain is rolldown.dts.config.mjsscripts/build-dts.mjs. Nothing in that chain can emit JSON Schema, a generator would be a new build tool (code standard 7), and — decisive — the types are vacuous exactly where a schema is valuable: a type-derived stylesheet schema says "strings map to strings-or-numbers" and nothing about which properties exist or which values they take. Decision: hand-written schemas, spec-gated against the running library — the test/modules/migration-guide.mjs precedent, whose whole design is "a prose claim about runtime behaviour verifies itself by probing the library" (migration-guide.mjs:9-25, the nameKnown probe at :53-70).
  3. The fixture supply exists. debug/fixtures.js produces every harness network's element defs through one exported toGpuElements (already exercised by test/modules/debug-harness.mjs), and debug/styles.js holds fourteen real hand-authored v4 sheets, including the ported enrichmentmap.org style — the exact documents the schemas must accept.
  4. No JSON Schema validator exists in the tree — devDeps (package.json:132-157) carry none, so the gate needs one (test-only; ships nothing).

79.1 — the harness: validator, gate shape, element schemas

schemas/ at the repo root: element.schema.json, elements.schema.json (the three accepted shapes: single, array, {nodes, edges} — mirroring partitionDefs), and columnar-elements.schema.json; draft 2020-12, $ids under a base URL reserved as an Open item. A devDep validator (ajv proposed; sign-off under Open — precedent: pixelmatch/pngjs are test-only devDeps) and test/modules/schemas.mjs: every debug/fixtures.js network's defs and a swept set of test/ fixture defs must validate. Controls, run once deliberately: corrupt one fixture def (edge without target) — red; delete a schema property — the acceptance half red. The gate direction is stated in the spec's header: the schema must accept everything the library accepts and reject what it rejects where the library is strict (inferGroup's group throw, element-defs.mts:12-16, is pinned both sides).

79.2 — the stylesheet schema, gated bidirectionally

The centerpiece and the drift risk. stylesheet.schema.json enumerates the per-group property names and the value shapes the compiler accepts — constants, and the mapper DSL (Mapper/Condition/CaseMapper, public-types.mts:157-268) as schema objects, which the no-style-functions rule makes possible at all. The value spaces JSON Schema cannot fully carry (CSS color strings, the percent forms) validate as type + pattern where cheap, with the spec — not the schema — remaining the authority on deep value validity; that division is written into the schema's own description. The drift gate, both directions: (a) every property name the schema enumerates, per group, must compile in a live sheet (the nameKnown probe); (b) every property the live compiler accepts must appear in the schema — enumerated by importing style.mts's own property lists from test/modules/ (internal imports are the tier's precedent), so a future round adding a style prop without touching the schema fails this spec, which is the "fails when the schema drifts from the running library" requirement made mechanical. Controls: add a fake property to the schema — (a) red; remove a real one — (b) red. The fourteen debug/styles.js sheets all validate; the migration guide's rejected-property table cross-checks as non-members.

79.3 — layout options and the envelope

layout-options.schema.json: a oneOf over the seven built-in names (the enum pinned against core.mts:703-737 — the spec probes that every schema name constructs and every non-schema name throws, reusing the existing throw's spec fixture), each branch carrying that layout's options from the interfaces plus the shared LayoutBaseOptions. Policy decision, taken here and recorded: additionalProperties stays permissive for layout options, because the runtime ignores unknown keys (to-verify in 79.3's first commit by probing; if the runtime is strict anywhere, the schema tightens to match — the library is always the authority). The impl extension form validates as an escape branch (object with impl) rather than being schematized. cytoscape-options.schema.json composes the pieces (elements | columnar | wire-ArrayBuffer noted as out of JSON scope, style, layout, the renderer/headless knobs from public-types.mts:610+).

79.4 — shipping, and the close

Where schemas live for a consumer: the npm tarball (a schemas/ entry in filestest/modules/packaging.mjs's allowlist gate is run before the manifest edit so the failure is the control, the round-71 discipline) and the status site (a schemas page in scripts/status-build.mjs, rendering each document plus its validation-run summary — the one place a human sees schema and fixtures together). Standing close: JSDoc untouched surfaces stay at 100% (no public runtime API is added by this round unless the Open item below lands one), src/README.md gains the schema contract section (what validates, what stays the library's job), MIGRATING/CHANGELOG rows, EXECUTIVE_SUMMARY.md rewritten, gates green including the new spec file under test:modules.

Risks named at planning

The stylesheet schema creates a third place a style property lives (compiler, docs, schema) — the bidirectional 79.2 gate is the entire defense, and it must import the compiler's own tables rather than a hand-copied list or it is two documents drifting in sync. Value-space fidelity is a permanent partial: a schema pass does not mean a sheet compiles, and saying otherwise in the docs would be a defect — the contract prose owns that line. The validator devDep must stay out of the shipped dependency graph and out of src/ entirely. Schema versioning is deliberately naive this round (the schemas describe 4.0.0-unstable's surface; they version with the package) — inventing a schema evolution policy before 4.0.0 ships would be design ahead of need.

Open: the $id base URL (js.cytoscape.org/schemas/… needs the maintainer's say on the domain and round 46's site shape); the validator choice (ajv vs a lighter draft-2020-12 validator — a devDep either way, maintainer sign-off); whether a runtime cytoscape.validate( doc, kind ) helper should ever exist (out of scope here — it would drag a validator into the bundle; noted because #3487's consumers may ask); whether the schemas are submitted to SchemaStore once stable; whether the columnar form's schema ships in v1 of this or follows once the wire docs settle (79.1 drafts it; the maintainer can hold it back at review).

Node charts: heat, bars, and the slice ceiling

Round 23's third-sitting call — "definitely yes, and consider other charts in future" — comes due. The consumers are named: EnrichmentMap's core visuals are per-node charts (its default multi-dataset chart is a radial heat map — equal sectors colored by NES through a diverging scale — plus linear heat strips and data-set pies; debug/styles.js:76 already notes RdBu as its regulation palette); Cytoscape Web caps pies at 16 slices and misrenders desktop's 26-slice pies (their issue #589); desktop's enhancedGraphics draws pie/ring/bar/heat-strip/line. What the code does today, verified:

  1. The chart family is 8 props (src/style.mts:797-804), with chart-values taking the { data } per-element passthrough and chart-colors constants-only (list or named scheme). Readback decodes the record, not the computed (style.mts:5227-5252 read through chartAt), which constrains the design below.
  2. The slice ceiling is policy, not packing. CHART_MAX_SLICES = 16 (src/contract.mts:277) is enforced in exactly one truncation loop (style.mts:9365); the packed ref is offset | n << 24 (graph-store.mts:853) — 24-bit offset, 8-bit count, so the packing carries 255 slices and 16-slice v3 parity was the only reason for 16. The FS walks the stops O(n) per fragment (shaders.mts:4900). Raising the cap is a constant plus specs, not a contract change.
  3. Donuts already existchart: pie + chart-hole (round 23 call 1). "Ring/donut" is not a new kind.
  4. The value loop is pie/stripes-shaped: negatives skip as sidecar junk and the running total clamps at 1 (style.mts:9360-9382). NES values are signed — heat and bar kinds cannot reuse it unchanged.
  5. Colors resolve at style-write (round 23 call 2), and the mapper DSL already compiles diverging scales and named schemes (src/style-scales.mts:214,444-455) — the value→color half of a heat chart exists, uncabled.
  6. The chart pass binds one uniform + 8 storage buffers, seven FS-visible (chart-pipeline.mts:70-88); new kinds need no new bindings — everything rides the record blob.
  7. Bench: benchmark/store.mjs:249 prices chart record writes; no renderer scene draws a chart.

Kinds decided at planning, each with its named consumer: heat-strip (EnrichmentMap's linear heat strip; enhancedGraphics heatstripchart), radial-heat (EnrichmentMap's default chart), bar (enhancedGraphics barchart; an up/down NES strip is a signed bar chart). Declined: ring/donut (exists — recorded), line charts (no consumer among the named apps' shipped defaults — logged, not foreclosed). The design's spine: heat kinds are record-build variants, not shader work — the record keeps the author's values (readback stays exact through chartAt) with colors resolved per value through a new chart-scale at style-write; the FS treats the new kind ids as aliases of the pie/stripes geometry, indexing region floor(t·n) directly — O(1) per fragment, cheaper than the pie walk. Only bar adds a real FS branch.

80.1 — chart-scale + the heat kinds

chart grows heat-strip | radial-heat (kind ids CHART_HEAT_STRIP/CHART_RADIAL_HEAT in contract.mts, stored in the record so readback answers the author's kind). chart-scale — a constants-only serializable object { scale, domain, range } compiled by the mapper DSL's scale compiler (diverging keeps its explicit [min, mid, max] throw) — maps each chart value to a color at style-write; heat kinds throw when it is absent (fail-loudly; a heat chart without a scale has no meaning) and chart-direction applies to heat-strip. writeChart branches by kind: the heat path takes signed values verbatim (no fraction clamp, no negative skip) and writes scale-resolved colors; chart-colors on a heat kind is a sheet error. The FS adds two alias compares (heat-strip → the stripes branch with equal bands, radial-heat → the pie branch with equal sectors, both indexing by region rather than walking stops). Tests-first Node specs in test/charts.mjs: parse/throws/readback, signed values, scale resolution, refresh on data writes of the values key — and the round-23.2 trap re-checked: the new scalar props join the mapper-capable set, and the chart-refresh fast path still re-routes through the full mapped write when defs carry mappers.

80.2 — the bar kind

CHART_BAR: n equal columns across the chart-sized sub-box (vertical bars default; chart-direction: horizontal flips the axis), heights normalized by chart-domain ([min, max] pair, constants-only, default [0, 1]; a signed domain places the baseline at 0's position). The header grows CHART_HEADER 7 → 9 (domainMin, domainMax — a blob-record format change, invisible to the mirror, which copies the blob wholesale; the contract comment is updated first, per the contract-first rule). Colors: chart-scale when set, else the palette cycle (v3-free design, recorded). FS branch: column index, coverage against the signed height with px-space AA at column boundaries and the bar top — every derivative hoisted above the branch (the uniformity rule this shader already follows). Golden: signed bars about a mid baseline on a bordered ellipse (the clip path is what the scene must expose).

80.3 — the slice ceiling, measured then raised

Measure-first gate: the FS stop walk is O(n) per fragment — before choosing the cap, price it with a render-bench pair scene (25k charted nodes, 16 vs 64 slices; the only difference is the loop count, so the pair discriminates by construction; batched with 76.1's scene edit). Then raise CHART_MAX_SLICES (proposal: 64 — covers desktop's 26-slice pies with headroom; 255 is the packing bound) and decide the overflow policy: today longer lists truncate silently (the recorded cap), and Web's #589 is precisely a silent-misrender complaint — proposal: warn-once

  • truncate (a throw on a 65-entry data-driven array would take down a frame on one element's sidecar). Golden charts-many-slices: a 26-slice pie (the #589 case) beside a cap-bound case; control: the cap dropped back to 16 must fail it. benchmark/store.mjs's chart sweep gains rows per kind × slice count, each asserting the kind and n it claims to price (the row-asserts-its-property rule).

80.4 — verification + close

Goldens per kind with feature-off controls, plus one magnified chart golden (slice-boundary AA is a boundary effect — the close-up lesson applies to goldens too). Parity: the existing pie/stripes scenes re-run untouched; the new kinds have no v3 counterpart (v3's numbered props stop at pie/stripes-16), so goldens + the magnified scene stand in — the record says so explicitly. debug/styles.js gains an EnrichmentMap radial-heat

  • NES-bar sheet on the em-web fixture and the page gets opened (something has to). Standing close: JSDoc/@throws/@param gates at 100%, test:throws at zero (the new heat/domain throws each get their deterministic spec), d.ts regenerated, src/README.md chart section rewritten, MIGRATING/CHANGELOG rows, EXECUTIVE_SUMMARY.md rewritten from this file.

Risks named at planning

  • WGSL uniformity in the bar branch — derivatives before non-uniform flow; the device-error guard catches it, but catch it in review first.
  • The writeChart kind branch sits beside round 23.2's chart-refresh trap; its two shipped fixes are the regression surface — spec both paths per new kind.
  • Goldens are exact: the cap raise must not move charts-pie-stripes (nothing in it exceeds 16 slices — assert by re-running, not by assumption).
  • A 64-slice record is 9 + 192 floats; blob compaction pressure is priced by the store sweep, not guessed.
  • The stranded-doc-block hazard (seventeen instances by round 36) — run the JSDoc gate per commit; contract.mts comment edits are its favorite terrain.

Open: the cap value (64 proposed; 32 if the pair scene says the walk costs; 255 is the bound); overflow warn-once vs throw; the chart-scale shape (one object vs three flat props); whether heat kinds throwing on a missing scale is right, or a default scheme + domain-from-extent is wanted (the fail-loudly reading says throw); and the declined line kind staying declined.

The annotations layer

v4 contains no annotation code of any kind (grep -ri annotation src/ answers zero lines), and the ecosystem demand is the tenth sitting's shortlist entry with "the longest paper trail": Cytoscape Web renders desktop annotations today beside cytoscape.js, through a layered-canvas library (cytoscape/cyannotation-cx2js — its own description: "Renders Cytoscape Desktop annotations from CX files via cytoscape.js-canvas"; Web's PRs #400 "Render annotations for cy.js networks", #426, and #526 "Support linear gradient fill for annotations" — titles verified 2026-08-14). Read-only, off to the side, and reimplemented per app is exactly the shape of a missing core feature.

The wire dialect, verified from that converter's source (src/cx_to_cy_canvas.js) rather than from its README (which documents nothing): annotations live in the CX networkAttributes aspect as an element named __Annotations whose value is a list of strings, each a |-joined list of key=value pairs (getAnnotationElementsFromNiceCX). Common keys: uuid, type (Java FQNs — ShapeAnnotation, TextAnnotation, BoundedTextAnnotation, ArrowAnnotation, ImageAnnotation, GroupAnnotation), canvas (foreground | background — desktop has exactly two annotation canvases), integer z (the converter sorts each canvas descending and draws in that order, so lower z paints later and sits on top), x/y/width/height, and zoom (a recorded scale the converter divides sizes and font sizes by). Shapes carry shapeType (RECTANGLE, ROUNDEDRECTANGLE, ELLIPSE, TRIANGLE, PENTAGON, HEXAGON, OCTAGON, STAR5, STAR6, PARALLELOGRAM, CUSTOM with an M/L/Q/C path string), fillColor (int RGB) + fillOpacity (0–100), edgeColor / edgeOpacity / edgeThickness; text carries text, fontSize, fontFamily, fontStyle, color. Two honesty notes from the same read: the reference converter's ArrowAnnotation body is commented out in full ("would take a great deal of math … left for later work"), and ImageAnnotation/GroupAnnotation are not handled at all — so the "parity" baseline Web ships is shapes and text only.

What v4 offers to build on, verified: draw order is structural and z-index is dropped for good (src/README.md:1307), with the one logged escape being a single boolean elevated tier (src/README.md:1321; PLAN.md round-14 sitting: "never arbitrary integer stacking"). The store's groups are exactly two — GroupName = 'nodes' | 'edges' (src/contract.mts:12) — and that spelling is load-bearing across StoreDelta.resized (src/contract.mts:879), ModelView (src/contract.mts:990), the mirror, the cull kernels and the wire header. The contract already has a precedent for model-owned data that is not a column: the label sidecar (LabelEntry, src/contract.mts:911; the store's per-stream arrays at src/store/graph-store.mts:268) and the four optional blob-dirt channels on the delta (src/contract.mts:889). The frame is a render-on-dirty loop whose scene pass runs prepass → parents → edges/arrows → ghosts → node bodies → image/chart/overlay → labels (src/render/renderer.mts:44). Node picking is a synchronous CPU scan (src/render/cpu-pick.mts:35), and pointerdown already decides pan-vs-grab from it (src/interact/pointer.mts:22). Images have a refcounted pool with warn-once decode failure (src/image-registry.mts:17). Labels are SDF from one atlas keyed by character, so font-family is a global constant (src/README.md:1926). Removed elements are terminally dead (src/README.md:1998). Extension points stay demand-gated deferred (src/README.md:4996) — this round builds a core feature, not a hook.

Design calls

  1. Representation: a store-owned sidecar table, not a third element group. Annotations are authored singletons in the tens, not data populations in the hundreds of thousands: they take no part in selectors, mappers, layouts, algorithms, adjacency, or the columnar dirty-span machinery that exists to price element churn. Promoting GroupName to a three-way union would touch every co-signed surface in src/contract.mts, the wire header, the mirror and every cull kernel, to buy reuse the feature does not want (per-population styling) or does not need (GPU picking of tens of items). Instead: an AnnotationTable owned by the store — the label-sidecar precedent — holding an ordered list per tier, a monotonically-assigned id, an alive generation (so removed handles read as dead, the terminal-death contract applied unchanged: no restore, re-adding from a kept definition is the app's job), and one coarse dirty flag on the delta (annotations?: true beside the blob channels — the whole population re-uploads when anything changes, which at tens of records is cheaper than span bookkeeping). Rejected: the third columnar group — churn across the whole contract for machinery annotations cannot exercise; also rejected: a pure renderer-side feature — the model must own annotations so headless instances can import, hold and export them with no canvas, which is the Cytoscape Web server-side story.

  2. Layering: two structural tiers, no z integer. Each annotation carries layer: 'background' | 'foreground' (default 'foreground') — precisely desktop's two canvases, and precisely v4's philosophy: the tier is the single-boolean structural placement the elevated-tier hook already sketched, arriving on annotations first. Background draws after the depth prepass and before parent bodies (depth-tested against opaque node interiors exactly as edges are — free early-z, correct under translucent nodes); foreground draws after labels, no depth. Within a tier, draw order is list order, insertion at the end; desktop's integer z exists only at the wire boundary — import sorts each canvas by the converter's own comparator once and discards the integers, export synthesizes fresh ones from list position. Rejected: a stored per-annotation z-index — re-litigates the 2026-08-01 decision for no consumer; the two-tier + list-order model reproduces every desktop file's paint order exactly.

  3. Style: direct properties on the annotation object; no stylesheet participation. An annotation is one authored thing, not a keyed member of a population — there is no data column to map from and no state vocabulary to condition on. Properties are plain serializable values (fillColor, fillOpacity, borderColor, borderWidth, shape, text, fontSize, geometry…), which is the desktop model verbatim and keeps interop 1:1; the no-style-functions rule holds trivially because there is nowhere to put one. Validation is fail-loud per the error policy: unknown kind, unknown shape keyword, non-finite geometry, and updates against a dead handle all throw, each with a spec (the throw gate is at zero and stays there). Rejected: an annotations sheet group with mappers — it would drag the compiler, the columns and the GPU mapper runtime into a feature whose population is countable on hands.

  4. Interaction: minimal v1 = hit-test + move, default inert; render-only is the fallback, maintainer chooses. Recommended scope (81.4): a CPU hit test over the lists (reverse draw order; foreground before elements, background only when no element hits — desktop's own reachability), a programmatic update() move, and pointer drag only for annotations opted in with movable: true — the default is pointer-transparent, so a graph with imported annotations behaves byte-for-byte like today's Web read-only rendering unless the app opts in. Events on the core (annotations are not event targets; no bubbling): annotationgrab / annotationdrag / annotationfree / annotationtap, payload = the handle. Resize/rotate handles and any editing chrome are permanently the app's job. The rejected-but-offered alternative: render-only v1 (drop 81.4 whole; the API and hit test become a later round) — it still covers the first consumer, and the pass is severable to the commit.

  5. Interop: the __Annotations dialect is the wire story. A pure module (no instance, no renderer) parses the |-separated key=value records into v4 annotation definitions and serializes back: annotationsFromCx() / annotationsToCx(), exported as named exports beside the default (they attach as properties of the UMD global; the packaging and d.ts-surface specs pick them up). Unrecognized keys are preserved per-annotation in a foreign bag and re-emitted on export, so a desktop file round-trips its content even where v4 renders a deviation (fonts, below) — the cheap honesty a bridge format owes. Import maps shapeType onto v4's existing SDF shape ids (RECTANGLE/ROUNDEDRECTANGLE/ELLIPSE plus the regular polygons and stars); CUSTOM paths warn once, are not drawn, and survive in foreign (the image-decode-failure precedent: degrade visibly-absent, never crash). ArrowAnnotation's endpoint attachment resolves to static coordinates at import (recorded deviation: endpoints do not track their targets — the reference converter draws nothing at all here, so this exceeds the parity baseline). zoom normalizes into model px once at the border. Annotations do not join the CYGE binary format in v1 — they are small and object-shaped, the graph-data-as-JSON reasoning (src/wire.mts:36) applied again. Rejected: inventing a v4-native annotation JSON dialect first — the desktop dialect has the installed base and the first consumer; v4's constructor option simply takes the parsed definition objects.

  6. Text: rendered in the instance's one global face — stated, not fudged. The glyph atlas is keyed by character with one font per atlas by design (src/README.md:1926); per-annotation fontFamily/fontStyle would re-key it by (font, char) and is out of scope, same as it is for labels. v1 honors text, fontSize and color; the imported family/style ride the foreign bag so export returns them untouched. This goes in "Known deviations from v3" — strictly, deviations from desktop — as its own entry, beside CUSTOM shapes and non-tracking arrow endpoints.

81.1 — the model: contract types, the sidecar, the API

The Annotation definition union in src/contract.mts (kinds: shape — with optional centered text, covering desktop's BoundedText — text, line, arrow, image) plus the tier type and the delta's annotations? flag; the AnnotationTable in src/store/ (add/remove/update/list per tier, id + generation, dirty signaling through the store's existing invalidate channel); the core surface — constructor option annotations: [...], cy.addAnnotation(def) returning a handle, cy.annotations() returning the live handles, handle update(patch) / remove() / getters. Headless-complete: every spec in this pass runs in test/ under the Node tier. All new throws (invalid kind/shape/geometry, dead-handle update) get message-asserted specs; JSDoc on every public member (the gate is at 100% and the d.ts regenerates); src/README.md gains the design-decisions entry. Files: src/contract.mts, src/store/annotation-table.mts (new), src/store/graph-store.mts, src/core.mts, src/public-types.mts. Verification: test:js, test:modules, test:throws at zero, test:soak (the sidecar joins the churn/leak tier — annotations across destroy cycles must collect).

81.2 — the draw: two tiers in the frame graph

Renderer-side mirror of the annotation lists into small instance buffers (full re-upload on the dirty flag — tens of records), and three deferred-compiled pipelines (the round-53 first-frame rule: nothing compiles until the first annotation exists): shapes (one instanced quad pipeline reusing the node SDF shape functions from shaders.mts — rect/round-rect/ellipse/polygons cost no new distance code; line/arrow reuse the arrowhead SDFs), text (glyph runs laid through the existing GlyphAtlas into a fifth GlyphBuffer; anchor positions baked into the instances and rebuilt on move — annotations are few and moves are rare, so no shader change and no slot-reference scheme), and images (through the ImageRegistry pool + a variant of the image pipeline). Two draw sites: background after the prepass, before parents, depth-tested; foreground after labels. No GPU cull: a CPU frustum test per annotation per frame, drawn direct rather than indirect — at tens of instances the cull dispatch would cost more than it saves, and the honest bench row (81.6) prices the claim. All new WGSL literals carry the wgsl tag. Files: src/render/annotation-pipeline.mts (new), src/render/renderer.mts, src/render/shaders.mts, src/render/label-layer.mts. Verification: 81.3's goldens; debug/ gains an annotations toggle on one fixture and the page gets driven (AGENTS.md code standard 5).

81.3 — goldens built to expose the tiers, with failing controls

New visual scenes constructed so the ordering is the measurement, not hidden under opaque paint (the round-55/56 lesson): a translucent background rectangle straddling nodes and edges (its color must show through nothing and be shown through by nothing it sits under), a translucent foreground ellipse over an edge bundle and labels, a text annotation beside a node label at matching font size, an arrow/line pair, an image annotation over the canvas edge (crop check via expectGraphFits / useViewport). Controls, each run once and observed red: flip one scene's tier (backgroundforeground) — the diff must jump; disable the annotation draw entirely — every scene fails. Goldens are exact (zero differing pixels) and generated on Linux; the text scene inherits the label-scene platform note. There is no v3 parity diff to run — v3 has no annotations — so goldens + controls carry this feature alone, and the round record says so.

81.4 — interaction (the maintainer's scope call)

If minimal-v1 is chosen: the hit-test walk (foreground reverse list order → existing element paths → background), movable drag through the pointer state machine (a new mode beside pan/grab/box; position writes go through the store so the dirty flag schedules frames), the four core events, and cy.annotationAt(position) public. Specs: hit order pinned (foreground annotation over a node wins; inert annotation never intercepts — the control: mark it movable and the same gesture must grab it), drag writes verified headlessly through the table, and a Playwright gesture spec on the served page. Renderer/gesture changes get a debug/ drive. If render-only is chosen, this pass is dropped whole and its API surface waits.

81.5 — interop: the CX dialect, round-tripped

src/annotation-cx.mts (new): the parser/serializer pair as pure functions, the foreign round-trip, the z-sort at import, the zoom normalization, warn-once for CUSTOM/GroupAnnotation (groups flatten; members import individually), and the throws for structurally invalid records (a record with no type, an unparseable numeric) — fuzz-shaped malformations get deterministic pins in test/ since the throw gate cannot see soak-only guards (AGENTS.md). Fixtures: annotation strings excerpted from cyannotation-cx2js's own example .cx resources, vendored small under test/fixtures/, with the spec asserting per-key values after the round trip, not just that parsing succeeded (the round-46.5 dictionary-column lesson: a payload can lose a whole field and still look plausible). Named exports join src/index.mts; test:types:surface and the packaging spec are the gates that notice.

81.6 — the bench row, and the close

One renderer-profile scene pricing frame cost at N annotations over a mid-size graph, run at two N values so the row discriminates — it must move with N, and its comment says what would make it not move (opaque overdraw hiding the tier work). Standing close: src/README.md (design decision, the three recorded deviations, the follow-up hook for what v1 excludes), MIGRATING/CHANGELOG rows (new public surface; "v3 had no annotations — this is a desktop-parity addition" stated plainly), AGENTS.md untouched unless a new directory appeared (none should), d.ts regenerated, EXECUTIVE_SUMMARY.md rewritten from this file, all gates green including JSDoc 100% and throws at zero.

Risks named at planning

The largest is scope gravity: desktop annotations trail an editor UI, rotation, gradients (Web's #526), and group semantics, and every one is out of v1 — the pass list draws the line at render + dialect (+ minimal interaction), and the Open paragraph is where the rest lives rather than the diff. Second, the foreground tier draws after labels with no depth, so it is the first pipeline family outside the early-z scheme; if a scene ever puts hundreds of large translucent foreground annotations over a dense graph the overdraw is unmitigated — the 81.6 row exists to keep that priced rather than assumed. Third, 81.4 touches the pointer state machine, historically the code most likely to regress invisibly to Node suites — the debug-harness drive is mandatory there, not advisory. Fourth, the dialect is reverse-engineered from a converter, not from a spec: keys verified against cx_to_cy_canvas.js are cited above, but ImageAnnotation's key set and desktop's exact z conventions are to-verify against a real desktop export before 81.5 hardens — the fixtures pass exists to force that check. Sequencing: 81.1 → 81.2 → 81.3 strictly; 81.4 and 81.5 are independent of each other; 81.6 last.

Open: the 81.4 scope call — minimal interaction (recommended) versus render-only; whether annotations ever join cy.fit() / boundingBox() (v1 excludes them, recorded — an opt-in flag is the natural follow-up if Web asks); a reorder affordance (bringToFront/sendToBack within a tier) — v1 ships insertion order only; whether the CYGE binary format gains an optional annotations section once Web consumes the feature at scale; GroupAnnotation fidelity beyond flatten-and-preserve; and whether line/arrow annotations should later re-resolve attachment to live node positions (tracking endpoints), which would move them from sidecar-static toward store-coupled and deserves its own sitting.

Cluster hulls and collapse/aggregation proxies

The LOD section of issue #3486 names "cluster or component proxies" as a progressive-detail tool; the AutoAnnotate/EnrichmentMap idiom (shaded, labelled cluster regions) is the visual half of the same idea, and EM web today fakes it with cytoscape-bubblesets + automove over v3. This round designs both natively. What the code does today, verified:

  1. Draw order is structural and z-index is gone for good (src/README.md:1307): the scene pass runs depth prepass → compound parents → edges/arrows → ghosts → node bodies + image/chart/overlay → labels (src/render/renderer.mts:50-52). A hull tier therefore gets a position in the pass, not a z value — first in the scene pass, under the parent bodies.
  2. No classes, no selector strings (src/README.md:1303; src/matcher.mts:14). The query IR is a plain serializable object — per-group flag tests plus data-sidecar conditions in the case-mapper vocabulary (src/matcher.mts:49-78); unknown keys throw (src/matcher.mts:187-193). A hull grouping has the same two honest declaration forms available: a data key, or explicit ids.
  3. Removed elements are terminally dead (src/README.md:1998) — no restore(), no import-form cy.json(). But the display tier is exactly the non-destructive hiding collapse needs: show()/hide() is structural state (src/collection.mts: 3644-3673, GraphStore.setVisibility at src/store/graph-store.mts:1795) — a hidden element draws nothing, picks nothing, takes no space, and hidden children leave their parent's auto-bounds; a parent with no shown children keeps its position at its stashed style size — v3's degenerate fallback (src/store/hierarchy.mts:306-309, 371-377). That fallback is, verbatim, the collapsed-proxy rendering: hide the children and the parent already draws as a normal node at its style size.
  4. The compound machinery is a narrow-host sidecar (src/store/hierarchy.mts:38-44): parent links, child lists, depth, lazily-flushed derived geometry, never touching columns directly. The same pattern (and the CurveIndex's lazy flush()) is the mold for a HullIndex.
  5. Dirty tracking is one coalesced span per column per frame (src/store/dirty.mts:13-19, mark at :57), drained by the renderer's takeDelta. Hull recompute can key off the node.position span the same way — over-recompute inside the span hull is the accepted cost, per the tracker's own doc.
  6. Labels are four slot-keyed glyph streams over one SDF atlas (src/render/label-layer.mts:14-25); the owner word carries slot + a rotate flag (src/render/glyph-buffer.mts:18). A hull-label stream is a fifth GlyphBuffer keyed by hull index.
  7. The harness already has the exact fixtures this round needs (debug/networks.js:23-38): em-web ships mcode_cluster_id on 354/569 nodes, and em-web-clustered materialises those 41 MCODE clusters as real compound parents.
  8. Serialization gap, verified: element json() exports selected/selectable/locked/grabbable/pannable but not the display-tier hidden state (src/collection.mts:920-940), and the wire format's flag list has no hidden column (src/wire.mts:66-77). Collapsed state does not round-trip today; the round must decide whether it starts to.
  9. To-verify at implementation: whether built-in layouts skip display-hidden nodes (a grep over src/layout/ finds no SHOWN/hidden test, so the presumption is they do not).

Design calls.

  • Hulls are declared by data key, not by member list. cy.hulls({ groupBy: 'mcode_cluster_id', ... }) — one hull per distinct value, membership read straight from the columnar sidecar, elements with the key absent belong to no hull (the matcher's missing-value rule). This is AutoAnnotate's own model (clusters are node attributes) and it makes membership live: a data write moves a node between hulls through the existing watched-key machinery, and there is no second membership store to keep alive across add/remove. Rejected: explicit member-id lists — a parallel membership structure with its own staleness rules; an app that has lists writes them into a key. Rejected: compound-parent hulls — a hull around a compound duplicates the parent body the renderer already draws.
  • Geometry is a padded, corner-rounded convex hull, computed on the CPU per dirty hull (hull of member outer boxes, O(k log k), Minkowski-padded; corners arc-sampled), drawn on the GPU as a triangle fan (convex ⇒ fan is valid) plus a border strip. Rejected for v1: bubble-set concave outlines — a marching-squares field costs O(grid cells × members) per recompute per drag frame, which is precisely the jank EM web lives with today. Logged as the v2 candidate instead: a fragment-shader SDF blob (smooth-union of member discs + intra-cluster capsules) — GPU- native concavity with no CPU field, but per-pixel cost O(members) needs a member cap and coarse tiling, so it enters only behind a measurement.
  • Hulls draw first in the scene pass — structurally under parents, edges and nodes. No pick surface in v1 (a recorded deviation from desktop AutoAnnotate's selectable annotations).
  • Collapse is non-destructive by construction: hide() the member nodes (edges follow — an edge with a hidden endpoint leaves bounds and draw via the existing endpoint gating), keep every original element alive in the store, and let the compound parent be the proxy via the degenerate fallback (finding 3). Nothing is removed on collapse, so terminal-death never bites. Only the derived meta-edges are added on collapse and removed on expand — they are cheap to recreate, which is the one shape of element for which terminal death is free. Rejected: a shadow "removed but restorable" tier — it re-opens the 2026-07-27 decision for one feature.
  • v1 collapses compound parents only (em-web-clustered is the fixture; EM's collapse is compound-shaped; iVis's expand-collapse tracker is compound-based). Data-key clusters get the hull idiom; a helper that wraps a key's clusters in parents can come later. Meta-edges are real elements with deterministic ids (meta:<parentId>:<otherId>), one per (collapsed side, outside endpoint) pair, so layouts, algorithms, queries, picking and export all work on the collapsed graph with zero new code paths.
  • v1 aggregation: collapsedCount always; summed numeric keys on request (aggregate: { sum: ['weight'] }), the sums written under the original key names — so an existing width-by-weight mapper styles meta-edges with no sheet change (EM's summed-edge behaviour). Means are derivable; min/max wait for demand.
  • Collapsed state is session state in v1. Adding a hidden column to the wire format is a version bump (src/wire.mts:57) and json() would need the field too; before paying that, ship cy.collapsed() (the collection of collapsed parents) and document re-collapse-after-load as the app idiom — consistent with "re-adding from kept definitions is the app's job". The Open paragraph carries the round-trip question.
  • One round or two? Split — and this plan is written at the seam. The halves share only one wire: hidden members leave their hull, which the HullIndex gets free from the SHOWN mask it already tests. Hulls are a renderer feature (a pipeline, a glyph stream, goldens); collapse is a model feature (a sidecar, aggregation semantics, events) with nearly no renderer work. Sizing: hulls ≈ 1.5× a typical round (a new draw tier + labels), collapse ≈ 1×. Recommendation: land 82.1–82.3 and close; run 82.4–82.6 as the next round. If the maintainer wants one round, the passes stand as ordered.

82.1 — the HullIndex (store tier)

New src/store/hull-index.mts on the hierarchy/curve narrow-host pattern: the cy.hulls( spec | null ) declaration (get/set; serializable; unknown keys throw — the matcher precedent), the value→members index off the groupBy column, and per-hull padded convex-hull geometry flushed lazily. Dirt: membership from watched-key data writes and add/remove/show/hide; geometry from the node.position span (recompute hulls intersecting the span) and from outerHalf changes. Hidden members are excluded by the SHOWN mask; a hull with < 1 shown member emits nothing. Spec props v1: groupBy, padding, fill, fill-opacity, border-width, border-color, border-opacity, label (on/off + per-value text map, default the value itself) — constants plus a per-value override map; no mappers in v1. Verified by Node specs: hulls over a fixture with known membership (geometry containment — every shown member's outer box inside its hull, padding respected), membership moves on a data write, hide/show flips, and every new throw pinned (test:throws at zero). Files: src/store/ hull-index.mts, src/store/graph-store.mts, src/core.mts, src/public-types.mts.

82.2 — the hull pipeline (render tier)

New src/render/hull-pipeline.mts + a wgsl-tagged shader: fill fan + border strip per hull from a hull-vertex storage buffer the mirror uploads on hull dirt, drawn first in the scene pass (renderer.mts drawScene) — structurally under everything, no depth participation. Not pickable; excluded from boundingBox() and fit in v1 (hulls follow content, recorded). Verified by goldens: a clustered scene with hulls (exact, per the round-57.1e rule) plus the two controls that must fail — the hull pass dropped (diff jumps) and the draw moved after edges (nodes/edges no longer paint over the fill; per AGENTS.md, the scene is built so the hull is not hidden under opaque bodies — translucent fill, visible borders). No v3 parity scene exists (v3 has no hulls) — goldens plus the debug page are the coverage, recorded. Files: src/render/hull-pipeline.mts, src/render/renderer.mts, src/render/column-mirror.mts, src/render/shaders.mts.

82.3 — hull labels, the harness, and the price

A fifth GlyphBuffer keyed by hull index, anchored top-center of the hull, laid through the existing atlas/wrap path; far-zoom behaviour is the LOD point of hulls, so hull labels are exempt from the glyph fade floor (recorded; the per-hull count is tiny). debug/: hulls toggle on em-web via mcode_cluster_id (excluding 'None'), sanity-driven per code standard 5. Bench: a new benchmark/hulls.mjs row pricing per-frame hull recompute under a synthetic drag on em-web (41 hulls) and a generated 500-cluster scene — each row prints the hull count and total member count it recomputed (the row asserts the property it is named for), plus an at-rest row proving a clean frame recomputes zero hulls. Measure-first gate: if the drag row shows hull recompute above ~1 ms at the 500-cluster scale, geometry moves from per-frame to a per-frame budget (stale hulls lag a frame) before any GPU-side scheme is considered. Docs: JSDoc (100%, @param/@returns/@throws gated), src/README.md section, MIGRATING row (bubblesets/automove → cy.hulls), CHANGELOG, d.ts regenerated. The seam: the round can close here.

82.4 — collapse/expand (model tier)

New src/store/collapse-index.mts (narrow-host again): parents.collapse( opts ) / parents.expand() / node.collapsed() / cy.collapsed(). Collapse: assert the target is a parent (throw otherwise — fail loudly), hide() its descendants, compute boundary-crossing edges (hidden-by-endpoint, including meta-edges of already-collapsed inner parents, which makes nesting compose), add aggregated meta-edges with deterministic ids, emit collapse. Expand: remove this parent's meta-edges, show() descendants (their own collapsed sub-parents keep their descendants hidden via own-state flags), re-aggregate one level down, emit expand. v1 rule, documented loudly: the display tier of a collapsed parent's descendants belongs to collapse — an app's own hide() under a collapsed parent is overridden on expand (recorded deviation; revisit on demand). Verified by Node specs: collapse/expand round-trip restores the exact shown set and bounds; crossing-edge aggregation counts; collapsed↔collapsed pair yields one meta-edge; nested collapse/expand in both orders; every throw pinned; and a test:soak churn spec (repeated collapse/expand cycles — the meta-edge add/remove churn is profile 2 of the compaction analysis, and the id blob/CSR meters must hold it).

82.5 — aggregation + the API surface priced

aggregate: { sum: [...] } semantics (absent values contribute nothing; non-numeric values under a summed key throw — loudly, not NaN), collapsedCount always written; directed pairs fold to one meta-edge per unordered pair in v1 (Open below). Bench: a benchmark/collapse.mjs row collapsing all 41 em-web-clustered parents and expanding them, printing the meta-edge count it created (the discriminating property); a row against v3 + expand-collapse extension is impossible in the Node tier (the extension is browser-shaped) — the row prices v4 against its own budget instead, recorded. Goldens: collapsed em-web-clustered scene + the control (expand ⇒ diff jumps); a hulls+collapse scene proving a fully-hidden cluster's hull vanishes — the one coupling wire, asserted where it lives.

82.6 — the close

Standing close: this record, src/README.md (Design decisions + deviations: hull tier, collapse-owns-display rule, session-state serialization), MIGRATING/CHANGELOG rows, EXECUTIVE_SUMMARY.md rewritten, d.ts regenerated, gates green (test:js, test:modules, test:soak, test:throws at zero, JSDoc 100%, Playwright visual + renderer projects), bench runs published serial --repeat 3 with all bench-file edits batched so each profile's fingerprint moves once.

Risks named at planning

  • The degenerate-fallback proxy depends on the parent's stashed style size and position surviving an all-hidden flip (hierarchy.mts:371-377 reads current position) — a parent that was never positioned independently needs a defined collapsed position (children centroid at collapse time, written once). Pin it with a spec before building on it.
  • Layouts likely move hidden nodes (finding 9) — a layout after collapse then expand may scatter revealed children. Verify early; if true, the v1 answer is documentation (run layouts on shown subsets), not a layout change.
  • The hull pipeline is a new always-first draw; a zero-hull graph must pay nothing (the ghost-pipeline precedent: skip outright at count 0) — assert it in the at-rest bench row.
  • Stranded-doc-block hazard on the two new sidecars — run the JSDoc gate per commit.

Open: (1) Split confirmation: close after 82.3 and run 82.4–82.6 as the next round, or one long round? (2) Should collapsed/hidden state join the wire format (version 5) and json() so collapse survives a round-trip, or stay session state? (3) Hull pick surface: is click-a-hull (select members? an event only?) wanted enough to justify a pick tier, and if so in which round? (4) Directed meta-edges: fold to one per unordered pair (v1) or keep per-direction aggregates? (5) Hull label text source: is the per-value map enough, or is a second data key (labelBy) wanted in v1? (6) The SDF-blob concave hull: log as an extension candidate (cyext, round 71) or keep as core v2?

GPU edge bundling

Issue #2332 asked for edge bundling in 2019 and was closed unfixed; desktop has it; it is the headline large-graph visual the v4 architecture exists to make cheap (#3486's rendering thesis). What the code does today, verified:

  1. Curved edges are one instance per edge over a fixed strip: CURVE_SEGS = 24 quads (src/curve-geometry.mts:55), vertex shader evaluates the curve from live positions + per-edge params (src/render/curved-edge-pipeline.mts:10-19), inside the 8-storage-buffer budget.
  2. Variable-length curve records live in the CurveBlob (src/store/curve-blob.mts:3-30): the f32×4 edge.curveParams column holds [blobOffset, dev, n, kind] headers into a compacting pool; records are position-independent — offsets/weights in the endpoint frame — so drags/layouts follow on-GPU with zero blob traffic (curve-blob.mts:16-19). Segment records cap at MAX_CURVE_PTS = 11 interior points (src/curve-geometry.mts:728).
  3. The CurveIndex owns styled records and derives params lazily (src/store/curve-index.mts:29-66); the 12b families never bundle pairs and write per-edge blob records.
  4. Arrows and picking already ride the curved stream: heads point along the curve's end tangent (src/render/curved-arrow-pipeline.mts:10-16); edges pick on the GPU via the same strip in the cursor-tile pass (src/render/renderer.mts:1202, 1339-1363).
  5. The async algorithm tier has the executor contract this operation should reuse (src/algorithms/executor.mts:1-25): Promise-returning, executor: 'cpu' | 'gpu' | 'auto', CPU as the bit-reproducible spec, GPU_MIN_N = 256 as the auto floor (:44), runAlgo routing (:90), kernels on the compute-only acquireAlgoGpu context — no canvas, headless parity suite in the Playwright algorithms-gpu project.
  6. LOD interplay exists today: far-zoom edge decimation draws a hash-stable 1-in-N subset (PLAN.md:1679) — bundled edges decimate like any others.
  7. Per-element bypasses are id-keyed sheet declarations (src/README.md:718-731), constants-only, O(bypassed) apply — designed for few elements, relevant below as the rejected output form.

Design calls.

  • Algorithm: density-based bundling (KDEEB/CUBu family), not FDEB. FDEB's compatibility measure is pairwise — O(E²) pairs times P points per iteration; at 100k edges that is 5×10⁹ pair terms per iteration before any force is summed, and no executor makes that a headline. The density approach is O(E·P) splat + O(cells) blur + O(E·P) gradient advection per iteration — linear in edges, GPU-native, and the published CUBu line bundles ~10⁶ edges in real time on commodity GPUs. The measured-crossover story is therefore not FDEB-vs-KDEEB (FDEB loses asymptotically before any measurement); it is CPU-KDEEB vs GPU-KDEEB, same algorithm on both executors — the round-65 shape, with the crossover constant measured on the benchmark machine and machine-stamped. Rejected: shipping FDEB as the CPU path "because it is the classic" — two algorithms means the parity suite compares nothing.
  • API: an explicit async operation, not a style value. cy.bundleEdges( options ) → Promise<BundleResult>, options { executor, iterations, cellPx, smoothing, edges? } (unknown keys throw). A curve-style: 'bundled' prop is rejected on the style system's own contract: every styled record is re-derivable from (style, endpoints) alone and stays fresh automatically (src/README.md:1340-1342), while a bundled shape depends on every other edge's positions and is expensive and iterative — a style value would either lie about freshness or re-run a global operation on every drag. The algorithm tier's explicit-async precedent (finding 5) fits exactly: expensive, global, environment-dependent, resolves with stats ({ iterations, edgesBundled, msTotal }edgesBundled is what the bench rows print).
  • Output lives in the curve tier as a new operation-owned record kind, CURVE_BUNDLED: per-edge polylines of up to CURVE_SEGS − 1 = 23 interior points, stored in the CurveBlob in the endpoint frame — so the existing position-independence contract holds and a post-bundle drag deforms the bundle plausibly on-GPU with zero blob traffic; re-run to re-tighten (documented). The record is flagged operation-owned: a style write to that edge's curve family drops the override (style wins; recorded), cy.unbundleEdges() clears all. segmentPoints() answers with the bundled polyline, which gives the numeric no-pixels verification tier (the routing.spec.js precedent). Rejected: emitting per-edge segment-distances bypasses — canonical and serializable, but id-keyed O(bundled) sheet entries are the wrong shape at 10⁵ edges, a bypass beats the sheet forever (muddying app bypasses), and segments cap at 11 interior points where the strip affords
    1. Serialization instead: BundleResult carries a columnar payload (edge ids + counts + offsets) that cy.bundleEdges({ preset: result }) re-applies — the preset-layout precedent, and consistent with "kept definitions are the app's job".
  • Kernels are compute-only on the AlgoGpu context: splat polyline segments into a u32 fixed-point density grid with atomics (no float-blend feature dependency, no render pass), separable box/Gaussian blur, per-point gradient advection with endpoint pinning, Laplacian smoothing, resample — all wgsl-tagged. Positions upload O(N) per run; no entanglement with the frame loop, and headless-with-adapter environments run it exactly like the round-65 tier.

83.1 — the CPU reference + the operation surface

src/algorithms/edge-bundling.mts: the f64 reference KDEEB loop (grid splat, blur, advect, smooth, resample to ≤ 23 interior points), cy.bundleEdges entry through resolveExecutor/runAlgo, result/preset shapes, throws (unknown option keys, bad iteration/cell values, edges collection from another instance — the round-48.4 guard) each with specs. Verified by Node specs asserting the property bundling is named for: on a two-cluster parallel-edge fixture, mean pairwise midpoint distance among compatible edges decreases by a pinned factor, endpoints stay bit-identical (pinned), and a control run with advection zeroed fails the convergence assertion. Files: src/algorithms/edge-bundling.mts, src/core.mts, src/public-types.mts.

83.2 — the curve tier learns CURVE_BUNDLED

Store: the record kind in curve-index.mts/curve-blob.mts (operation-owned flag, style-write drop rule, compaction relocation), the bb term in the store scan (conservative polyline hull — the round-12a pattern), segmentPoints(). Render: the kind dispatch in CURVED_EDGE_SHADER (walk the 23-point polyline across the 24-quad strip), the arrow end-tangent case, FLAG_CURVED set so cull/pick/arrows ride the existing curved stream unchanged. Verified by goldens: a bundled scene (exact) with the control run unbundled — the diff must jump — and a close-up golden at zoom 3-4 per the round-56 rule (bundle geometry, not AA, must own the mismatch); a pick spec asserting a click on the bundled arc hits the edge and a click on the old straight chord misses (run once with bundling disabled to prove it discriminates); the deformation contract pinned numerically (move an endpoint, segmentPoints() follows in the endpoint frame with no re-run). Files: src/store/curve-index.mts, src/store/curve-blob.mts, src/render/shaders.mts, src/render/curved-edge-pipeline.mts, src/render/curved-arrow-pipeline.mts.

83.3 — the GPU kernels + parity

src/algorithms/algo-gpu-bundling.mts: the four kernels above, GpuUnfitError on grids/buffers past device limits, accumulation order pinned where atomics make sums order-free (fixed-point splat is add-commutative — state it in the kernel doc). Verified by the algorithms-gpu Playwright parity spec: CPU-vs-GPU bundled polylines within a pinned tolerance (f32 vs f64, the round-65 determinism contract) on the two-cluster fixture and a multi-component one, plus one control with the blur radius skewed on the GPU side only — the spec must go red. Measure-first gate: before tuning minGpuN for 'auto', measure both executors at three sizes on real hardware via benchmark:algorithms-gpu (SwiftShader refused, adapter identity printed); if the CPU owns every bench size, the GPU path serves explicit 'gpu' only and the constant stays Infinity with the measured number in the wrapper comment — the pageRankDense precedent, a losing configuration kept measured.

83.4 — the price, the crossover, and the close

Bench: rows in benchmark/algorithms-gpu-bench.mjs (cpu / gpu / gpu first call for bundling — each row prints edgesBundled and refuses to publish a row that bundled zero, the round-39.1 rule in this round's shape) and a renderer-profile scene pricing the bundled curved stream per frame against the same graph straight (the row prints how many instances took the CURVE_BUNDLED path). All bench-file edits batched so each profile's harness fingerprint moves once; publish serial, --repeat 3, per the standing rules. Interaction cost recorded honestly: hover over a dense bundle resolves to the topmost slot (slot order — no z-index, structural, documented). debug/ gains a bundle button on the generated dense scene. Standing close: this record, src/README.md (the operation-owned record contract, the style-wins drop rule, the deformation contract), MIGRATING/CHANGELOG, EXECUTIVE_SUMMARY.md rewritten, d.ts regenerated, JSDoc/throws gates at 100%/zero, oxfmt clean.

Risks named at planning

  • The 23-interior-point cap may read coarse on screen-length edges; the close-up golden is the detector. The escape hatch — raising CURVE_SEGS — reprices every curved edge's VS work, so it happens only behind the renderer bench row.
  • The blob's operation-owned records join compaction; a relocation defect corrupts silently. The soak tier gets a bundle/unbundle churn spec before 83.3 lands.
  • u32 fixed-point splat can saturate on dense grids; the kernel clamps and the parity fixture includes a worst-case cell.
  • The pick spec's chord-miss assertion depends on cursor-tile size; verify it discriminates (run with bundling off) before trusting green.

Open: (1) Progress surface: is a per-iteration event (or an animate: true-style live application, the layout precedent) wanted in v1, or does the promise-plus-stats shape suffice until asked? (2) Should BundleResult presets round-trip through the wire format (a new section, version bump) or stay an app-held payload? (3) Arrowheads at bundle ends: endpoints stay pinned so heads fan at nodes — acceptable, or should v1 offer arrow-scale-style de-emphasis guidance in docs only? (4) Does bundling belong partly in cyext (round 71) as the extension showcase instead of core — and if core, is the operation namespaced (cy.bundleEdges) or grouped under a future cy.ops tier?

Attribute-table and filter affordances

The consumer case: Cytoscape Web built its TableModel/TableBrowser (glide-data-grid) and FilterModel on top of cytoscape.js by copying every element's data out into its own store, and desktop treats the network table and filters as core UI. v4's sidecar is already the columnar store a table wants — the round's job is to stop making a grid copy it. What the code does today, verified:

  1. The sidecar is columnar and typed already — per-(group, key) columns: numbers as Float64Array + presence bytes, strings dictionary-encoded (1-based Uint32Array, 0 = absent) with per-entry refcounts and an order-preserving compaction that bumps a per-column epoch, mixed as a plain-array fallback (data-store.mts:34-55, compaction 622-661). keys() answers the union in first-write order — over the group's history, empty columns stand (data-store.mts:197). reader() hoists column resolution out of a scan (:153), and exportColumns already emits index-aligned wire shapes per key (:207). DataStore.epoch bumps on every value write/clear/ingest and deliberately not on dict compaction or slot remap (:105-109) — one counter over both groups.
  2. Whole-object data() is epoch-keyed (collection.mts:2253-2262, round 62.4): two reads with no write between return the same object. A naive grid binding data() per row therefore pays one object per row, re-materialized after every write burst.
  3. The query IR (matcher.mts:49-78): group + state booleans + structural terms + per-key data conditions (eq/ne/lt/lte/gt/gte/in, exactly one op; unknown keys and ops throw, :127-139, :187-193). Whole-graph scans hoist per-key readers (graph-store.mts:4221-4230); Collection.filter on a subset does not — it calls store.data.get per element per condition (collection.mts:1330). Nothing consults the dictionary: a string eq re-compares strings per slot where one dict lookup plus a u32 compare per slot would do.
  4. No topology predicates: degree is O(1) off the CSR (adjacency.mts:253, :264) but the IR has no degree term; "connected to X" is spelled neighborhood() + intersection today.
  5. Change signals exist but are fine-grained: element data writes emit data per element, listener-gated (collection.mts:2324, :2377), with no changed-keys payload; graph-level cy.data() also emits data (core.mts:2131-2143); add/remove fire per element. The epoch — the natural coarse invalidation key — is private.
  6. Collections are eager: a result is a refs array plus interned handles (collection.mts:319-352), and every speed mechanism around them (the elements() memo, _dataObj, _eles, packed-key maps) assumes immutable eager refs. scanRefsInto writes refs in insertion order (graph-store.mts:4198-4205).

Scope honesty up front: v4 ships columns, keys, coverage and fast predicates; the grid, its sorting/column UI and the filter-builder UI stay app-level.

84.1 — the column view API

The read surface a data grid renders 100k rows from without materializing 100k objects:

  • cy.dataKeys( group )[ { key, kind, coverage } ]kind is the column's ('number' | 'string' | 'mixed'), coverage how many live elements carry a value. Keys are the historical union, stated in the JSDoc (a cleared key stays listed at coverage 0 — the documented keys() semantics, not a defect).
  • cy.dataColumn( group, key ) and eles.dataColumn( key ) — a snapshot in exactly the shapes exportColumns already emits (Float64Array with NaN holes / { dict, indices } / plain array), aligned to a stated row order: insertion order for the core form (the scanRefsInto order, which is also cy.nodes() order), the collection's own order for the collection form. Snapshot rather than live view, decided: dict compaction remaps indices in place (data-store.mts:647-652), so a live view would silently re-key under a consumer's feet. The snapshot carries the DataStore epoch at capture so "still current?" is one int compare.
  • Synthesized columns: id (and edge source/target, node parent) are first-class, not sidecar (collection.mts:2264-2277) — the surface synthesizes them as columns too (ids off the id blob, no per-element handles), or a grid has rows it cannot label.
  • Subscription: the epoch goes public (naming open), and the existing data/add/remove events are the triggers — a grid listens, marks dirty, and re-pulls invalidated columns at most once a frame with the epoch compare deciding whether anything moved. Whether the data event gains a written-keys payload is an open call (additive to the Event shape; without it invalidation is per-store, not per-column).

Verified by specs asserting each column kind round-trips values (the round-46.5 lesson: assert every column carries values, with the read-the-dict-as-an-array control failing on every fixture), coverage against hand-counted fixtures, epoch movement on write and non-movement on read, and snapshot isolation across a forced dict compaction. Measure-first gate: coverage — measure an on-demand presence scan at 200k before adding a maintained per-column counter to set/clearValue (the hottest sidecar paths); if the scan is microseconds, on-demand wins and the write path stays untouched.

84.2 — fast filters over columns

  • Column-compiled conditions: compile each data condition against its column's kind once per filter — string eq resolves the value to a dict index once, then u32 compares per slot; in becomes a Set of dict indices; numeric ops test the Float64Array + presence byte directly; mixed columns keep the generic testCondition. The case-mapper absence rule (a missing value fails every op, ne included) must survive the accelerated path — a spec pins it per kind. One compileColumnTest helper serves both scanRefsInto and Collection.filter, and filter also gains the reader hoist it lacks today (collection.mts:1330 vs graph-store.mts:4222-4230).
  • Topology predicates, per the matcher's own extension note (matcher.mts:23-26): degree / inDegree / outDegree as numeric-bound terms (nodes-only — an edges-restricted query throws, the structural-term shape at matcher.mts:237-241), answered O(1) per slot off the CSR; and adjacentTo: id | id[] (nodes matching when adjacent to any listed node), built once per filter as a slot-membership set from the CSR rows. Serializable, no functions — a FilterModel can persist every query it builds.
  • Lazy collections: declined, recorded. Collections are eager by architecture and everything fast about them assumes it; the non-materializing consumer is the column view (84.1) — a grid never asks for a collection at all, and a filter result that will be styled or selected wants a real one. New IR keys join QUERY_KEYS, so a typo still throws.

84.3 — bench rows that discriminate, and the close

All benchmark/data.mjs edits land in one batch so the fingerprint moves once (the round-68 rule). Rows: dataColumn 200k against the naive per-element loop (which must call data() per row — and must write one value between passes, or the 62.4 cache makes the naive side measure the memo rather than materialization); filter string eq (dict) and filter numeric range against the predicate-fn spelling; filter degree ≥ k against the traversal spelling. Every row asserts its result count is > 0 and < N (a filter matching nothing or everything prices a scan, not a filter) and asserts result-set equality across the compared spellings. Controls, run once: the dict acceleration disabled (string compares) must move the eq row; the CSR degree term swapped for per-element degree() must move the degree row. Measure-first gate: before the dict path lands in the subset filter, measure the reader hoist alone at bench N — if hoisting closes most of the gap there, the dict path lands only in the whole-graph scan and the subset number is recorded instead. Standing close: JSDoc with @param/@throws/@returns at 100%, d.ts regenerated, src/README.md gains the table-affordances section, MIGRATING/CHANGELOG rows, test:throws at zero.

Risks named at planning

  • A chatty writer plus a per-frame column puller is a copy storm (800 KB per 100k-row f64 column); the epoch compare and batching bound it, but the record must carry the measured re-pull cost, not assert it away.
  • Two invalidation keys exist — the store epoch (values) and the per-column dict epoch (indices; data-store.mts:27-31, the GPU ordinal LUT precedent). A snapshot consumer holding raw indices must be told about both or it repacks late; the snapshot shape has to carry them together.
  • Adding a keys payload to the data event changes an emitted shape consumers already see — keep it additive and spec the no-listener fast path stays listener-gated (collection.mts:2324).

Open: the surface's naming — flat members (cy.dataKeys, cy.dataColumn) or one cy.table( group ) view object; whether the data event gains a written-keys payload now or waits for demand; whether adjacentTo ships nodes-only in v1 (edges' incidentTo twin deferred); whether the epoch is exposed raw or wrapped in a changedSince( token ) shape; and coverage counter vs on-demand scan if the measurement lands near the line.

The layouts round: radial, force constraints, edge length, per-side padding

Four demands with issue numbers attached, gathered. What the code does today, verified:

  1. Built-ins: grid/preset/circle/concentric/breadthfirst/random/ force, dispatched by name with an unknown name throwing (core.mts:703-735). Concentric is metric-driven — a concentric score (default degree) bins nodes into rings by levelWidth (concentric.mts:14-36); breadthfirst's circle: true rings each BFS depth with uniform per-index angles (breadthfirst.mts:384-394). Neither allocates a subtree an angular wedge, so a node's children scatter around the ring far from its sector — the hierarchy-aware allocation is exactly #2493's ask (issue text to-verify; cited from the round brief).
  2. Two layout shapes exist: handle-level discrete layouts through the layoutPositions finisher (breadthfirst.mts:422; circle/ concentric/random likewise) and the columnar round-17 LayoutContext (layout/contract.mtsnodeSlots/endpoints/ positions/setPositions plus the same finisher), with force as the contract's production consumer.
  3. edgeLength already exists and is per-edge: a number or plain fn resolved once into a Float32Array (force.mts:33-36, 239-249) consumed by both executors (force-sim.mts:107-108; gpu-force.mts:417, uploaded at 584-586), nesting multiplier applied after (:243-245). #1514's per-edge control is therefore built; what is missing is a serializable spelling — a fn cannot ride cy.json() or the wire.
  4. Fixed nodes exist: locked nodes pin (force.mts:173-182), ride bit 31 of the slot map into the GPU kernel (gpu-force.mts:34-35), and suppress the settle re-pack. No alignment or relative-placement machinery exists anywhere.
  5. The GPU iteration is eight dispatches encoded k-per-submit (gpu-force.mts:822-837) with a 4-byte convergence staging read as the sole readback (:44-48) — a CPU-side projection per tick would need the per-tick readback the architecture forbids, so constraints run on-device or the run demotes to the CPU executor (the 14.11 compound precedent, force.mts:7-11).
  6. The compound clamp is centered by decided design (hierarchy.mts:11-25): the flush materializes the children-bb center with symmetric padding and coreW = max( bbW, minWidth ) (hierarchy.mts:385-391); compound props enter via style.mts:4861-4871 and read back at 5686-5687; the per-side hook is logged (src/README.md:2415-2416, :4943).

85.1 — the radial tree layout (#2493)

New src/layout/radial.mts, cy.layout({ name: 'radial' }) joining the dispatch and its throw message. Discrete, breadthfirst's shape: roots as a collection or id array (a selector string throws — the breadthfirst.mts:113-117 precedent), a BFS tree per root (round-10 slot-native bfs; non-tree edges just draw, recorded — breadthfirst's stance), then hierarchy-aware angular allocation: each node's wedge is a share of its parent's proportional to its subtree's weight (weight: 'leaves' | 'subtree', default 'leaves' — no functions), placed at its wedge bisector at radius depth × levelSpacing (derived from the bounding box when unset). Multiple roots partition the sweep proportionally to their trees. Options: roots, startAngle, sweep, clockwise, levelSpacing, weight, plus the shared plumbing (fit/padding/boundingBox/spacingFactor/animate/ transform) through the finisher. Leaves only; parents derive (the standing compound rule). Verified by test/layout-radial.mjs with headlessWidth/headlessHeight set (the AGENTS.md rule): a child's angle inside its parent's wedge, monotone radius by depth, sibling wedges disjoint, and the unbalanced fixture's heavy subtree measurably wider — with the control swapping the allocation for breadthfirst-circle's uniform index spacing, which must go red. Bench row in layouts.mjs (batched with 85.3's edit): an unbalanced 2000-node tree, the row asserting in-row the property it is named for (distinct radii == depth count; the heavy subtree's angular span exceeds the light's). No v3 twin exists (v3 has no radial); the comparison partner is v4's own breadthfirst-circle, pricing what the hierarchy-awareness costs.

85.2 — constraints on the force layout (fcose #54/#53 absorbed)

v1 kinds: fixed — already spelled lock(); document the equivalence rather than add a second spelling; alignmentalignment: { horizontal?: string[][], vertical?: string[][] } (fcose's shape; id arrays, serializable), groups sharing a node merge transitively; relative placementrelativePlacement: [{ left, right, gap? } | { top, bottom, gap? }]. Validation fails loudly at start: unknown ids throw, a constraint cycle in the placement DAG throws, two locked members of one alignment group at different coordinates throw.

Method: constraint projection after each integration step — the IPSep/CoLa-lineage standard, and what fcose itself runs. CPU executor first and as spec: per tick, alignment groups snap the constrained coordinate to the group mean (a locked member pins the group to its coordinate), violated relative pairs split the correction Jacobi-style, pinned nodes never move. Projection order vs the displacement fold is load-bearing: fold-then-project reads as never settling, project-then-fold can settle while violated — a spec pins that constrained runs both converge and satisfy. The spectral seed is constraint-blind, so the run projects once before the first tick to shorten the transient. The settle re-pack translates whole components and is already skipped for locked nodes; the skip extends to any constrained run.

GPU: measure-first gate. The candidate shape is one constrain dispatch appended after apply per iteration — alignment groups as a [starts][members] buffer, one workgroup per group tree-reducing the mean (v1 caps a group at one workgroup's reduction width), relative pairs as a Jacobi pair list. But constraints are user-authored (tens, not 100k), so first measure the demotion: the renderer bench runs the constrained scene on the CPU executor against the unconstrained GPU run. If the demotion is acceptable at target sizes, v1's contract is "constrained runs take the CPU executor" (the compound precedent, documented) and the kernel is logged with the number attached; if not, the kernel is built. Either way the losing configuration stays measured. Verified by seeded CPU specs (spread < eps at settle per aligned group; gaps satisfied; every throw message-asserted), the control deleting the projection step (every geometry spec red, the convergence spec not hanging), and a bench row that verifies constraints were active: the timed row asserts alignment spread < eps at end while its unconstrained twin on the same fixture shows a large spread — the delta prices projection.

85.3 — edgeLength gets its serializable form (#1514)

edgeLength?: number | { data: string, default?: number } — the { data } form reads the per-edge numeric column once at start through the hoisted store.data.reader; a missing value takes default (else DEFAULT_EDGE_LENGTH, force.mts:68); a key whose column is string/mixed throws, naming key and kind (silent defaulting is how a typo'd key would lay out plausibly wrong). The nesting multiplier applies after, unchanged; both executors consume the same Float32Array, zero sim changes. The fn form stays for now (removal is a breaking call — see Open). Verified by a seeded CPU run on a path graph with data lengths 50/200 asserting the settled neighbor-distance ratio, against the constant-edgeLength control reading ~1:1 — the pair discriminates; plus the throw spec. No new bench row: same cost shape as the fn form; the force row's comment notes the option. d.ts + public-types.mts:486-488.

85.4 — per-side compound padding (the logged hook)

Four parents-group props — padding-left / padding-right / padding-top / padding-bottom — each px or 'N%' like padding (same pfValue convention, same relativeTo basis), defaulting to padding when unset. Plumbing: CompoundStyle grows four optional fields (hierarchy.mts:19-25); the flush resolves each side and materializes w = coreW + padL + padR, cx' = cx + ( padR − padL ) / 2 (same for y) — the centered min-size clamp itself is untouched, per the round-14 decision the hook records (src/README.md:2415). Readback via the existing compound plans (style.mts:5686-5687 pattern); padding() keeps answering the uniform prop, documented. Compound props outside the parents group throw (existing rule). The round-25 padding tween writes { padding } alone through the partial-update path whose own comment warns about resets (hierarchy.mts:279-281) — a spec pins a padding tween leaving padding-left standing, because that path is exactly where the defect would hide. Verified by compound-bounds specs with headless dims (asymmetric padding shifts the parent box the computed amount; children bb unchanged; the zero-one-side control goes red) and one open of the debug compound fixture (the "something has to open the page" rule).

Sizing, and whether the round splits

Recommend splitting: 85.1/85.3/85.4 are each single-subsystem and discrete-spec'd — together one normal round. 85.2 touches both executors, possibly a new WGSL kernel, the renderer bench, and carries a measure-first gate that can change its own shape mid-round — round-59-shaped, a round on its own. So: round 85 = radial + edge length + per-side padding; the constraints pass becomes its own follow-on round with its design recorded here once. If the maintainer keeps it whole, 85.2 lands last so its gate cannot stall the other three.

Risks named at planning

  • The projection/fold ordering (85.2) has a wrong answer in each direction; only the paired convergence-plus-satisfaction spec catches both.
  • The radial control must actually fail — run it once (the round-27 "check that a new spec can fail" rule); a wedge assertion loose enough to pass uniform spacing measures nothing.
  • All layouts.mjs edits batch into one commit so the benchmark fingerprint moves once (round 68).
  • Every geometry spec sets headlessWidth/headlessHeight or it tests a different graph than the one that ships (the AGENTS.md rule).
  • 85.3's kind-mismatch throw should share round 84's column-kind vocabulary so the two rounds' messages agree.
  • The stranded-doc-block hazard applies to the dispatch-table and options-type edits — run the JSDoc gate before each commit.

Open: the split call (recommended: the constraints pass runs as its own follow-on round); keep or deprecate the fn form of edgeLength — and whether the answer is a policy covering concentric's fn defaults (concentric.mts:27-28), the standing serializability exception, or one option's call; radial's default weight ('leaves' recommended) and whether multi-root sector partitioning matches desktop's radial expectations; the constraint v1 surface — the alignment-group reduction cap, whether cross-component relative placement is in, and whether CPU-executor demotion is an acceptable v1 contract for the fcose #54/#53 consumers; per-side padding percent support vs px-only.

Landed (2026-08-31, with round 87 in one pass)

All four items shipped; the split call was resolved by the maintainer the other way — 85.2 included, landing last — and its measure-first gate resolved to the CPU demotion (below). The Open items, decided:

  • 85.1 radial — as specced (src/layout/radial.mts, weight default 'leaves'). #2493's issue text was fetched and verified: the ask is exactly the subtree-wedge grouping (the Vega radial-tree reference), which the wedge allocation delivers. Two deviations found by the specs: the slot-native bfs seeds its queue in reverse roots order, so multi-root wedge order is restored to the caller's roots order; and with several roots they move out to the first ring (a shared centre would be coincident). Priced against v4's own breadthfirst-circle: 2.4 vs 7.5 ms at N=2000 (the wedge allocation is ~3x cheaper than breadthfirst's weighted-percent sort), in-row assertions green (8 depths == 8 radii; true wedge spans 5.03 vs 1.25 rad).
  • 85.3, expanded by discussion — the maintainer reframed the value as ergonomics ("how do I make a log mapping with large scores mapped to shorter edge lengths"), not serializability, and chose the full census scope: the score shape { data, scale?: 'linear'|'log'|'sqrt', range?, invert?, default? } on force.edgeLength and concentric.concentric, and the sort shape { data, order? } on grid.sort/circle.sort/ breadthfirst.depthSort — one module (src/layout/layout-mapping.mts, one-shot resolution, deliberately not the style-scales IR), fn forms kept as escape hatches with the objects canonical. The kind-mismatch vocabulary ('number'|'string'|'mixed') is the store's own (round 84 never landed, so this sets the precedent); DataStore gained a public kind().
  • 85.4 per-side padding — px and percent (same pfValue / relativeTo convention), centered clamp untouched, padding() still answering the uniform prop, core readbacks via per-axis sums. The 25.4 tween trap is pinned; padding-left left the 2026-07-29 decided-drops triage (it exists again, parents-group).
  • 85.2 constraints — as specced on the CPU executor (src/layout/force-constraints.mts + the projection in force-sim.mts); the projection's corrections stay outside the convergence displacement and every tick ends projected, and the paired convergence-plus-satisfaction spec pins both failure directions. The gate: constrained CPU settle 25.98 s vs 0.41 s silent GPU at 25k x 50k (render-bench --layout) — severe there, but the constraint population (fcose migrators) runs at sizes where the CPU settle is seconds and the projection itself costs ~4% (125.2 vs 120.5 ms per 20 CPU iterations at N=2000, layouts.mjs) — so v1 accepts the CPU demotion (the compound precedent), the losing configuration stays measured in both benches, and the constrain dispatch design above stays recorded for the day demand justifies building it.

Every new spec ran its round-27 control red once (weights-to-1 and uniform-angles for radial; neutered score/sort resolution for the mappings; sides-ignored flush for the padding; projection-deleted for the constraints — no hang). All layouts.mjs edits landed in two batched commits (85.1+85.3 together; 85.2's row separately), the fingerprint moving once each.

The worker-hosted renderer (OffscreenCanvas)

The oldest renderer demand on the tracker (#1350, #2799): move rendering off the main thread entirely. WebGPU works in workers (Chrome; other engines to-verify — round 73's reach table carries the in-worker columns for exactly this). Planning verified the coupling before designing, and the finding that organises the round: the model↔renderer boundary is already message-shaped, and the one load-bearing sync API never touches the GPU. What the code does today, verified:

  1. The renderer reads the model only through ModelView (contract.mts:990-1030): typed-array columns, one coalesced dirty span per column per frame (store/dirty.mts:47-68, take()-and-clear at :103), the four blobs under the same span rules (StoreDelta, contract.mts:878-896), the label sidecar with its own dirty list, parentOrder(), and the image registry. ColumnMirror consumes it as byte-for-byte span copies (column-mirror.mts:13-14). So the question the plan was asked — are the diffs already enumerable? — is answered by reading: the delta the renderer drains each frame is the enumeration, and a (columnId, start, end, bytes) message carries it verbatim.
  2. But the renderer also holds direct core refs beyond ModelView: cy._store (37 call sites), cy._styleEngine (the arrowEnds/midArrowEnds tables, renderer.mts:1839-1869), cy._viewport (pan/zoom read per frame, renderer.mts:595, 2478), cy._ele for pick decode (renderer.mts:617-636), and — the deepest coupling — cy._animations: the frame loop is the animation clock (attachDriver at renderer.mts:1171, cy._animations.tick(t0) at renderer.mts:1276). The core's own view of the renderer is, by contrast, already narrow and mostly async: RendererLike (core.mts:82-95) — destroy / pick (Promise) / requestRender / resize / stats / forceActive / exportImage (Promise) — plus the force layout duck-typing renderer.startForce (layout/force.mts:426-445).
  3. The sync surface: pickNodeSync (renderer.mts:580; five pointer call sites, the pan-vs-grab decision at pointer.mts:342) resolves through pickNodeAt over the CPU-canonical columns (renderer.mts:602, cpu-pick.mts:36-41) — no GPU, no renderer state beyond view constants. With the store staying main-side, the sync pick stays main-side and stays sync. exportImage and pick are Promises already; stats() is a sync counter snapshot.
  4. Events: the pointer handler binds to the renderer's canvas (pointer.mts:1693 — not the container, correcting the planning premise) and writes back through the core API. Under transferControlToOffscreen() the canvas element stays in the DOM and keeps receiving events, so input needs no forwarding at all — only what the frame already consumes (viewport state, diffs) crosses.
  5. The wire format (wire.mts:17-25) is the precedent and the seed for the initial full-state transfer; per-frame traffic is spans, not the wire format.

Design, pass 1: renderer-only in the worker — store, style, animation manager, layouts and pointer all stay on main. A main-side RendererProxy implements RendererLike; a worker-side RemoteModelView implements ModelView over local typed arrays fed by span messages; the frame loop, mirror and pipelines run in the worker unchanged. The named hard couplings and their resolutions: the animation manager reclaims its own rAF loop (it already owns one for the sink-less case, animation.mts:1740-1750) and the tween sink becomes a register/unregister proxy — the settle contract ("re-derive on CPU, no readback", gpu-tween.mts:16-19) survives untouched, a designed-for property; pick decode + staleness revalidation (renderer.mts:617-636) moves main-side over raw ids; image decodes move to the worker (createImageBitmap + transferables); device loss forwards out and the core's re-mount recovery (core.mts:2917) tears down and respawns the worker; startForce is deferred or proxied — decided in-round on 86.1's cadence numbers.

86.1 — the coupling audit and the messaging contract, written first

Enumerate every renderer→core touch (the census above is the start) and classify each: served by RemoteModelView / moved main-side / forwarded / deferred. Write the message schema — init transfer (wire-format-seeded), per-batch delta (spans + blobs + label entries

  • viewport + tween registrations), pick request/reply, export, resize, devicelost, stats snapshot — as a typed module under the contract.mts discipline: co-signed, changed first. Measure-first gate: the per-frame copy cost. A CPU-side position animation or layout tick dirties a span covering most of node.position; measure copy + transfer at harness scale (ndex-x-large) before committing to the copy design. SharedArrayBuffer would erase the copy but demands cross-origin isolation the library cannot impose on embedders, so it can only ever be an opt-in tier — priced here, decided by the maintainer (Open).

86.2 — extract the seam (behaviour-neutral, lands alone)

The renderer stops reaching into cy._*: it takes at construction a ModelView, a viewport view, the style arrow tables, and a tween-sink registration surface; pick decode moves core-side. No worker yet — the same-thread path remains the default and must stay byte-identical. Verified by the full existing suite with the goldens moving zero pixels, plus the round-42 restructure rule: a green suite proves the paths resolve, so the proof is the call-site diff audited against the one change the pass is allowed to make. This pass is independently valuable — it is also the seam a WebGL fallback renderer or a headless-Node renderer would mount through — which is why it is separated from 86.3.

86.3 — the worker host, behind renderer: { worker: true }

Opt-in on RendererOptions (public-types.mts:528); the default is untouched. The worker bundle question — a rolldown worker entry versus spawning from the existing UMD — is decided with test/modules/packaging.mjs's gates in mind, not around them. Fail-loudly: absent OffscreenCanvas / worker-side navigator.gpu, mount rejects with a clear message on the index.mts:60-65 precedent (whether a documented capability probe should accompany it is an Open call — never a silent same-thread fallback). Verified by: a worker variant of a golden subset diffed exact-zero against the same scenes' same-thread goldens (same SwiftShader pin — the strongest available parity statement); the renderer project's interaction specs run once under the worker config; and a soak spec asserting the worker is terminated and the instance collected across create/destroy cycles (the WeakRef discipline). The frame-driver note is re-verified deliberately: worker rAF under CI's SwiftShader has never been measured here, and the mid-flight specs' polling rule already assumes nothing about when frames land.

86.4 — measure, record, close

A benchmark row whose subject is the point: main-thread occupancy per frame under drag/animation at harness scale, worker versus same-thread — total frame time would measure nothing, since the work does not shrink, it moves. Plus the pick-latency delta (one hop). New suite ⇒ new fingerprint; publish serial per the standing rules. Close: src/README.md section, MIGRATING/CHANGELOG rows if any public surface moved, JSDoc gates, d.ts regenerated, this record.

The round-73 dependency, flagged

This round's architecture depends on round 73's outcome, and 86.2 is where it bites. If a WebGL fallback is built — pre- or post-4.0 — the worker split must sit above renderer choice: the message schema and RemoteModelView renderer-agnostic (nothing WebGPU-typed in the contract), or the matrix becomes two renderers × two hosts with private plumbing in each cell. If 73 recommends never, the seam may keep WebGPU types and stay simpler. Also re-checked after 73: WebGL2-in-worker OffscreenCanvas support is likely broader than WebGPU-in-worker (to-verify — 73.3's reach table carries both columns), which could invert which renderer the worker host serves first. Whichever assumption this round builds on, the record states it and names what 73's data would have to say to overturn it.

Risks named at planning

  • The per-frame copy can eat the win on position-heavy frames: GPU force and GPU position tweens publish on-device (no spans), but CPU layouts and CPU-channel animations produce near-full-column spans every tick. 86.1's gate exists because this number decides the design, not tunes it.
  • Worker rAF throttling differs per engine and changes background- tab behaviour; document against today's main-thread throttling rather than fixing what is not this round's subject.
  • Two host configurations widen the Playwright surface; the worker tier runs a golden subset, exact where it runs, rather than doubling all 45.
  • 86.2 is the stranded-doc-block refactor shape (65.4/65.8, 72.3's warning) — the JSDoc gate runs before each commit.
  • stats() under a worker is a mirrored snapshot; a spec that samples it must key off gpuFrameReadings transitions, exactly as its doc comment already instructs (public-types.mts:573-577).

Open: the option shape and the unavailable behaviour (reject loudly as planned, with or without a public capability probe); the SharedArrayBuffer opt-in tier — pursue, or decline with the measurement recorded; whether stats() stays a documented stale-snapshot or goes async (recommendation: sync snapshot plus a freshness field); the worker option's own pre/post-4.0 positioning — it is additive and opt-in, so unlike 73's subject it does not change what 4.0 is, and the recommendation is post-4.0 unless 73's outcome bundles the two splits into one boundary round; whether startForce crosses the boundary in pass 1 or waits; and whether 86.2 should be pulled forward to land beside round 73's decision, since it is the seam both futures share.

Landed

The round ran in the plan's order: the measure-first gate, then the seam, then the worker host, then the measurement. Each pass's record was written with the pass.

86.1 — the copy-cost gate: the copy design is affordable

The plan's first question — can a full node.position span cross a thread boundary every frame? — is answered by measurement before any design was committed. benchmark/worker-copy-bench.mjs prices the worst case the risks section named: a CPU-side position writer (CPU layout tick, CPU-channel position tween, whole-graph drag) dirtying a span covering the whole position column, every frame, sustained with one message in flight at a time (the renderer's own coalescing discipline). Median of 300 frames on this machine (Node worker_threads; browser postMessage shares the structured clone/transfer machinery, and 86.4 confirms in situ):

graph span slice (copy out) clone round trip transfer round trip
ndex-x-large (19,607 nodes) 153 KiB 0.055 ms 0.100 ms 0.035 ms
100k nodes 781 KiB 0.260 ms 0.237 ms 0.098 ms
500k nodes 3.8 MiB 0.346 ms 1.514 ms 0.681 ms

The control is the byte count itself: doubling n roughly doubles every row, so the rows measure the copy and not the harness floor.

The gate passes decisively. At harness scale the whole worst-case round trip is a tenth of a millisecond against a 16.7 ms frame budget; even at 500k nodes — beyond anything v4 has ever rendered — the transfer-based trip is 0.7 ms. Two calls fall out of the numbers:

  • The copy design is committed. Per-frame span messages, fresh slice per message, buffer transferred (transfer beats clone 2–3× and frees the sender's copy).
  • The SharedArrayBuffer opt-in tier is declined, with the measurement recorded (the plan's own alternative resolution) — including the head-to-head, run under the same one-in-flight protocol. A SAB design crosses only a {start, end} notice, so it is flat ~0.02 ms at every size; against the committed transfer round trip that saves 0.07 ms/frame at harness scale (0.086 → 0.018 ms), 0.14 ms at 100k nodes and 0.73 ms at 500k (0.747 → 0.022 ms). The relative win (4–34×) is real; the absolute win is noise against a 16.7 ms budget, and it would be bought with the cross-origin isolation SAB demands of every embedder plus a tearing-safety design (double buffering or epoch fencing) the measured row does not yet pay for. Revisit only if a real app measures span traffic above ~1 ms/frame.

86.2 — the seam: the renderer takes a host

The renderer no longer reaches into the core. src/render/host.mts defines the seam, and its RenderHost is the 86.1 coupling audit made executable — a new renderer read now has to be added to an interface, visibly, before it can compile:

  • RenderStoreView extends the contract's ModelView with everything the renderer and label layer actually consume beyond it, enumerated: the frame-uniform scalars (curveSlack, haystackSlack, outlineSlack, arrowScaleMax, arrowWidthMax), the draw-gating counts (parentCounthasCurvedEdges), flushDerived, boundingBox (export views), compactEpoch, takeMapperSpans, nodeImagesAt, and the label layer's font surface and setLabelDims write-back. GraphStore satisfies it structurally; the census the plan asked 86.1 to produce is this interface plus the host members below.
  • ViewportView (pan()/zoom(), read per frame), AnimationClock (tick/active/attachDriver/detachDriver), arrowEnds()/midArrowEnds() (the style engine's two tables, now snapshot-shaped accessors), onViewportChange, emitRender/emitError, and two capability seams: gpuMappers (null ⇒ no MapperRuntime is constructed — the CPU-applied style columns stay canonical) and createImageDecoder (null ⇒ the registry gets no rasterizer).
  • Pick decode moved core-side, as planned. The renderer speaks slots and packed ids only: pick() resolves a raw pick id (node slot + 1; edges carry EDGE_PICK_BIT), pickNodeSync() answers a slot, and Core._decodePick does the id decode plus the two-frame-staleness revalidation against the live model. The pointer layer wraps sync picks through a nodeAt helper. The renderer imports nothing from the core or the collection anymore.

Behaviour-neutrality, verified as the plan required: the full Node tier green; the renderer Playwright project green; all 45 goldens exact — zero differing pixels — plus the live v3-parity diffs, under the same SwiftShader pin. One file:line-keyed throw-gate exemption (MISATTRIBUTED, the exportScale arrow const) was repointed 153→150 because the import block above it shrank — the gate failing on that move is it working as designed.

86.3 — the worker host, behind renderer: { worker: true }

Renderer-only in the worker, exactly the plan's pass-1 shape:

  • The engine is unchanged in kind — the same Renderer class runs in the worker, constructed against an OffscreenMount (transferred canvas + explicit device-px size + the main thread's resolved dpr) instead of a container; setSize replaces the ResizeObserver, the glyph atlas rasterizes on an OffscreenCanvas where document is absent, and export views arrive pre-resolved (exportFromView; the view maths extracted as the pure resolveExportView).
  • The message contract (worker-protocol.mts, written first, under the contract.mts discipline): one StoreBatch shape serves the init transfer and every per-frame drain — wire spans with transferred buffers, blob dirt, label sidecar entries, the scalar/count/font snapshot, arrow-end tables and viewport. buildBatch + collectTransfers are pure over the store, so the Node spec exercises the very code the proxy runs.
  • RemoteModelView (remote-view.mts) implements the whole 86.2 RenderStoreView seam over local mirrors, re-expressing each batch through the same DirtyTracker class the store uses — the renderer's frame drains an ordinary StoreDelta and cannot tell the hosts apart. Measured label dims flow back (labeldims messages) into the canonical store, where label bounds and the text-events pick live.
  • The proxy (worker-renderer.mts) satisfies RendererLike and the pointer layer's new GestureRenderer surface. Main-side, by design: the canonical store, the DOM canvas (events land on it — no input forwarding, as planning predicted), the sync CPU node pick (pickNodeAt over canonical columns; never mirror-stale), the animation clock (the manager's own sink-less rAF loop), and export view resolution. Worker-side: frames, culls, uploads, GPU edge picks, label rasterization, exports.
  • The spawn is the packaging answer the plan asked 86.3 to decide: the worker loads the same bundle from its own URL — importScripts for the UMD script-tag path, a message-buffering dynamic import for native-ESM — and calls cytoscape.__runRenderWorker__ (underscored machinery; excluded from the docs generator's statics and, via a cast at the assignment, from the shipped declaration). No second build artifact, so test/modules/packaging.mjs is untouched.
  • Fail-loudly: mounting without Worker/OffscreenCanvas throws its own clear message (spec'd in Node, where neither exists); a worker without WebGPU rejects ready through an initerror message. No silent same-thread fallback, per the plan. Device loss forwards out; the core's existing unmount/mount recovery respawns a fresh worker through the same _attachFn.

Verified: the plan's own strongest statement — the same scene exported through both hosts on the pinned SwiftShader adapter diffs exact-zero, twice (static, and again after style + position + zoom mutations crossed the boundary). Also spec'd in the browser: label ink plus the dims write-back (observable as the main-thread bounding box growing), node/edge/background picks through the proxy, and three create/destroy cycles leaving a healthy page. In Node (test/modules/worker-renderer.mjs): full-state and incremental mirroring across a real structuredClone, capacity growth, the delta re-expression, dims batching — and the control, a deliberately-tampered batch that leaves the mirror provably wrong.

Deferred in-round, recorded (each was the plan's own contingency): background images (the proxy zeroes the count and emits one loud error event; createImageBitmap decodes are the follow-up), GPU tweens and startForce across the boundary (CPU paths are correct today; the copy cost of their per-frame spans is exactly what 86.1 priced), @font-face labels in the worker (the worker's FontFaceSet does not inherit the page's registrations; system faces render), and the SharedArrayBuffer tier (declined on 86.1's numbers). The three worker-host deferrals are also logged as ledger item 51, so they are findable from the open-calls ledger and not only from this record. One incidental find, since diagnosed to invocation shape and given its own round (round 109): test/force-layout.mjs's spectral-seed spec fails deterministically when the file runs without the tier's --import ./test/node-test-setup.mjs preload, and passes 13/13 with it — pre-existing at the branch base, unrelated to this round.

86.4 — the measurement: whose thread pays

benchmark/worker-occupancy-bench.mjs measures the subject the plan named — main-thread cost per frame, worker versus same-thread — under the 86.1 gate's worst case made real: a main-side rAF loop rewriting every position of a 20,000-node / 30,000-edge graph each frame (the shape of an external CPU layout tick or a whole-graph drag), plus a renderer-dominated control (viewport spin, writer trivial) and the pick-latency delta. 240 frames per mode, warmed, on this machine's real AMD adapter (the run prints the adapter identity and carries a --swiftshader mode for the software-adapter case):

mode writer loop main busy renderer cpuFrame pick rtt frames drawn
same-thread 16.90 ms/frame (59.2 fps) 52% 0.20 ms (on main) 0.4 ms 120 / 240
worker: true 17.54 ms/frame (57.0 fps) 52% 0.20 ms (in worker) 1.0 ms 236 / 240
same-thread, viewport spin 16.94 ms/frame 51% 0.20 ms 238 / 240
worker, viewport spin 17.60 ms/frame 51% 0.20 ms 236 / 240

Read honestly, three findings:

  • The costs are as 86.1 predicted and small: ~0.7 ms/frame of batch build + post on the writer loop, and +0.6 ms on the async edge-pick round trip (the one hop).
  • The occupancy win is negligible on this hardware — and that is a finding about v4, not about the worker. The same-thread renderer's whole per-frame CPU cost at this scale is ~0.2 ms: the render-on-dirty architecture already keeps the main thread nearly idle, so there is little occupancy left to move. The worker's occupancy case rests on configurations where that number is large — software adapters, heavier scenes — not on healthy desktop GPUs.
  • The visible benefit is cadence isolation. With the main loop saturated by the writer, the same-thread host painted half its frames (120/240) — the writer and the renderer's rAF contend for the same 16.7 ms — while the worker host painted 236/240: the graph stays visually smooth under exactly the load that degrades the same-thread path. The viewport-spin control shows both hosts at full cadence when the main thread is idle, pinning the writer contention as the cause.

The software-adapter case, measured rather than assumed (the plan's frame-driver note said worker rAF under SwiftShader had never been measured here; now it has). At harness scale SwiftShader is simply unusable — the same-thread writer loop measured 2,527 ms/frame (0.4 fps) before the run was cut off — so the datapoint comes from a 2,000-node / 3,000-edge scene: both hosts are compositor-bound at 2–3 fps (the rAF cadence waits on software rasterization regardless of which thread encodes), the worker host again painted every frame (60/60 and 59/60) against same-thread's 31/60 and 41/60, and it added ~30% to the main rAF interval. The conclusion transfers: on a software adapter the worker cannot make SwiftShader fast, it can only keep paint cadence — which it does.

The recommendation the plan left open is taken by these numbers: the worker host stays opt-in, and post-4.0. It is additive, its costs are real but small, and its benefit today is smoothness under main-thread saturation rather than a wholesale occupancy win — worth having, not worth changing what 4.0 is.

Layout mechanics: packing made reusable, the handoff decoupled from animate, and the animate gaps

Three maintainer ideas — reusable packing, the debug page's animate toggle reaching the discrete layouts, and the force layout handing off to the GPU regardless of animate — investigated before planning. Round 85 owns the layout feature surface (radial, constraints, edgeLength, per-side padding); this round is the mechanics counterpart. What the code does today, verified:

  1. Packing is force-private. All of it sits in src/layout/force-init.mts: computeComponents (:36-88), estimateComponentRadius (:100-105), packAnchors (:160-203) and packComponentsExact (:261-336 — "exactly v3's separateComponents, translation-only", per its own header), while the actual bin-packer shelfPack (:119-148) and its PackBox shape (:107-114) are module-private — unexported and therefore untestable directly. Sole consumer: force.mts:21-27; the settle re-pack closure (force.mts:346-351) is skipped whenever the scope holds a pinned node. Everything works in sim-index space, keyed off the run's mean edge length. No discrete layout packs: grid is ordinal cells, circle one ring, concentric degree rings; the only one that reads components at all is breadthfirst — for root selection (breadthfirst.mts:127-139) — and it interleaves components in shared depth rows, orphans prepended as a synthetic depth-0 row (breadthfirst.mts:329-330).
  2. The GPU handoff is gated on animate === true (force.mts:423), so animate: false runs the CPU sim synchronously on the main thread to convergence (:480-491) even on a flat rendered graph with a device present. And the lease presents inherently: the runtime integrates directly into the mirror's own position buffer (renderer.mts:1407-1413 passes mirror.buffer('node.position') to encode), the column is tween-owned for the run (:1278-1287), and redraw is held while the runtime lives (:1289-1292) — today "hand off" and "show it" are one mechanism, which is exactly the coupling the maintainer's ask dissolves.
  3. Discrete animate support is split down the middle. circle/concentric/breadthfirst/random finish through eles.layoutPositions (collection.mts:5280-5443), whose animate branch tweens per node with the fit-at-final-positions viewport animation (:5377-5433). grid and preset ignore animate, animateFilter and transform, and never call options.ready / options.stop (grid.mts:71-105, preset.mts:59-126) — while both doc comments claim tween support (grid.mts:65-67, preset.mts:53-55), and those comments ship as d.ts hover text. The animate branch's test coverage never touches grid or preset (test/layouts.mjs:421-475 exercises circle and random).
  4. The debug page forwards animate only for force (debug/layout.js:47-52; the ?layout= load path hardcodes animate: true for force and passes nothing to the rest, debug/init.js:235, :243). Worse, its timing chain .run().promise() (:55-59) throws for all six built-ins — promise() exists on CustomLayout alone (contract.mts, "the builtins' shape plus promise()") — and the throw lands after the synchronous run has applied and emitted, so the page looks right while every non-force Apply click errors uncaught and its console.timeEnd never fires.
  5. ForceLayoutOptions is missing four options the layout accepts (public-types.mts:484-504 vs force.mts:33-66): componentSpacing, init, nestingFactor, gravityCompound — absent from the shipped declaration.

87.1 — packing extracted: src/layout/pack.mts, and the contract learns it

Move computeComponents, estimateComponentRadius, packAnchors and packComponentsExact into a new src/layout/pack.mts, and export shelfPack/PackBox; force-init.mts keeps the seeding and the spectral embedding (force-private, recommended below) and imports the packing. The move is behaviour-neutral and checked the round-42 way: diff the moved function bodies, then run the seeded force suite, which is bit-reproducible on the CPU executor. The extraction finally makes shelfPack testable — unit specs against hand-computed fixtures (area-descending order with the id tie-break, the row wrap at max( widest, sqrt( totalArea ) * 1.25 )).

Then the reusable surface: LayoutContext.packComponents( spacing ) — a translation-only post-pass over the scope: compact the scoped leaves to sim indices the way force does, union-find over the scoped edges, per-component bboxes at current positions, shelfPack, translate members with the largest component's centre as the fixed point (packComponentsExact's contract). Any extension layout gets v3's separateComponents — the layout-utilities packing both flagship apps ship as a bolt-on (the tenth sitting's ecosystem-demand shape) — in one call. The debug harness's SpiralLayout example calls it, so the extension-contract demo demonstrates the helper too. JSDoc with all three gated tags; d.ts regenerated.

Verified by the shelfPack/packAnchors/packComponentsExact unit specs; a contract spec — two disjoint K3s laid out overlapping, ctx.packComponents() separates them (zero bbox overlap, gap >= spacing), with the control skipping the call staying overlapped; the import-graph spec; and the seeded force suite unchanged.

87.2 — the handoff decoupled from animate: always hand off

Executor choice becomes availability-driven; animate becomes what its own doc line already says — "live display" — presentation only. The decision at force.mts:423 drops the animate conjunct: flat + rendered + startForce available → the GPU integrator for both animate values; compounds, headless and no-device keep today's paths, and the CPU sim remains the correctness spec (the force.mts:96-101 stance, unchanged).

The mechanism for a silent run, verified against the code: encode already takes its target buffer as a parameter (renderer.mts:1407-1413), so a non-presenting run integrates into a runtime-owned buffer while draws keep reading the untouched mirror column — the screen holds the pre-run frame, no tween-owned fold, and no per-tick readback (the architecture rule stands). The existing settle path (runGpu, force.mts:522-561) is unchanged in shape: one readback, finishForce, one setPositions through the normal dirty-span upload. Redraw stays held while the runtime lives, since frames drive the dispatches.

The named semantics change: animate: false on a flat rendered graph goes synchronous → async (settle at layoutstop / promise()). A caller reading positions on the next line breaks; the events and the promise are the contract, as they already are for every other force mode. MIGRATING.md, CHANGELOG.md and the option's JSDoc all say so; no sync opt-out spelling (headless is the sync spelling — recorded as Open in case consumers surface).

Verified by an executor-parity spec in the round-65 shape — same seeded graph, CPU settle vs silent-GPU settle agreeing on invariants (component separation, edge-length distribution), not trajectories; a Playwright spec that a silent run shows no intermediate motion — poll-based, never sleep-to-offset: while the runtime is live a screenshot equals the pre-run frame, then positions land at settle — with the control forcing present-mode, which must show motion, and allowance for the first-animate pipeline-compile stall; and a bench row pricing what the round buys — sync-CPU settle wall clock (all of it main-thread) against silent-GPU settle at N — batched with 87.3's layouts.mjs edit so the fingerprint moves once.

87.3 — grid and preset stop lying about animate

Both take the finisher when a handle-demanding option is present: grid's run already branches to its handle path on sort/position/eles (grid.mts:87-94) — animate, animateFilter, transform and the ready/stop callbacks join the condition, and the handle path finishes through eles.layoutPositions (the circle shape) instead of hand-emitting; the slot bulk path stays for the bare call, which is the benchmarked one. Preset likewise — both its forms already produce a position per node, so the finisher can own the animated case (and brings the transform/callback support the hand-rolled path never had). The two doc comments are corrected to the truth in the same commit — they ship as hover text, so today they are shipped defects regardless of which way the code goes.

Verified by grid and preset animate specs extending test/layouts.mjs: animate settles at the same final positions as the discrete write, animateFilter exempts, transform applies, ready/stop fire — with the round-27 control (finisher branch disabled once → red), headlessWidth/headlessHeight set throughout; and the layouts.mjs grid rows unmoved on the compare page rather than eyeballed.

87.4 — the harness forwards animate everywhere, and the promise chain stops throwing

debug/layout.js: the animate checkbox forwards for every named layout (seed stays force-only); the timing chain guards typeof layout.promise === 'function' so the six built-ins stop throwing uncaught on every Apply; the ?layout= load path forwards the toggle instead of hardcoding force's; the checkbox joins paramDefs beside layout/seed so an animate state is linkable; and the label's "(force: hands the integrator to the GPU)" is reworded — after 87.2 it is wrong twice over. The option-assembly moves into debug/fixtures.js-style extractable form only if it stays trivial; either way test/modules/debug-harness.mjs pins what it can, and the page gets opened once per discrete layout with animate on (the "something has to open the page" rule — this item exists because nothing ever had).

87.5 — the force option surface catches up

ForceLayoutOptions gains componentSpacing, init, nestingFactor, gravityCompound with force.mts's doc lines; npm run build:types; the surface audit. One small commit.

Suggested further directions (recorded, not scheduled)

  • Per-component discrete layouts: packComponents: true on circle/concentric/grid — run per component, shelf-pack the boxes. 87.1's module makes it a small round; breadthfirst's interleaved depth rows are the strongest case.
  • Lifecycle unification: one shape for whatever cy.layout() returns — the six built-ins lack promise()/stop() and are not emitters; 87.4 guards around the gap, a round should erase it.
  • Bulk tween for discrete animate: the finisher's animate branch creates one Animation per node (collection.mts:5377-5396) — the wrong shape at 100k; a column-level position tween on the gpu-tween machinery could carry layout transitions. Measure-first.

Risks named at planning

  • 87.2 touches the frame graph — drive debug/ before and after, and the no-motion spec polls a state rather than sleeping to an offset (the frame-driver and compile-stall notes).
  • The extraction is behaviour-neutral only if checked per function (the round-42 rule) — diff the moved bodies, then the seeded suite.
  • grid's bare-call slot path is a benchmark headline: the finisher engages only on demand, and all layouts.mjs edits batch into one commit so the harness fingerprint moves once.
  • The doc-comment corrections and option-type edits sit in stranded-doc-block territory — run the JSDoc gates per commit.
  • Every new parity/no-motion/animate spec runs its control once (round 27); the ones planned are listed with their items.

Open: whether silent-GPU async needs a sync opt-out spelling (recommended: no — headless is the sync spelling); packComponents v1 scope — contract helper only (recommended) vs also an option on the discrete layouts; whether the seeding (seedAroundAnchors, spectralSeed) moves with the packing (recommended: stays force-private); whether the built-ins gain promise()/stop() now or in the unification round (recommended: the 87.4 guard now, the round later).

Landed (2026-08-31, first — the mechanics before round 85's features)

All five items shipped in order 87.5, 87.1, 87.3, 87.2, 87.4. The Open items resolved as recommended: no sync opt-out spelling for the silent GPU run (headless is the sync spelling); packComponents is a contract helper only; the seeding stays force-private; the built-ins got the 87.4 promise() guard now, the lifecycle-unification round later.

  • 87.1src/layout/pack.mts extracted verbatim (round-42 body-diff checked; seeded force suites bit-identical), shelfPack/ PackBox exported and unit-tested for the first time — the first widest-clamp fixture wrapped either way and was replaced with one that discriminates (a control that fails to fail is a finding). LayoutContext.packComponents( spacing = 40 ) landed with the two-K3 separation spec and the SpiralLayout demo calling it; src/layout/pack.mts left PLANNED_PATHS on landing (the documented lifecycle).
  • 87.2 — the gate at force.mts dropped its animate conjunct; a silent run publishes into a runtime-owned slot-capacity scratch buffer (GpuForceRuntime.silentTarget) while draws keep reading the untouched mirror column. The semantics change (rendered flat animate: false sync → async) is recorded in MIGRATING.md, CHANGELOG.md and both option JSDoc sites; the stale "six dispatches" line was corrected (seven named passes + per-level reduce). The 18.4 parity spec became a three-way (headless CPU vs silent GPU vs presenting GPU — a rendered animate:false run is no longer the CPU spelling), and the poll-based no-motion spec landed with its present-mode control and compile-stall allowance. Priced: silent-GPU settle 346 ms vs 25.3 s sync-CPU main-thread at 25k x 50k (render-bench --layout; the frames-delta in-row assertion refuses the row on a device-less fallback) — ~73x, the round's buy.
  • 87.3 — grid and preset route through the finisher on any of sort/position/eles/animate/animateFilter/transform/ready/stop (preset: the last five), the bare calls keeping the synchronous bulk paths; both false doc comments corrected in the same commit; spacingFactor handed to the finisher unset on both (grid applies its own; preset's discrete path ignores it — the two paths must agree). Also surfaced: both had skipped the ready/stop callbacks entirely, not just the tweens.
  • 87.4 — animate forwards for every named layout on Apply and on the ?layout= load path, animate joined the param registry as a live param, the promise chain is guarded in both places, and the label was reworded. The page was opened once per discrete layout with animate on (scripted): every layout tweens (~530 ms readout vs ~1 ms before 87.3) with zero console or page errors.
  • 87.5 — the four missing options joined ForceLayoutOptions with force.mts's own doc lines.

The further directions stand as recorded (per-component discrete layouts over 87.1's module; lifecycle unification; the bulk tween for discrete animate).

Edge layer strokes: the caps, the corners and the arrow reach

The maintainer, driving the page: v4's edge overlays are visibly not v3's in three ways. The stroke ends square where v3's are round; a segments edge can double its translucent stroke over itself at a corner (curved edges look right); and the stroke stops short of the arrowhead, where v3's overlay covers the head. All three reproduce in the code, and each has a distinct mechanism, verified:

  1. The butt caps are a recorded deviation, not an accident. vsEdgeLayer's header says so outright ("v3 strokes overlays solid with round caps — v4 keeps butt caps, a recorded deviation", src/render/shaders.mts:2928-2931), and src/README.md's layer paragraph records the same. v3: drawEdgeOverlayUnderlay sets context.lineCap = 'round' for every edge type except a self edge on the no-paths fallback (v3/src/extensions/renderer/canvas/drawing-edges.mts:183-187), and drawEdge sets lineJoin = 'round' before any layer draws (:129). Round 13 A2 took the cheap quad and logged it; the maintainer has now seen the difference on screen, which is the review the deviation was waiting for.
  2. The doubling is per-quad compositing at a clamped miter. v3 strokes each layer as one path stroked once — Canvas composites a stroke atomically, so a path crossing itself can never darken itself, whatever the opacity. v4's route-family layer strip (vsCurvedLayer, src/render/shaders.mts:3270-3360) emits a quad per polyline step, joined by a miter whose scale is clamped (1.0 / clamp(dot(n, nIn), 0.1666, 1.0), :3346) — past the clamp the strip folds over itself, adjacent quads overlap, and premultiplied alpha blending composites the overlap twice. A hairpin (the length(m) < 1e-4 fallback, :3339) overlaps by construction. The bezier families are immune because their strip extrudes along the shared normal at each t ("watertight without miter joints", the curved shader's header) — which is exactly the "curved seems to work" observation. Taxi rides the same route walk as segments and shares the defect.
  3. The band never reaches the head. Since round 58 both layer VSes span the draw trim — the same shortened span the line itself draws (shaders.mts:2969-2975, :3292-3298) — with butt ends, so the arrowhead sits wholly outside the stroke. v3 spans its shortened path too, but its round cap extends half the stroke width past each end (Canvas cap semantics), which is what paints over the head; and its z-order draws overlay after arrows. v4's z-order already matches (underlay, casing, edges, arrows, then overlay — renderer.mts:1773-1889), so the whole difference is geometric reach, and most or all of it should fall out of the round caps. Measure before adding machinery.
  4. Ledger item 27 is the same surface and still open: v4 strokes the band at width + 2 × padding, v3 at 2 × padding alone. Any cap/reach fix retunes the same scenes and goldens, so the call belongs to this round rather than a later one.

88.1 — round caps on every layer stroke

The straight quad extends by half the stroke width at each end and the fragment stage gains capsule coverage about the span — the dash-cap machinery already computes exactly this shape (capsule distance about the segment, shaders.mts:317-322), so the SDF is reuse, not invention. The curved and route strips extend their end steps the same way. The self-edge exception (v3 butt-caps a self edge only on its no-paths fallback, which path caching makes the rare case) is not copied: v4 rounds self edges too, matching v3's common path. The README deviation sentence and the shader comment are rewritten in the same commit — after this item they would be recording a deviation that no longer exists.

Verified by a close-up parity scene (round 56 rules: short edges, zoom 3-4) with a translucent overlay and underlay — the round-55 lesson applies squarely, an opaque stroke at zoom 1 painted most of this difference over — plus the control run with the cap coverage disabled once, which must jump; the existing edge-layers golden regenerates (exact goldens, so it will move; look at the diff before committing it).

88.2 — the route joins stop double-blending

The observable to pin: uniform alpha along a translucent stroke, corners included, at any corner angle. Candidate mechanisms, to be chosen by measurement in-round:

  • Equal-depth self-rejection (recommended first look): draw the layer pass with depth write on at a per-edge depth so a second fragment of the same edge fails the depth test — self-overlap rejected, cross-edge blending (which v3 also double-blends, correctly) preserved. The trap to check: the pass currently writes no depth (edge-pipeline.mts:154-159) because it must stay under nodes and not occlude later draws — the arrows and labels that follow must be shown unharmed.
  • Watertight join geometry: bevel or round joins built from the cap discs 88.1 adds, replacing the clamped miter — no fold-over, but corner discs overlap their own quads, so this only closes the defect if the coverage math excludes the overlap.
  • Offscreen coverage compositing (last resort — a texture and a pass per layer per frame).

Verified by a close-up parity scene: a segments edge with an acute corner and a translucent overlay, diffed live against v3; a second scene at a near-hairpin angle; controls prove both scenes fail against HEAD before the fix. Count the corners, not the edges — the difference scales with joins, so several bends per edge.

88.3 — the arrow reach, measured then matched

After 88.1 lands, re-diff the arrow scenes live against v3: a round cap reaching (width + 2 × padding) / 2 past the trim may already cover what v3's cap covers. If a residue remains, extend the layer span toward the arrow tip to whatever distance the parity probe says v3 actually paints — measured through playwright-page/parity.html, not inferred from v3's source — and only then consider an arrow-quad layer pass, which nothing verified so far suggests v3 has. The scene that decides it uses large heads and a small padding, the configuration where the cap cannot reach the tip.

Verified by the live parity diff on an arrows + overlay scene (hollow heads, per round 56 — filled heads paint the difference over), with the numbers recorded in this round's record.

88.4 — ledger item 27 decided

The call on the band width: keep v4's width + 2 × padding (recommended — the halo is always visible, and the formula matches the node overlay's own semantics; v3's 2 × padding renders an invisible halo whenever padding is under half the line width, which reads as a bug, not a look), recorded as a deliberate deviation in src/README.md and MIGRATING.md, and the item closed. The alternative — match v3 for pixel parity — stays one line in the record; flipping it later is a constant. Whichever way, the parity scenes above must tolerate the band-width difference explicitly (mask or match the padding), so a bound failure means caps/joins/reach and never the recorded formula.

Risks named at planning

  • Every item changes rendered output: the edge-layers golden and any layer-adjacent goldens regenerate (exact since 57.1e — read the diffs, then commit the PNGs), and the affected parity bounds retune downward, never up.
  • Shader edits follow the WGSL rules: tagged literals, no interpolation inside comments; the minify transform runs in dev, so Playwright exercises what ships.
  • 88.2's depth-write candidate touches the frame graph's occlusion assumptions — drive debug/ before and after (?network=edge-types and edge-arrows, selecting edges to light the overlay machinery), per code standard 5.
  • Every new parity scene runs its control once (round 27); each item lists its control with the scene.
  • The straight, curved and route families each have their own layer VS — a cap fixed in one and not the others is exactly the kind of partial fix a zoom-1 scene would pass; the close-up scenes cover all three families.

Open: whether 88.2 lands equal-depth or geometry (decide by measurement, not preference); whether v3's overlay reach needs any machinery beyond the round caps (88.3 measures before building); whether the self-edge cap exception is worth a recorded note (recommended: one sentence in the README, no code).

Pointer cursors: the canvas says what a gesture will do

The maintainer's ask: the pointer should carry the standard affordances — a grab/grabbing pair around panning, something over a hoverable element — instead of staying the browser default through every gesture. What the code does today, verified:

  1. Nothing sets a CSS cursor, in either library. v4's renderer writes exactly five inline styles on its canvas — position, size, display (src/render/renderer.mts:326-332) — and no file under src/ or v3/src/ touches style.cursor (grep; v3's only "cursor" hits are prose and the WebGL pick comments). v3 left cursors to userland, and the standard pattern there is a mouseover/mouseout pair writing container.style.cursor. v4's canvas fills the container, so an inline canvas cursor overrides exactly that pattern — which is the constraint the defaults below are shaped around.
  2. Every state the cursor depends on is already tracked in one object. PointerHandler owns hover (updateHover, src/interact/pointer.mts:1586, fed by the throttled async pick at :1563 and cleared on pointerleave at :195), the press mode 'pan' | 'grab' | 'box' decided once at pointerdown (:405), the tap-threshold moved flip (:588), and the draggability predicate (canDrag, :471 — grabbable, unlocked, not pannable, not animating). A cursor writer piggybacks on transitions the handler already makes: no new pick passes, no new listeners, no keydown tracking.
  3. Hover is asynchronous and throttled (25 ms + a pick round-trip; paused during wheel with the settle re-pick at :256, and never run during a pan drag) — so a hover cursor carries exactly the latency mouseover already has, and the pan/drag cursors must key off the press, never off hover.
  4. A captured drag outlives the canvas. Drags run under setPointerCapture (:1660), and capture routes events, not the cursor: while the pointer is physically outside the canvas the browser shows whatever the element underneath declares, so a mid-drag excursion past the container edge drops the grabbing affordance unless something writes at document level.
  5. The option surface has an obvious seat. CytoscapeOptions carries the interaction knobs — userPanningEnabled, boxSelectionEnabled, selectionType, the round-20.1 tuning quartet (src/public-types.mts:610-667) — each with a runtime getter/setter on the core. A cursor option lands beside them.

89.1 — the cursor map and its single writer

New src/interact/cursor.mts: a pure cursorFor( state ): string over { gesture: 'idle' | 'pan' | 'grab' | 'box', hover: 'none' | 'element' | 'draggable-node', pointerType }, returning a CSS cursor keyword or '' for inherit. The default map:

  • idle over background: '' (inherit) — deliberately not default or grab: it leaves the app's own container cursor in force, so the v3 userland pattern keeps working wherever v4 has nothing to say;
  • hover over any interactive element: pointer;
  • hover over a node canDrag accepts: grab;
  • an active pan- or grab-mode press: grabbing, from the pointerdown itself rather than the threshold flip — immediate feedback, and a tap restores within the click so nothing reads as flicker;
  • a box-mode press: crosshair (press-time only — the mode is decided at pointerdown, and the handler reads modifiers off pointer events alone, so there is no pre-press armed state to show);
  • pointerType === 'touch': never writes — there is no cursor to show, and a synthetic write would stick after the finger lifts.

PointerHandler applies it at exactly the transitions it already owns — updateHover, pointerdown, pointerup/pointercancel, the gesture-cancel paths (:946, :1017, :1214), destroy — through one applyCursor() that writes canvas.style.cursor only when the computed value changes and restores '' on destroy (destroy runs on the device-loss re-mount, round 10, so nothing leaks across mounts). For fact 4, an active pan/grab/box drag also mirrors the cursor onto the canvas's ownerDocument.documentElement, cleared on every release path — that is what keeps the affordance honest when a captured drag leaves the canvas. A hover pick resolving after destroy must be tolerated the way updateHover already tolerates a removed element.

Verified by Node unit specs over the pure map — every gesture × hover × pointerType cell asserted, plus the canDrag-driven distinctions (a locked, ungrabified or animating node hovers as pointer, not grab) — with the round-27 control run once (the map keyed to return '' unconditionally → the file must go red). The DOM writes are 89.3's: headless has no canvas, so the Node tier can only see the pure half — that split is the design, not a gap.

89.2 — the option: pointerCursors

CytoscapeOptions.pointerCursors?: boolean | Partial<CursorMap> — default true; false means the writer never touches the DOM; an object overrides individual entries ({ pan: 'move' }, { hoverNode: 'pointer' }), with '' meaning inherit so any single state can be handed back to the app. Runtime cy.pointerCursors() getter/setter beside its sibling toggles. JSDoc with all three gated tags; npm run build:types and the surface audit; the compile-only consumer test exercises both option shapes. MIGRATING.md gains the one-line compat note — v3 never set cursors, so an app whose own mouseover/mouseout cursor code now fights the default passes false — and CHANGELOG.md and src/README.md's interaction section record the feature.

Verified by the types tier and 89.3's runtime flip spec; the docs edits ride the JSDoc gates (stranded-block territory — run them per commit).

89.3 — the gestures drive it, in a real browser

Playwright specs in the renderer project, every assertion poll-based on canvas.style.cursor (hover is async + throttled — the house rule against sleep-to-offset applies verbatim):

  • hover a node → grab; after cy.autoungrabify( true ) the same hover reads pointer; hover an edge → pointer; leave the canvas → '';
  • background press-drag → grabbing from the press, '' on release; a node drag likewise, including a release outside the canvas under pointer capture — which is the spec that proves the document-level mirror restores;
  • a multiple-select-key drag → crosshair, restored at boxend;
  • pointercancel mid-pan → restored (the cancel paths are where a sticky grabbing would live);
  • pointerCursors: false → all of the above read '' throughout — the suite's own control, since with the feature off every positive assertion must invert.

Node hovers ride the sync CPU pick, so none of this needs more than the adapter the renderer project already has.

89.4 — the harness shows it

Drive debug/ (npm run watch) through pan, node hover/drag and box select on ?network=em-web and ?network=v3-default — the "something has to open the page" rule; this feature is only observable by a person or a browser spec. A pointerCursors checkbox joins the core-toggles section so the page can demonstrate both states, pinned by test/modules/debug-harness.mjs where practical.

Suggested further directions (recorded, not scheduled)

  • A cursor style property in the sheet DSL (node.clickable { cursor: pointer }) — the CSS-shaped end state: per-selector cursors read at hover time. Needs a dictionary column and a contract change; the state map covers the common cases without either. Revisit if consumers ask.
  • A cxt-gesture cursor (context-menu during an active cxt press) — cheap once the writer exists; skipped in v1 because v3 suppresses the browser menu and no app expectation exists.
  • A busy cursor for long synchronous work (progress during a sync layout) — recorded to decline it deliberately: 87.2 removes the largest sync stall, and a cursor that says "wait" is the wrong fix for work that should not block.

Risks named at planning

  • The inline canvas cursor overrides the app-container pattern — the '' idle default and the false opt-out are the mitigations, and MIGRATING.md names the behaviour. Do not "improve" idle to default: that is the compat-breaking spelling.
  • Every gesture-end path must restore — pointerup, pointercancel, the touch/pinch cancels, destroy. The cancel spec exists because that is where a sticky cursor hides.
  • The document-level mirror touches state outside the container; it ships only with the release-outside-canvas spec proving every path restores it, or it drops to canvas-only (Open below).
  • A hover pick's promise can resolve after unmount; the writer must tolerate it (the existing updateHover shape).
  • All specs poll; none sleeps to an offset. Any spec that also animates inherits the frame-driver and compile-stall notes.

Open: whether idle-over-background stays '' (recommended) or becomes grab when panning is enabled — the map-app affordance; one map entry either way, flippable later; whether a draggable node hovers as grab (recommended) or uniformly pointer; whether the mid-drag document-level mirror ships in v1 (recommended: yes — it is what makes release-outside-canvas honest) or the writer stays canvas-only; whether pointerCursors needs the runtime setter (recommended: yes, matching every sibling toggle).

Landed (2026-08-27)

The canvas carries the affordances, every gesture-end path puts them back, and pointerCursors turns the whole thing off. The plan's five verified facts were re-checked before starting and all five held — including fact 1, that nothing in either library sets style.cursor, which a grep confirmed still true. Every "Open" was taken as recommended. Three things the plan did not have right are recorded below; two of them changed the code.

89.1 — the map. src/interact/cursor.mts is pure: cursorFor( { gesture, hover, pointerType }, setting ) and a DEFAULT_CURSORS constant, no DOM. That is what makes every cell of gesture x hover x pointer type assertable from Node, and it is why the browser tier can then test the writes rather than the decisions behind them. CursorState and CursorMap live in public-types.mts beside BoxSelectionMode, so export type * carries them to consumers.

89.1 — the writer, and the plan's one wrong instruction. The plan enumerated seven transitions for PointerHandler to hook. It hooks the DOM-listener wrapper instead — the same finally that clears originalEvent (41.4) — plus updateHover, which resolves asynchronously outside that wrapper, plus destroy.

The reason is the plan's own risk note. this.down is cleared in six places, and three of them are touch paths the plan's list does not name: beginPinch, beginTouchCxt and the three-finger touchBoxMove. Each would have been a live sticky-grabbing path on a hybrid device, and each is the kind of branch an enumeration gets wrong quietly. Deriving the cursor after every DOM handler is a superset that cannot miss one, and it costs a string compare per event against handlers that already pick and emit. The plan's enumeration was not wrong about those seven — it was wrong that seven was the count.

The document mirror ships, and the release-outside-canvas spec does not. The plan made the mirror conditional on that spec ("it ships only with the release-outside-canvas spec proving every path restores it, or it drops to canvas-only"). playwright-page's container fills the viewport, so there is no outside to release into without resizing the shared page — round 91's territory, and not a change to make from here. The condition is met a different way, and the substitution is recorded rather than glossed: the browser suite asserts the mirror (documentElement reads grabbing mid-drag, '' at every release, on pan, grab, box and cancel), and test/pointer-cursors.mjs asserts the half a full-viewport page cannot — that the mirror saves and restores the page's own root cursor rather than clobbering it, with a wait planted before the drag and read back after. The mirror is the mechanism the excursion depends on; both halves of it are pinned.

89.2 — the option. pointerCursors?: boolean | Partial<CursorMap>, default true, with cy.pointerCursors() in the getter/setter shape every sibling toggle uses. No validation and no throw: a typo'd map key is a no-op, and the constructor's stated design (fifth sitting) is that option strictness resolves at the type layer. One thing the plan did not think of: a runtime flip has to reach the canvas immediately, or turning cursors off while one reads grab leaves it there until the next pointer event. The setter calls the handler's applyCursor(), and Core._pointer's structural type grew the method for it. It is not in cy.json(), matching every other v4-only option.

89.3 — the browser tier, and three things the specs had to be taught, each a first draft that passed or hung for the wrong reason:

  • A gesture moves its own target. A pan moves the viewport and a node drag moves the node, so a second gesture aimed at a remembered coordinate misses. Two specs failed on exactly that before at() started asking the element where it is now.
  • The last move of a steps batch is routinely dropped. Hover picking is throttled to 25 ms and latest-wins, so moving once and then polling the cursor waits for a pick nothing will ever ask for — two more specs failed here. hoverOnto nudges inside the poll, which is state-driven, not a sleep-to-offset.
  • Waiting on the hover is what makes the control honest. With pointerCursors: false a poll for '' passes instantly and proves nothing. Waiting for the pick to land first turns each assertion into "the hover really happened, and nothing was written anyway" — testing.md's rule about asserting the precondition when the end state satisfies the predicate on its own.

Controls, three of them, all landing:

control result
cursorFor keyed to return '' unconditionally 10 of 18 Node specs red
the map intact, only the DOM write neutered 6 of 18 Node specs red
applyCursor() made a no-op all 6 browser specs red

The first two failing for different reasons is the point: the two tiers fail for their own reasons rather than each other's. The debug-harness gate has its own pair — mistype the checkbox id in index.html, rename the member in toggles.js, one each.

89.4 — the harness, driven. A pointerCursors checkbox joins Core toggles, and the page was driven in a scripted browser on ?network=em-web — the rule this round could not have satisfied any other way, since the feature is observable only by a person or a browser:

at rest          canvas ''         root ''
hovering a node  canvas 'grab'     root ''
pressed, drag    canvas 'grabbing' root 'grabbing'
released         canvas 'grab'     root ''
checkbox off     canvas ''         setting false
hover, off       canvas ''         setting false

One row looked like a defect for a moment and is not: re-checking the box reads '', not grab, because clicking a side-panel control moves the pointer off the canvas, pointerleave clears the hover, and idle-over-nothing is the right answer.

test/modules/debug-harness.mjs gained a gate for the failure mode a new control has, which is silence: boolControl returns early when its selector matches nothing, so a typo'd id is a checkbox that never appears and never fires. Two text checks — every selector literal toggles.js names exists in index.html, and every core member its getters call exists on a real instance.

scripts/status/markdown.mjs dropped src/interact/cursor.mts from PLANNED_PATHS, which is that list's designed lifecycle: a planned round names a file before it exists, the round lands, and the "no exempt spelling resolves" spec goes red until the entry is removed. It went red on this round's first test:modules run.

Verification. npm run -s test:node:quiet green (2260 Node specs); npx playwright test --project=renderer green; npm run build:types regenerated dist/cytoscape.d.ts — the docs gate fails on a documented member the declaration does not carry, which is how the first run caught that it had not been rebuilt.

Recorded, not scheduled (unchanged from the plan): a cursor style property in the sheet DSL is the CSS-shaped end state and needs a dictionary column plus a contract change; a cxt-gesture cursor is cheap once the writer exists and waits on an app expectation; a progress cursor for long synchronous work is declined — 87.2 removed the largest sync stall, and a cursor that says "wait" is the wrong fix for work that should not block.

API review and cleanup: the parity pass audited

The early rounds went aggressively for v3 parity as a first pass, and that was the right call then — the foundation needed to exist before it could be judged. The bill for it is that v4's public tier now carries members that were private in v3 (instanceString) and members v3 itself had marked backwards-compat/deprecated (forceRender, batchData). This round is the audit; the findings below are proposals, not decisions. The maintainer reviews and approves every proposed amendment before any of it lands — nothing in this round is pre-approved, including the "obvious" removals.

Method (so the numbers are reproducible, not vibes): the v4 surface is the 460 public members over the ten PUBLIC_API files, as enumerated by scripts/jsdoc-coverage.mjs's own scanner — not a throwaway regex, per round 33.12's rule. The v3 documented surface is 703 names extracted from v3/documentation/docmaker.json: fn.name plus every formats[].name and pureAliases entry — the first extraction read fn.name only (342 names) and falsely flagged half of collection.mts, because v3 documents cy.elements inside the cy.$ entry's formats and the degree family inside node.degree's. The v3 privacy signal is the 112 member names carrying /** @internal */ in v3/src. Diffing: 164 of the 460 carry names absent from v3's docs, and 42 of those carry names v3 tags @internal. Caveat the tables must carry: the match is by bare name, so a v4 member can collide with a differently-owned v3 name (two known: LayoutContext.options is deliberate layout-author surface, and StyleEngine.removeBypass is round 63's bypass API, not v3's element-private of the same name).

The findings, in four classes:

  1. v3-internal, now public (42 names). The headline cases, each verified in v3's source: cy.forceRender() (core.mts:2001) is @internal in v3 (v3/src/core/renderer.mts:46) and is exactly the deprecated-in-v3 shape this round exists for; cy.batchData() (core.mts:665) is @internal and commented "for backwards compatibility" (v3/src/core/notification.mts: 117-118, 151) — legacy twice over; and instanceString() ships on Core (core.mts:2593), Collection (collection.mts:487) and Event (event.mts:149), was never documented in v3 (it fed v3's is-checks), and has zero callers in v4 — three definitions, no use, pure parity baggage. The rest of core's 18: batching, zoomRange, getFitViewport, getCenterPan, renderer, onRender/offRender, multiClickDebounceTime, isReady, headless, styleEnabled, hasCompoundNodes, hasElementWithId, mutableElements, window, options. Collection's 21: renderer, element, hasElementWithId, indexOf/indexOfId, byGroup, silentPosition(s), silentShift, takesUpSpace, interactive, padding, paddedWidth/paddedHeight, isBundledBezier, show/hide, inactive/activate/unactivate, boundingBoxAt. Not all 42 should go: some are useful and arguably better public (hasElementWithId, show/hide, the silent* family), and some are internally load-bearing so they can demote but not vanish (boundingBoxAt is layoutPositions' bounds source, collection.mts:5405; getFitViewport/getCenterPan back animate()'s fit/center). Each gets a table row with a proposed disposition — remove / demote to internal / keep, documented as deliberate — for the maintainer to rule on.
  2. Machinery classes riding the public tier. cy.style() returns the whole StyleEngine (core.mts:373), and 28 of its 31 public members are engine surface — readProp/readProps, applyAll/applyBulk, paintInputs/paintContext, setGpuOwned, transitionSink, refreshMapped/refreshState, onCompacted, arrowEnds/arrowBase/arrowWidthModes… — with the consumer-intended handful (setSheet, round 63's bypass trio, update) undistinguished from the plumbing. animation.mts is the same shape: 39 non-v3 names, nearly all the GPU tween driver (buildChannelWrite, tick, schedule, settleGpu/demoteGpu, repairRefs, gpuBatches, attachDriver/detachDriver, settleGpuAll/demoteGpuAll), while the Animation handle API a consumer holds matched v3 cleanly. Viewport (viewport.mts:39) publishes 9 members that duplicate cy's viewport surface under other spellings (setZoom, setPan, fitViewport, centerPan, modelToRendered…), and nothing hands a consumer one — cy._viewport is private — so the class may belong in the internal tier outright. The root cause is structural: PUBLIC_API is a file list (scripts/jsdoc-coverage.mjs:26), so the tier boundary is file-grained where the truth is member-grained. The renderer calls these members cross-module, so TS private cannot mark them; the fix needs a marker the audits, the docs generator and the d.ts build all respect (@internal support, or the _ prefix the scanner already skips) — that mechanism choice is itself a maintainer decision.
  3. Verified-deliberate v4 additions — no action, listed so the next reader knows they were checked: the algorithm tier (rounds 65/69/70: katzCentrality through motifCensus), the wire/columnar surface (serialize, serializeElements/ deserializeElements/isSerializedElements, the four columnar exports), compact/gc (rounds 19/39.3), pick, elementsInBox (in no form in v3), boxSelectionMode (39.1), boxSelectionIncludesLabels (16.5), the interaction tuning quartet (20.1), labelBoundingBox, the LayoutContext/ CustomLayout extension surface (round 71's substrate), and the Event field surface (type, originalEvent, timeStamp, preventDefault, stopPropagation — v3's event shape, documented there in prose rather than as fns).
  4. Discussion, not caught by the diff because v3 documents them: the jQuery-era listener aliases bind/unbind/ removeListener/pon (core.mts:1427,1520,1522,1577 and the Collection equivalents). Era cruft by the same argument as forceRender, but documented v3 API — one keep-or-drop decision covers the family.

Mechanics once dispositions are approved: anything removed or renamed is a breaking-surface change — MIGRATING.md and CHANGELOG.md rows, dist/cytoscape.d.ts regenerated, a src/README.md sweep (its introspection bullet at line ~575 names several of the class-1 members), alias-table updates where an alias dies, and the coverage gates re-run (removals shrink the 460 and the @param/@returns/ @throws tallies with it). Guarded removals (a throwing stub, if any member gets one) join the throw gate with a spec.

Open: the privacy-marker mechanism (@internal across scanner + docs generator + d.ts build, vs _ renames, vs facade splits for StyleEngine/AnimationManager — recommended: @internal, it leaves call sites untouched); whether show/hide and the silent* family stay public (recommended: yes, with docs saying they deviate from v3's privacy deliberately); whether Viewport leaves the public tier entirely (recommended: yes, absent a consumer path that holds one); and the alias-family question in class 4.

Round 90 — the rulings, and the plan restated as passes (maintainer, 2026-08-24)

The maintainer reviewed the audit and ruled on the headline items; the round is approved to land under the structure below. Three of the plan's own recommendations were overruled, which is the audit working as designed — the tables were proposals. The rulings:

  1. The privacy mechanism is @internal (the plan's recommendation, confirmed). Member-grained, and three consumers must respect it, none of which do today (verified — the string appears nowhere in scripts/): the jsdoc-coverage scanner moves a tagged member from the public tier to the internal tier (it still requires a doc comment — internal means hidden from consumers, not undocumented — and the @param/@returns/@throws gates stop applying, since those exist for shipped hover text); the docs generator omits tagged members; and the d.ts build strips them from dist/cytoscape.d.ts (rolldown-plugin-dts carries doc blocks through, so the tag is visible in the declaration text and a post-pass in scripts/build-dts.mjs can remove block + declaration). Each consumer gets a spec with a control: tag a member, assert it vanishes from that consumer's output; untag, assert it returns.
  2. The seven flagged members, ruled member by member: forceRender, onRender/offRender and mutableElements are removed (onRender was pure sugar for on('render', …); mutableElements is elements() by another name — v4 has no immutable collections, so the name describes a v3 distinction that no longer exists); instanceString (Core, Collection, Event) is demoted to @internal rather than removed — zero callers in src/, but a benchmark row and the decided-drops sweep call it, and demotion keeps them at zero migration cost (maintainer, amending the initial remove ruling); silentPosition(s) and silentShift are demoted to @internal — the plan's keep-public recommendation is overruled; the layout path is ctx.setPositions/layoutPositions and consumers should not see the silent family (zero src/ callers, verified); and cy.renderer() (and Collection.renderer()) are demoted to @internal, with the stats story resolved by option (b): a new typed cy.stats() passthrough (RendererStats | null, null when headless) so the one documented consumer surface that lived behind renderer() survives the demotion. The debug page's stats overlay moves to cy.stats() with it.
  3. The events API is ruled by one principle: analogous to Node's EventEmitter, plus pon. So addListener/removeListener stay (they are Node's own spellings), pon stays (the maintainer likes it), bind/unbind are removed, and listen/unlisten — which the plan's class-4 list did not even name — are removed by the same principle, being neither Node's spellings nor load-bearing anywhere in this repo. One correction from implementation: the plan said once would join as an alias of one — measurement says it has been there all along (core.mts/collection.mts both declare it), so the ruling's Node-emitter set was already complete and nothing was added.
  4. Class 2 lands as recommended: the StyleEngine machinery (~28 members — readProp/readProps, applyAll/applyBulk, paintInputs/paintContext, setGpuOwned, refreshMapped/ refreshState, onCompacted, the arrow/opacity readers, …) is tagged @internal, keeping public the consumer handful: setSheet, json, update, and round 63's bypass surface. AnimationManager's GPU-driver members are tagged the same way; the Animation handle a consumer holds is untouched. Viewport leaves the public tier entirely — nothing hands a consumer one (cy._viewport is private, index.mts exports nothing viewport-shaped) — by dropping src/viewport.mts from PUBLIC_API and stripping the class from the declaration.
  5. The remaining class-1 table, delegated to the round with the leanings endorsed: keep, documented as deliberatezoomRange, multiClickDebounceTime, isReady, headless, styleEnabled, hasCompoundNodes, hasElementWithId (both owners), window, options, indexOf/indexOfId, takesUpSpace, interactive, padding, paddedWidth/paddedHeight, show/hide; demote to @internalbatching, getFitViewport/getCenterPan (they back animate()'s fit/center), element, byGroup, isBundledBezier, inactive/activate/unactivate, boundingBoxAt (layoutPositions' bounds source); removebatchData (@internal and "for backwards compatibility" in v3 — legacy twice over, and data() covers it).

Two insights from the pre-implementation verification, recorded so the implementation sweeps for them:

  • src/animation.mts:690 has a runtime error message that advises onRender by name — the exact defect class round 31 existed for (an error advising a removed form), found before landing this time. The close therefore includes a dead-name sweep over runtime strings and doc comments, not only over markdown.
  • benchmark/surface.mjs has rows calling mutableElements and the silent family — the mutableElements row dies with the member and the silent rows survive demotion; either way the suite file changes, so its harness fingerprint moves, and all benchmark edits land batched in one commit (the round-68/72 rule).

The passes: 90.0 the @internal mechanism across the three consumers, specs with controls, built first because every demotion rides on it; 90.1 the seven ruled members (removals, demotions, cy.stats()); 90.2 the machinery classes and Viewport; 90.3 the remaining class-1 table as ruled above; 90.4 the event-alias family (bind/unbind/listen/unlisten out; once was already present); 90.5 the close — MIGRATING/CHANGELOG rows per removal, dist/cytoscape.d.ts regenerated, the alias table and src/README.md swept, the dead-name sweep, coverage gates re-tallied (the public tier shrinks by design), quiet gates green, and EXECUTIVE_SUMMARY.md rewritten from this file.

Landed

The parity pass audited, ruled on (the 2026-08-24 rulings above), and landed the same day. The public tier went 460 → 355 members (dist/cytoscape.d.ts 9,695 → 8,387 lines), with every removal and demotion recorded in MIGRATING/CHANGELOG and the gates re-tallied (@param 205/205, @returns 254/254 — the ratchet moved down with the surface, which a demotion does by design — @throws 17/17, both tiers at 100%).

90.0 — the @internal mechanism. One tag, three consumers, each with a fixture spec and a control (test/modules/internal-tag.mjs): the scanner (INTERNAL_RE; member-grained tier move in audit(), the three tag gates stop at the boundary), the docs generator (tagged members and constructors no longer publish), and the d.ts build — where implementation found the mechanism half-shipped already: tsconfig.dts.json has carried stripInternal: true since the dts build existed, and oxc strips a tagged declaration itself. The stripInternal() post-pass in scripts/build-dts.mjs stays as the belt-and-braces layer. Two findings for the next tagger, both pinned: oxc honors the tag only as a standalone doc-comment tag line — an inline — @internal suffix is invisible to it while this repo's scanner matched it, exactly the tool-divergence the spec now checks by source location — and a tag can cascade: stripping the last reference to a class tree-shakes the class out of the declaration (how Animation left, correctly; and why Viewport could not — see 90.2).

90.1 — the seven ruled members. forceRender, onRender/offRender, mutableElements removed; instanceString (×3), the silent position family and renderer() (both owners) demoted; cy.stats() added (RendererStats | null) as the public frame-stats snapshot, and debug/init.js's overlay moved to it. The round-31-class defect found in planning — animation.mts's step rejection advising onRender by name — fixed with the member, and the close's dead-name sweep over src/ runtime strings and doc comments reads clean.

90.2 — the machinery classes. StyleEngine: 23 members tagged, the consumer handful (setSheet, json, update, setBypass/ removeBypass/hasBypasses) stays. The animation tier's demotion went further than planned, truthfully: a consumer holds the AnimationHandle interface — cy.animation()/eles.animation() return it — and never the Animation class, so the whole class is machinery; with every member tagged, Animation and AnimationHandleImpl left the declaration entirely, the docs ani namespace now reads from AnimationHandleImpl (shipping as the interface — DTS_NAMES maps the spelling for the cross-check), and Animation joined NOT_PUBLISHED with its reason. Viewport left PUBLIC_API and ships as an opaque shell — the class name must remain because Core._viewport is a value-import rolldown cannot drop, so every member is tagged instead, and Core's zoom/extent/ renderedExtent signatures moved off Viewport['…']-derived types onto the exported ZoomOptions/Extent so the demotion cannot re-couple them.

90.3 — the delegated table. Kept public, each carrying a "Public in v4 by decision (round 90)" doc note: zoomRange, multiClickDebounceTime, isReady, headless, styleEnabled, hasCompoundNodes, hasElementWithId (both owners), window, options, indexOf/indexOfId, takesUpSpace, interactive, padding, paddedWidth/paddedHeight, show/hide. Demoted: batching, getFitViewport/getCenterPan, element, byGroup, isBundledBezier, inactive/activate/unactivate, boundingBoxAt. Removed: batchData, with its spec rewritten to pin the tombstone and the batch()-over-data() idiom.

90.4 — the alias family. The ruling's one principle — Node's EventEmitter, plus pon — kept addListener/removeListener, removed bind/unbind and (same principle, unlisted in the audit) listen/unlisten: declares, prototype wiring and alias-table rows on both classes. The plan's "once joins as an alias" was corrected by measurement: once has been one's declared alias all along.

90.5 — the close. All four benchmark-row edits in one commit (surface.mjs's fingerprint moves once; the removed members' rows became pair() rows pricing the surviving idiom against v3's helper spelling), MIGRATING/CHANGELOG rows, the src/README.md sweep, dist/cytoscape.d.ts regenerated through the strip, and the quiet gates green end to end.

Left open, deliberately: the underscore-field leak — _renderer, _animations and kin are emitted into the declaration as typed public fields (pre-existing, out of this round's written scope; _viewport is the one the Viewport work touched). A follow-up can tag or private-ify the family as one sweep.

The maintainer's screen pass

Seven defects from driving the debug harness, one round each. Every mechanism below was verified before planning — reproduced in a served page against the built UMD bundle (the round-27.9 rule: probe from a served page, never about:blank), with the v3-vs-v4 cases diffed live on playwright-page/parity.html. The repro scripts are throwaway; what each round keeps is the pinned mechanism and the spec that will hold it. Performance trade-offs are named per round — most of these sit on hot paths (the frame loop, the whole-graph fit scan, the per-vertex curve evaluation, the glyph atlas, the pick path), so each fix carries its measurement, not just its scene.

Resize without distortion

The maintainer, resizing the debug page: the network view stretches or squishes with the window. What the code does today, verified:

  1. The steady state is already correct. Measured on a served page: an 800×600 container resized to 1300×500 leaves the backing store at exactly 1300×500, circles round, zoom and pan untouched — the ResizeObserver (src/render/renderer.mts:356-360) calls resize()applySize() (renderer.mts:2407-2415), which sizes canvas.width/height from container.clientWidth × dpr. So the distortion is transient, not steady-state — but during a live window drag "transient" is every frame of the drag.
  2. The stretch mechanism is the 100% canvas CSS plus a frame-late redraw. v4's canvas is styled width/height: 100% (renderer.mts:330-331), so the compositor scales whatever was last presented to the new CSS size immediately; the redraw that would correct it is schedule()d to the next rAF (renderer.mts:402-410, :1236-1247), and ResizeObserver callbacks run after this frame's rAF in the event-loop rendering steps — so every resize step composites at least one frame of stale-content-stretched-to-new-size. A continuous drag is a continuous rubber-band.
  3. v3 is structurally immune, and not by redrawing faster. v3 sizes its canvases in fixed CSS px, written only inside the synchronous redraw (canvas.style.width = width + 'px', v3/src/extensions/renderer/canvas/drawing-redraw.mts:219-239), and its resize handler is debounced 100 ms (v3/src/extensions/renderer/base/load-listeners.mts:326-343). During a drag a v3 canvas is momentarily the wrong size (letterboxed), never the wrong shape — stale coverage reads as lag; stale stretch reads as the graph deforming.
  4. dpr is frozen at construction (renderer.mts:319-322) where v3 reads devicePixelRatio live per redraw (drawing-redraw.mts:12). A browser-zoom change or a move to a different-density monitor leaves v4 rasterizing at the stale ratio — uniform blur rather than stretch, but the same round owns it: applySize is the one consumer. Note browser zoom does not change clientWidth (CSS px), so the ResizeObserver alone will not catch it — the standard hook is a matchMedia('(resolution: …)') listener re-armed per change.
  5. cy.resize() is public (src/core.mts:2008-2011) and the debug container is a plain absolutely-positioned box (debug/style.css:2), so nothing harness-side contributes.

91.1 — the canvas presents at the size it was drawn

Two candidate shapes, decided by driving the page, cheapest first:

  • Synchronous frame in resize() (recommended first look): call frame() directly after applySize() instead of schedule()ing. ResizeObserver runs before paint in the same rendering update, so the frame that composites the new layout composites new content — the stretched frame never exists. Cost: nothing new per frame (RO fires at most once per frame); the care point is re-entrancy (a frame() mid-rAF-chain must not double-tick animations — the clock is performance.now()-driven, but assert it).
  • v3's fixed-px canvas CSS, written in applySize: even when a frame is late, a wrongly-sized canvas letterboxes rather than distorts. Belt-and-braces with the above; costs a style write per actual size change.

Verified by a Playwright spec that resizes the container and asserts, without waiting extra frames, that the presented pixels are undistorted — render one filled circle, resize, screenshot on the next compositor frame, assert the ink's width/height ratio (the steady-state half is cheap; the transient half is the reason the spec exists, and if the harness cannot observe a single compositor frame deterministically, the spec asserts the synchronous-path invariant instead: after resize() returns, the canvas has already presented at the new size). Plus the standing rule: drive debug/ and drag the window edge, before and after.

91.2 — live pixel ratio

applySize re-reads devicePixelRatio when opts.pixelRatio is 'auto'/absent (the constructor keeps honouring an explicit number), and a matchMedia resolution listener — re-armed per change, removed on destroy — triggers resize() so browser zoom re-rasterizes. The scene/depth/pick targets already rebuild off canvas size, and the label thresholds already scale by dpr; the sweep is checking the few places that cached this.dpr at init.

Verified by a spec that flips a mocked devicePixelRatio (CDP Emulation.setDeviceMetricsOverride in the renderer project) and asserts the backing store follows; control: pin pixelRatio: 1 and assert it does not.

Risks named at planning

  • A synchronous frame() from the RO callback runs GPU submits inside the rendering steps — Dawn is fine with it, but the frame must tolerate canvas.width === 0 (already guarded, renderer.mts:1263) and a destroyed renderer racing a late RO fire (already guarded, :403).
  • Goldens and parity scenes never resize, so none should move; if one does, something leaked into the steady state.
  • The debounce question: v3 debounced 100 ms to keep canvas reallocation off the drag's critical path. v4 reallocates no canvas DOM (one canvas, width/height writes) and the swapchain resize is the browser's own; if profiling shows reallocation churn in ensureSceneTarget during drags (a texture rebuild per step), the fix is a short settle for the offscreen targets only — never for the presented size, which is what must track the drag.

Open: whether 91.1 ships both halves or the synchronous frame alone (recommended: both — the fixed-px CSS also covers the no-ResizeObserver fallback path, where today nothing resizes at all); whether a devicePixelRatio change should also emit resize on the core (recommended: yes — v3's cy.resize() semantics).

Landed (2026-08-28)

Landed as planned, both opens taken as recommended: 91.1 ships both halves (the synchronous frame and v3's fixed-px canvas CSS), and a device-pixel-ratio change emits resize on the core — v3's cy.resize() semantics.

91.1, as shipped. resize() calls frame() directly after applySize() whenever the renderer is ready: ResizeObserver callbacks run after this rendering update's rAF and before its paint, so the frame that composites the new layout composites new content — the stretched frame never exists. The named re-entrancy risk resolved two ways: the animation clock is performance.now()-driven, so an extra tick inside the same rendering update advances no tween beyond wall clock; and the one true recursion — a cy.resize() called from inside a render handler — is cut by an inFrame latch that falls back to the scheduler (frame() became a latch wrapper over frameBody()). applySize now writes the canvas CSS box in fixed px (clientWidth/clientHeight), so whenever a frame is late — the no-ResizeObserver path, or the worker mount, which is always at least a message late — the canvas letterboxes rather than stretches. Both halves are mirrored on the worker mount: the proxy re-fits the CSS box synchronously in its resize() and at mount.

91.2, as shipped. applySize re-reads devicePixelRatio per measure when the ctor option was 'auto'/absent (the constructor keeps honouring an explicit number), and a matchMedia (resolution: …dppx) listener — re-armed per change, torn down on destroy — triggers resize() and emits resize on the core through a new RenderHost.emitResize() seam. A ratio change drops the cached pick tile (device-px addressed), in both applySize and setSize. The worker mount keeps its ratio pinned worker-side and receives updates explicitly: the proxy re-reads the live ratio, arms the same matchMedia watch, and the resize protocol message now carries dpr, which setSize applies — so worker-side edge picks scale correctly after a zoom change. The planned sweep of cached this.dpr consumers found none stale: every same-thread consumer reads the field live per call, labels scale through the per-frame zoomDpr uniform, and the CPU pick params are built per pick.

Verified by three tiers, every control run and failed on cue. test/modules/renderer-resize.mjs exercises the DOM-facing half headless against a fake document (fixed-px CSS at mount and re-fit, live-ratio re-read, pinned-ratio control, matchMedia arm → fire → re-arm → emit → destroy teardown; controls: the fixed-px write removed fails 2 specs, the ratio re-read removed fails 2, the re-arm/emit removed fails 1). In playwright-tests/renderer.spec.js, the 91.1 spec asserts the synchronous-path invariant the plan named: sampled in the same task as cy.resize() — no await, no rAF — stats().frames has already advanced and the canvas is already at the new size in both device px and CSS px; then the steady-state ink of a 100 px circle stays square within 2 px after a 500×300 reshape, and a bare layout change with no resize() call still re-measures through the ResizeObserver (control: the pre-91 schedule() shape fails the frames assertion deterministically). The 91.2 spec runs a real CDP Emulation.setDeviceMetricsOverride — with one measured surprise below — plus a stubbed-input pass through the real bundle and the pinned-ratio control; a worker spec pins the proxy's fixed-px CSS re-fit and the size crossing to the worker's backing store (control: the resize-path CSS write reverted to 100% fails it).

One measurement the plan did not predict. Headless Chromium's CDP metrics override moves devicePixelRatio and flips a resolution query's matches, but never dispatches the matchMedia change event (measured: 1 s of driven frames, matches false, zero events) — and Playwright's own emulation re-asserts its viewport override under screenshots, silently clobbering the CDP ratio back. So the browser spec splits per the plan's own escape hatch: part A pins the applySize re-read against the real override through one cy.resize(); part B stubs the two platform inputs in-page (matchMedia + devicePixelRatio) and fires the armed listener, so everything from the handler down — re-measure, re-rasterize, emit, re-arm — runs through the shipped bundle; the event dispatch itself is the platform's contract, pinned headless in the Node spec's fake and un-pinnable in this harness.

The debounce question stayed answered as planned: no debounce anywhere on the presented size. A scripted seven-step window drag on the debug harness (?network=v3-default) drew exactly one synchronous frame per step — stats().frames 2 → 9 — with the canvas device-px and CSS-px sizes tracking every step, per-frame cost steady at ~0.5 ms CPU / 0.5 ms GPU, and no device errors; the offscreen scene/depth targets rebuild per size change as before, and the goldens and parity scenes moved by nothing (the steady state was already correct, and stayed so).

The compound fit, from conservative to exact

The maintainer: the compound fixture does not fit to screen properly. Reproduced on the page (930×900 viewport, the fixture's own cy.fit(undefined, 30)), and the residual is round 54's own recorded cushion, now judged on screen and found not modest:

  1. Measured today: fit zoom 0.874 against an exact-box fit of 1.077 (Collection.boundingBox reads 807.7×637.3 for the graph; (930−60)/807.7 = 1.077) — a 1.23× over-frame, matching round 54's recorded "~1.25×, the kept p2 cushion showing".
  2. The slack is asymmetric, so it also de-centers. 54.1's directional compound-loop box grows up and left only; fit centers the conservative box, so the graph sits visibly down-right with dead space up-left — the screenshot shows it plainly. An over-frame reads as "zoomed out a bit"; an off-center over-frame reads as "fit is broken", which is what was reported.
  3. The formulation already has its successor in the same function. 54.2 moved taxi from a margin bound to the memoized exact curve bb (curveBBAt, epoch-invalidated, already computed per curved edge by the box-selection path) after the sweep proved the margin unsound. The compound-loop and blob-bezier kinds still ride the conservative terms; extending the exact tier to them removes both the 1.23× and the asymmetry in one move, with no new bound to prove sound — exact ⊇ nothing.
  4. The cull kernels keep their conservative terms deliberately (43.13/54's standing rule: over-inclusion in a cull costs efficiency, never correctness). This round touches the two CPU scan sites only (GraphStore.boundingBox, Collection.boundingBoxAt), like 54 before it.

92.1 — exact curve bounds for the remaining conservative kinds

CURVE_CMPD and the weight-extrapolated blob kinds take the curveBBAt route in both scan sites; the p2-cushion terms for those kinds are deleted rather than tightened (54.3's precedent — curveBoxMargin() died the same way). The staleness question 54.1 raised (p2's 2× cushion as the memo's staleness allowance) must be re-answered for the exact tier: curveBBAt is epoch-invalidated, so freshness is structural, not margin-based — confirm the epoch covers every input the compound-loop geometry reads (positions, outer halves, the loop params), which is the round's one soundness task.

The cost is the round's gate, measured not assumed. The scan's headline property is the ndex fast path (235 → 15 ms when the columnar scan landed). benchmark/spatial.mjs already carries the round-54 group (warm 86 µs / cold 162 µs on the 100-parent fixture); this round re-measures warm and cold on that row, adds the ndex-shaped case (curved share ~0: the cost must not move at all where no edge is curved), and publishes per the --repeat 3 rule. If the cold scan regresses beyond the page's own noise band on a realistic mix, the fallback is a hybrid: exact for the kinds that misframe (compound loops are rare and expensive to over-bound), conservative for the rest — recorded either way.

Verified by the 54 sweep re-run (conservative ⊇ exact holds trivially once both sides are exact — the sweep then pins exact-vs-flattened-route containment instead, both directions); the compound fixture's fit driven on the page with the zoom asserted near 1.077 (spec sets headlessWidth/Height to 930×900 — the round-43.12 trap); and a centering assertion (left and right margins within a few px of each other), which is the spec the asymmetry defect was missing all along. Control: reintroduce the directional slack once — the centering spec must go red.

Risks named at planning

  • First-fit cost on a cold instance now derives curve geometry for every compound-loop/blob edge before the first frame; the memo amortizes it but the first fit() is startup-visible. Measure on em-web-clustered (41 parents, real data) before and after.
  • boundingBoxAt (hypothetical positions — layoutPositions' bounds source) takes the same change via curveRouteAtPositions; layouts call it in loops, so the layout benches watch it.
  • The fixture's fit was photographed in round 54's record as acceptable; this round supersedes that judgement — update src/README.md's bounds paragraph and the round-54 cross-refs in the same commit, per the docs-travel rule.

Open: whether the whole-graph store scan keeps any conservative kind once compound loops go exact (recommended: yes — haystack/straight stay pure-columnar; exactness is bought only where the box was visibly wrong); whether fit should ever pad asymmetrically to compensate residual slack (recommended: no — fix the box, not the frame).

Landed (2026-08-28)

92.1 landed as planned, plus one restructure the measurement forced.

  • 92.1 Exact curve bounds for the remaining conservative kinds. Both CPU scan sites now route every FLAG_CURVED_BOX kind — compound loops, taxi, weight-extrapolated blobs — through the exact memoized curve bb: GraphStore.boundingBox generalizes round 54's taxi-only curveBBAt branch (the directional p2 box and the per-edge outer-half + chord margin are deleted, 54.3's curveBoxMargin precedent), and Collection.boundingBoxAt calls the new curveBBAtPositionscurveBBAt's sampling at hypothetical centres, unmemoized (no epoch covers a hypothetical), backed by the new curveEvalAtPositions (the CurveEval twin of 54.2's curveRouteAtPositions) so compound loops evaluate there too.
  • The staleness question, re-answered structurally. Every input the compound-loop geometry reads writes through a geoEpoch-bumping path, checked setter by setter: positions (setPosition/setPositions/setPositionsConst/shiftPositions), sizes and borders — and therefore the derived outerHalf — including materialized parent auto-bounds (materializeParentGeom bumps after writing, and curveBBAt runs flushDerived() before consulting the memo, so a flush-time bump invalidates before anything is computed), shapes and curve params (setScalar/setPair/both curve-param sinks), and arrow trim (setArrowShapes/the arrow-scale writer). Freshness is structural; no margin stands in for it.
  • curveBBAt answers a fresh memo without evaluating. The eval ran before the memo check, so every warm read paid a full route/curve evaluation it then threw away — invisible while only taxi used the memo in the scan, and the first post-change benchmark run showed it (warm 82 → 97 µs). The kind gate now reads the params column, the memo answers first, and the eval runs only on a stale slot. Warm scan 82 → 20 µs — 4× faster than round 54's own number, and the box-selection path shares the win.

Measured, on the debug harness's compound fixture (930 × 900, cy.fit(undefined, 30), the 43.12 headless dimensions): fit zoom 0.874 → 1.077, exactly the exact-box fit ((930−60)/807.7); the scan box collapses onto the exact box (995.8 × 825.8 → 807.7 × 637.3); margins go 194/30 → 30/30 left/right and 254/89 → 106.8/106.8 top/bottom — the de-centering was the reported defect, and it is gone to the pixel.

The cost gate, measured not assumed (benchmark/spatial.mjs, N=2000 profile, the 100-parent round-54 fixture: 100 compound-loop + 99 taxi edges): warm 86 → 20 µs (the memo-first restructure); cold — a geometry write per call, which the wholesale epoch invalidation turns into a full re-derive of every curved bb — 154 → 204 µs, the price of sampling 100 compound loops exactly instead of reading two columns, on a fixture built entirely of the affected kinds. The new ndex-shaped control row (same element count, every edge straight) reads 34.3 µs before and 35.2 after — noise; the fast path is untouched, as the flag gate predicts. First fit on a cold instance: the generated compound-cluster fixture (2000 nodes / 4000 edges, 80 compound loops + 161 leaf loops) p50 1.58 → 1.65 ms over 30 runs, inside its own min–max spread; em-web-clustered (41 MCODE parents, real data) is unaffected by construction — all 6,899 of its edges derive haystack, none box-bounded (p50 1.51 → 1.39 ms, noise). No hybrid fallback needed: the regression is confined to the cold scan of the kinds that were misframing, and it buys the exact fit.

Verification. The 54 sweep re-ran and, as planned, its containment half went trivial for the exact kinds — so it now pins exactness both directions on every graph whose curved edges are all box-bounded (39 of the 60 seeded graphs; a floor of 10 guards the generator), with the chord-hull kinds (a random leaf self-loop, a bundled-bezier pair) keeping containment-only. The compound fixture's fit is driven headlessly at 930 × 900 in test/modules/debug-harness.mjs: zoom pinned to the exact-box fit (and > 1, where 0.874 was measured), plus the centering assertion the asymmetry defect was missing all along — the exact box's rendered margins equal side to side within 3 px. test/compound-loop-edges.mjs's round-54 pin (x1 = -15 - p2) became its inversion: the scan box equals the edge's exact bb and sits strictly inside the old conservative corner, with right/down still contributing nothing beyond the nodes. Control, run once with round 54's directional slack reintroduced in the scan: four specs go red — the centering spec, the sweep's exactness half, the sweep's own control, and the compound-loop pin — and were watched failing before the slack came back out. Node tier green end to end (2260 node:test + modules/soak/throws, lint, format, types; the throw gate's line-keyed UNREACHABLE entry for graph-store re-pointed after the insertions above it), shipped declaration regenerated, and the full Playwright suite green — no golden frames a fit over the affected kinds, so none moved.

Opens, decided as recommended. The whole-graph scan keeps its conservative hull terms for the chord-bounded kinds (haystack and straight stay pure-columnar; exactness was bought only where the box was visibly wrong), and fit pads symmetrically as ever — the box was fixed, not the frame.

Curve smoothness: spend the 24 quads where the bend is

The maintainer: some curved edges render as visible chord chains while bundled beziers are smooth. Reproduced and mechanism pinned:

  1. Every curved edge is one strip of CURVE_SEGS = 24 quads (src/curve-geometry.mts:55; indexCount 6×24 per instance, src/render/cull.mts:803), evaluated in the vertex shader.
  2. The analytic families spend all 24 on one curve. Bezier, loop and compound loop sample curvePoint(g, idx/24) (src/render/shaders.mts:3130-3160) — 24 chords per quadratic, smooth at any sane zoom. That is exactly the "bundled beziers look smooth" observation.
  3. The route families split the same 24 uniformly across pieces. quadPieceW (shaders.mts:947-964) hands each piece ⌊24/pieces⌋ or so subdivisions regardless of what the piece is; round-segments spends 2n+1 pieces for n interior points (routePieceCountW, :939-943), round-taxi similarly. A radius-50 arc therefore gets 3–8 chords while pixel-straight legs — which need exactly 1 — consume the rest. Measured on the parity page: a round-taxi radius-50 corner at zoom 3 renders as ~6 visible facets in v4 beside v3's perfect arc. The unbundled bezier (MULTI, C1 spline through inserted midpoints, :967-985) has the same shape of problem at 24/n chords per quadratic piece.
  4. v3 has no such budget — canvas arc()/quadraticCurveTo() are analytically rasterized — so every chord is a v4-only artifact, and the round-56 close-up tier is where it shows (AA does not scale with zoom; chords do).

93.1 — curvature-weighted subdivision allocation

The recommended fix spends the existing budget instead of raising it: quadPieceW weights pieces by bend — an arc piece by its sweep angle, a multibezier piece by its control's deviation from the chord, a straight leg exactly 1 quad — normalized to the same 24. Zero new vertices, a few extra ALU in a shader whose loop already walks all 24 subdivisions for dash length (:3152-3159). The allocation must stay canonical per vertex index (both quads sharing an index compute identical geometry — the watertight rule the strip already lives by), which it is, being a pure function of the route params. Sagitta check at the budget: a 90° arc given 20 of 24 chords has max chord error r(1−cos 2.25°) ≈ 0.0008 r — at radius 50, zoom 4, dpr 2: 0.3 device px. Invisible; no budget raise needed for arcs. The unbundled spline's worst case (few controls, deep bend) gets the same treatment and is measured, not assumed.

Every consumer of the subdivision map moves in the same commit — the co-signed rule applied to geometry: the CPU flatten twin (curve-geometry.mts:504 — "a CPU flatten at the same K", used by bounds and picking), the route-family layer VS (vsCurvedLayer), the dash arc-length loops, and the curved-arrow placement (curved-arrow-pipeline.mts:259). A spec pins CPU-flatten == GPU-vertex at every index over a fixture of every family (the existing twin-parity shape).

93.2 — the budget itself, priced

If 93.1 leaves a family visibly faceted (candidates: many-point round-segments, where 2n+1 pieces at n=11 leaves ~1 chord per arc), the follow-up is raising CURVE_SEGS — priced first: vertex count is linear in drawn curved edges, so benchmark:renderer on the curve-heavy scenes and benchmark/curves.mjs decide 24→32/48, and the number is chosen by measurement, not taste. A zoom-adaptive indexCount (the indirect args are written per frame) is recorded as the further step and deliberately not taken until a real scene needs it — it couples the cull pass to zoom in a way nothing else does.

Verified by close-up parity scenes (round 56 rules: short edges, zoom 3–4, and count the corners — several bends per edge) for round-taxi, round-segments and unbundled-bezier, with controls (allocation reverted to uniform must jump each scene past its bound); the twin-parity spec above; goldens that touch curved edges regenerate (exact goldens — read the diffs). The v3-default and edge-types harness pages get driven per the standing rule.

Risks named at planning

  • The subdivision map is load-bearing for dashes (u runs along the polyline) and mid-arrow placement; both must be asserted in the parity scenes, not assumed unaffected — a dash pattern that breathes when allocation changes is the regression to catch.
  • Piece boundaries must still land exactly on subdivision indices (straight legs stay pixel-straight); the weighted allocator keeps the integer-boundary construction, only the shares change.
  • WGSL edits follow the house rules (tagged literals, no interpolation in comments); the routing numbers must not move at all — routing.spec.js is the control that costs nothing.

Open: the bend metric for multibezier pieces (sweep-angle proxy vs flattened-length ratio — decide by which keeps the allocator branchless); whether haystack/straight-triangle need anything (no — they are straight by construction); whether 93.2 runs at all (measure after 93.1).

Landed (2026-08-28)

93.1 landed as planned; 93.2 was measured, deferred to a hardware session, and then priced and taken at 32 (below). The open bend metric resolved to the sweep-angle form for both families, which is what keeps the allocator branchless: an arc piece weighs its sweep (π minus the interior angle between its legs — atan2(|cross|, -dot); the radius scales the arc, never the sweep, so the clamped-lenOut case needs no branch), a multibezier piece the turn between its control legs (atan2(|cross|, dot) — a quadratic's tangent rotates monotonically from c - a to b - c), a straight leg zero. Haystack and straight-triangle confirmed needing nothing — straight by construction, they never enter the curved stream.

93.1, as shipped. allocRouteQuads / allocRouteQuadsW give every piece one mandatory quad and split the leftover proportionally to the bend weights by cumulative floor, so the map stays monotone, every piece keeps ≥ 1 quad, and every piece boundary still lands exactly on a subdivision index — the integer-boundary construction the plan required, only the shares changed. A no-bend route (sharp polylines) keeps a uniform split. The map is a pure function of the evaluated route, so it is canonical per vertex index (watertight); routeQuadPiece / quadPieceW read it and routeVertex / routeVertexW route through them, which is how every consumer moved in one commit: the CPU flatten (bounds, box selection, CPU pick), the strip VS, vsCurvedLayer, and the dash arc-length loops — the dash coordinate is the same polyline's accumulated chord length. The curved-arrow and label stages never read the map (end tangents run from route points; mid anchors through the analytic routeMidpointW), so they skip the alloc — on the GPU the map is built once per invocation by the two entry points that subdivide, on the CPU lazily on first read with evalRoute invalidating, because the evaluators share scratch instances. One surprise worth the record: the very first symmetric fixture (two equal corners) landed the split's floor argument exactly on an integer, where f64/f32 rounding noise decides 10-vs-11 — a 1e-4 nudge before the floor keeps exact ties deterministic across the twins. Cost, measured: the alloc is ~95 ns per route eval on the CPU (0.6% of one 25-vertex flatten; micro-measured through tsx, so a relative figure only), and on the GPU one atan2 per bent piece once per invocation plus an integer scan per quadPieceW — in a vertex stage that already walks all 24 subdivisions through per-corner atan2/asin for dash length.

Verified by the three tiers the plan named, every control run and failed on cue. The Node allocator suite runs over a fixture of every family: boundaries on indices, ≥ 1 quad per piece and the budget spent exactly, straight legs pinned at one quad, a deeper corner out-weighing a shallower one, scratch invalidation, non-default subdivisions, and the plan's own sagitta check made an assertion — a radius-50 round-taxi corner flattens within 0.05 model px of the true circle (22 chords measure 0.032; the uniform split's 8 measure 0.24, failing 5×). The dash risk is pinned in the same file: the arc's flattened length must sit within 0.05 model px of r·sweep (weighted 0.03 short, uniform 0.12 — 2.4× past the bound). The control — allocation reverted to uniform in both twins — fails exactly those five specs plus the dash spec and nothing else. In the browser, parity-closeup-bends frames one large-radius corner per family at zoom 3 (the maintainer's radius-50 round-taxi case among them) and measures 0.004% against v3, with the uniform control at 0.099% — a 25× jump, failing the 0.03% bound by 3.3×. Its first draft is a recorded lesson: following the close-up tier's short-edges rule literally (whole short edges, radius 10–12) measured 0.006% fix and 0.005% control — sub-half-pixel facets, a scene that discriminated nothing; the short-edges rule keeps arrow ends in frame, and what this scene has to keep in frame is the arc. routing.spec.js ran as the free control: 28 green, no routing number moved. One golden movedsegments-families, the one golden drawing round-segments, by 2 px past threshold (0.002%); the diff was read before regenerating and is confined to the rounded routes' arcs. The harness pages were driven per the standing rule (edge-types, v3-default, plus zoomed fits onto the round-taxi and round-segments corners): clean arcs, no facets, no device errors.

93.2, priced and taken (2026-08-28). The deferred raise ran on hardware: benchmark:renderer --scene gen-25k-curved on this box's RX 580 — the adapter line reads adapter: amd gcn-4 · dpr 2 · 1280×800 · render scale pinned to 1 (RADV, the round-0 validation box; the earlier "SwiftShader-only" reading came from an ad-hoc launch flag the harness never passes). Measured at 24 / 32 / 48 (device = timestamp-query GPU-pass p50 on the 25k × 50k curved scene; probe = the 5-corner round-segments zigzag above, diffed against v3 on parity.html with pixelmatch 0.2 — SwiftShader there, deliberately, it is a pixel probe):

segs probe device fit-all / far-zoom / zoomed-in wall p50 curves.mjs cold read
24 0.384% (461px) 9.64 / 9.64 / 3.83 ms 16.7 ms 30.1 ms (1.90×)
32 0.003% (4px) 13.19 / 14.14 / 3.78 ms 16.7 ms 40.1 ms (2.57×)
48 0.000% (0px) 25.71 / 26.62 / 4.05 ms 33.2 ms 44.8 ms (2.75×)

The number is 32, not the 48 the deferral's arithmetic favoured: that arithmetic estimated ~1.2 device px of sagitta left at 32, and the measured probe overrules it — 4 of 120,000 pixels differ, the chorded crests gone. 48's price is real and superlinear (×2 the segs costs ×2.7 the device time — the dash arc-length loop is O(segs²) per edge) and lifts the scene's wall time off the vsync floor to two frames (33 ms), for no measurable gain over 32. Init is flat (156/155/154 ms) and the zoomed-in pass is culling-bound, not segs-bound; the CPU premium moves with the flatten as expected (cold first-read 30 → 40 ms, elementsInBox 3.60× → 3.81×) and the warm rows hold. Landed with the constant: the three specs that hardcoded 24-derived subdivision indices now derive them from allocRouteQuads/segEnd (plus the odd-count Q(0.5) index as CURVE_SEGS / 2), so the suite holds at any budget — the uniform-allocation control still fails six specs, the generalized corner-join among them — and the radius-50 sagitta / dash arc-length bounds only tightened (0.017 px and 0.009 short at 32, both well under the 0.05 bounds, with a uniform split still failing both). Twelve goldens moved, every one a curved-edge scene; each diff was read before regenerating and is confined to the curved ink (chord-position and dash-phase shifts along beziers, loops and round corners). parity-closeup-bends held and routing.spec.js ran 28 green — no routing number moved. The zoom-adaptive indexCount remains the recorded further step, deliberately not taken.

Label fidelity under zoom

The maintainer: labels break down in quality when zoomed in a fair bit. Reproduced: at zoom 4 (font-size 14 → 56 displayed px) v4's glyphs go soft, corners round off, and a 'g' descender deforms, beside a v3 render that is crisp at every zoom. Mechanism, pinned:

  1. The atlas is a runtime single-channel SDF at a fixed 32 px per glyph (src/render/glyph-atlas.mts:15, TinySDF-style: canvas2d alpha raster → Euclidean distance transform, pad 6, radius 8, 1024² r8unorm, ~1 MiB). The header's claim — "crisp at any zoom from one 32px-per-glyph atlas" (:9, restated at shaders.mts:4387-4388) — is true of the edge AA (fwidth-based, scale-free) and false of the letterform: raster + EDT quantization error is baked in SDF px, so it magnifies linearly with displayed-px / 32, and single-channel SDFs round every sharp corner at ~1 SDF px radius by construction. At 56 displayed px that is ~2 px of visible corner rot; at zoom 1 it is sub-pixel, which is why nothing noticed before the maintainer zoomed.
  2. v3 rasterizes text per frame at the current zoom (canvas fillText under the zoom transform), so it is crisp everywhere and pays for it on every frame — the design v4 rejected for throughput. The ask is not v3's cost model; it is more fidelity from the atlas model, bought cheaply.
  3. The re-raster machinery already exists: labelLayer.reraster() re-rasters every cached glyph today when web fonts finish loading (renderer.mts:302-317, label-layer.mts:70-72), and the zoom-promotion meter pattern (15.6, schedulePromotionCheck, renderer.mts:351) already watches zoom to re-tier images — the two halves of a zoom-tiered atlas are built and shipping.

94.1 — measure the two cheap levers, ship the winner

  • Lever A — raise SDF_FONT_SIZE 32 → 64 (atlas 1024 → 2048, 1 → 4 MiB, EDT per glyph 4× on a one-time cost): halves every artifact's on-screen size at a given zoom, uniformly, no runtime machinery. Measure the raster stall (the em-web glyph population on first frame — the atlas rasters on demand) and the memory.
  • Lever B — a zoom-tiered re-raster: keep 32 px as the base tier; when the promotion meter reports sustained zoom past a threshold, re-raster the glyphs in use at 64/128 px into new shelves and swap runs, exactly as the font-loading re-raster does. Costs nothing until someone zooms; costs a visible one-frame sharpen when they do (the image promotion tier has the same behaviour and it reads fine).
  • MSDF is investigated and expected to be declined: sharp corners at any scale, but a faithful MSDF needs the glyph's vector outline, which canvas2d does not expose — raster-derived MSDFs are exactly the fragile hand-derived second implementation this repo keeps declining. If declined, the decline is recorded with the reason (no-deps rule + fidelity risk), per house practice.

The round ships A, B, or A+B based on measured quality-per-cost at zoom 3–6 over the labels fixture; the quality judgement is a close-up golden set, the cost judgement is the label benches plus first-frame timings.

Verified by close-up label goldens (zoom 4, the round-56 tier — which today has no zoomed label scene, itself a gap this round closes) regenerated intentionally; a live parity diff against v3 at zoom 4 with a bound the pre-change render measurably fails (run the control: today's render must exceed the bound, or the scene is not discriminating); the soak tier watches atlas growth if B ships (shelf churn across zoom cycles must plateau, not grow — the leak gate's reachability rule applied to texture shelves).

Risks named at planning

  • Every label golden regenerates if A ships (the raster changes everywhere); read the diffs, per the exact-golden rule.
  • B's swap must not tear mid-frame (runs re-point atlas UVs while a frame is in flight) — the font-loading re-raster already solves this; reuse its sequencing, do not invent a second one.
  • The 'g'-descender deformation seen in the repro may be quad/pad clipping rather than SDF error (outline width eats into the 6 px pad) — check SDF_PAD against the worst outline before choosing levers, since a clipped quad no tier fixes.
  • dpr interacts: a dpr-2 display at zoom 2 already shows 4× — 91.2's live dpr work makes the meter's input honest; land 91 first or meter on displayed px directly.

Open: the tier thresholds and count for B (recommended: one extra tier at 64 px triggered near displayed 40 px, judged on the goldens); whether the atlas grows to 2048 in both levers (A: yes by necessity; B: only on first promotion).

Landed (2026-08-28)

Lever B shipped; A and MSDF declined on measurement. The atlas is zoom-tiered: GlyphAtlas.setTier rasters at SDF_FONT_SIZE × tier with pad and encoded radius scaled together, so the SDF halo and the field's em-span are tier-invariant and the outline-width conversion needed no change. Metrics normalize back to base-tier SDF px — layout, the shaping memo and the run scale math never see the raster resolution (pinned headless: a leaked raster px is a strict spec failure). One extra tier at 64 px, as recommended; the texture grows 1024 → 2048 (1 → 4 MiB) only on first promotion, answering the open question — and the bind-group cache keys on a new atlas generation, since the texture object is the one identity the promotion replaces.

The meter. LabelLayer.maybePromote(zoomDpr): monotone max label font size × displayed device px per model px (zoom × dpr, render-scale-free like the label LOD thresholds — 91.2's live dpr landed first, so the input is honest on density changes), threshold LABEL_PROMOTE_PX = 40. It runs on the image meter's own settle-debounced timer (never per wheel tick), and — a gap the round found — on arrival: construction sets the viewport without firing a viewport event, so a graph built already zoomed never promoted until the label layer learned to flag a process() pass that raised its max font size (the 15.6 fresh-upload rule applied to text). Image exports promote at the export scale in exportFromView, both same-thread and worker paths; the export frame's process() rebuilds the runs before the encode. Promotion is one-way: a promoted atlas draws zoom 1 identically, so demotion would buy back 3 MiB at the price of shelf churn on zoom cycles — the soak concern dissolves by construction, and the plateau is pinned (twenty zoom cycles after promotion create no further textures). stats().glyphAtlasTier publishes the state; the swap reuses the font-loading re-raster's sequencing verbatim (atlas reset + shaping-memo clear + markAllLabelsDirty, rebuilt by the next frame's process() before anything draws), per the plan's tear risk.

The costs, measured. EDT per glyph 0.19 ms base, 0.41 ms promoted (2.1× for 4× the pixels — the O(n) transform's per-pass overhead amortizing; two new rows in benchmark/labels.mjs price a 96-glyph em-web-sized population at ~18 vs ~39 ms). Lever A would have moved that 4× onto every graph's first paint and 4 MiB onto every graph, for identical zoom-4 quality — declined and recorded. MSDF declined as planned (no vector outline from canvas2d; a raster-derived MSDF is the fragile second implementation the no-deps rule exists to refuse). In the debug harness (?network=labels, 209 glyphs, SwiftShader) the promotion lands 258 ms after the zoom settles and the sharpen frame's cpuFrameMs is 16.7. The pad risk note was checked and held: the outline clamp (0.45 × SDF_RADIUS = 3.6 SDF px) sits inside the 6 px pad at every tier, so the 'g'-descender deformation was SDF error, not quad clipping — confirmed by the promoted render.

Verified by — each control run and failing on cue:

  • test/modules/glyph-atlas-tier.mjs (10 specs over a fake proportional-metrics canvas): tier-free metrics, the 2048 growth + generation bump + old-texture destroy, double-resolution cells in the grown uv space, no-op same-tier setTier, the texture plateau, and the meter (under-threshold no-op; promotion re-lays every run at an unchanged model size, budgeted at the raster's own ceil quantization; one-way including never demoting; monotone across removals). Five controls: un-normalized advances, no generation bump, a two-way meter (whose first draft hid behind canPromote and failed nothing — itself the testing note's lesson, rebuilt until it bit), promotion without markAllLabelsDirty, churning setTier.
  • parity-closeup-labels, the close-up tier's first label scene: letterform-dominated by construction (2 px invisible nodes, short descender/corner-rich words wholly on frame, 24 px font at zoom 4 = 96 displayed px, both sides on the pinned Open Sans — parity.html gains the @font-face). The placement policies differ by design and the ~0.28 em block-centering offset out-signals the letterforms (measured 11.3% mismatch dominated by it, tier-invariant), so the scene compensates with text-margin-y −6.75 and what remains is glyph shape. Promoted 0.112%, the tier-1 control 1.202%, bound 0.4% — the pre-round render fails it 3× over, as the plan demanded. A promoted-but-soft raster control reads 18.3%. A finding for the suite: the diff's AA-exclusion classifies sub-2 px softness as antialiasing, so the raw ratio under-weights exactly this defect until displayed px push the error into multi-pixel structure — which is why the scene runs at 96 displayed px rather than zoom 4 over 14 px text.
  • labels-zoom-closeup, the round-56 tier's first zoomed label golden (14 px labels at zoom 4, built already zoomed, so it pins the arrival promotion too), exported only after stats() reports tier 2 — waitForAtlasTier, because frame-count exports race the 250 ms settle meter. label-outline-closeup gains the same wait and regenerated: read against the old PNG, the move is exactly the sharpen. Every other golden is byte-stable across the full visual project (130 specs) — the base-tier raster path is arithmetically unchanged.
  • The renderer spec drives the meter through public stats: no promotion at zoom 1 (bounded wall-clock control past the debounce), promotion after zoom 4 settles, unchanged glyph count across the swap, no demotion on zoom-out; with the threshold disabled at source the spec fails on cue.

Deviations from the plan. The zoom-promotion meter was not extended from promoteVectors itself (its demand walk is per-image column data); the label half shares the timer and the settle semantics, and the label layer owns its own one-line meter — same pattern, no second debounce. The close-up parity scene runs its letterforms at 96 displayed px via a 24 px font at zoom 4 rather than 14 px text, for the AA-exclusion reason above; the 14 px case is carried by the golden instead.

The outline goes under the ink

The maintainer: label outlines overlap the previous characters of a word, cutting white notches into the letters. Reproduced at zoom 1 (the notches are visible in 'Wavelength' at 14 px) and worse zoomed. Mechanism, pinned:

  1. v4 computes fill and outline in one fragment pass per glyph quad (src/render/shaders.mts:4426-4435: fill alpha from the 0.5 threshold, outline from 0.5 − outlineWidth, maxed) and alpha-blends the quads in glyph order. Glyph quads overlap by construction — each carries the SDF pad halo (6/32 of the em) past its ink, and advances are narrower than ink + 2×pad — so glyph N's opaque outline ring composites over glyph N−1's already-blended fill. The notch is the next letter's white ring biting the previous letter.
  2. v3 never composites a stroke over a fill within a line: it draws strokeText for the whole line, then fillText over it (v3/src/extensions/renderer/canvas/drawing-label-text.mts: 399-413) — all outline under all ink, per wrapped line.
  3. The fix is therefore a draw-order fix, not a shader-math fix: outline first for the run, fill second. The label pipeline draws one indirect instanced pass off the culled glyph list (label-pipeline.mts:185-205); a phase uniform (outline-only / fill-only thresholds in fsLabel) turns that into two passes over the same instances with no new buffers.

95.1 — the two-phase label draw

Pass 1 renders outline coverage only (skipped entirely — one branch on a per-frame flag — when no visible label carries an outline); pass 2 renders fill (plus outline-under-fill mixing as today for the colour, minus the max that lets a ring beat ink). The granularity question is the one real decision: global two-phase (all visible labels' outlines, then all fills) is one extra draw call and matches v3 everywhere except where two different labels overlap — there v3 draws label B's stroke over label A's ink and v4 would not. Per-run phases inside one pass would match v3 exactly but breaks the single instanced draw into per-run draws, which is the label pipeline's whole cost model. Recommended: global two-phase, deviation recorded (overlapping distinct labels are already unreadable in both libraries; within-word legibility is what the defect is about). Text-background quads (solid path, :4393-4423) stay in the fill pass under both phases — they already draw under their own run.

Cost, priced before landing: the second pass doubles label vertex work and rasterized quads only when outlines are in use, and only for the visible set the cull already produced. Measure on the labels-heavy bench scene with outlines on and off; the outline-free path must measure unchanged (the flag skips pass 1 before any GPU work).

Verified by a golden of an outlined multi-word label (today's render fails it — the notches are pixels, so the control is the diff itself), a close-up golden at zoom 4, and a live parity diff against v3 on the same scene with the bound tuned per round 55's lesson (dark ink, contrasting outline, several words — the configuration that exposes the defect, since same-colour outlines paint over it); plus the standing drive of ?network=v3-default, whose edge labels all carry text-outline-width: 2.

Risks named at planning

  • Every outlined-label golden regenerates; the label parity bounds retune downward, never up.
  • The fill pass must keep the outline colour mixing at the glyph boundary (the mix(outlineColor.rgb, color.rgb, fillA) term) or edges get a dark AA fringe on light outlines — the phase split changes coverage, not the colour math.
  • Edge labels ride the same shader (EDGE_LABEL_SHADER, :4442-4443) — both pipelines take the phase, or edge labels keep the notch.
  • Rotated labels overlap differently; one golden rotates (v3-default has the 38°/45° pair ready-made).

Open: whether pass 1 also underlays the text-background quad order (v3 draws background under both; v4's solid path already does — confirm, don't assume); whether the per-run exact-parity variant is worth a follow-up if a real scene surfaces the distinct-label case (recommended: no, record it).

Landed (2026-08-28)

Landed as planned: global two-phase, the recommended granularity, with both opens taken as recommended. The background-quad question was confirmed rather than assumed — the solid branch draws inside its own run's fill order, and pass 1 now returns zero for solid quads explicitly, so the box sits under both phases; the per-run exact-parity variant is declined and recorded (src/README.md carries the deviation: where two distinct labels overlap, v3 strokes the later label over the earlier one's ink and v4 keeps all outline under all ink).

The mechanism, as shipped. LABEL_PHASE is a pipeline-overridable constant on the one label shader module — not the phase uniform the plan sketched, and better than it: two specializations of the same module share the bind groups and buffers outright, and no per-draw uniform write exists to get wrong. Phase 1 renders outline coverage only (zero for solid quads and outline-free glyphs); phase 0 renders fill, keeping the mix(outlineColor.rgb, color.rgb, fillA) boundary term — the risk note held: coverage changed, not colour math — and dropping the max() that let a ring beat ink. The outline pipeline compiles lazily on the first outlined draw. The per-frame flag is a per-stream count the GlyphBuffer maintains across set/replace/clear/ compact (solid quads excluded — their outline words are the B6 text-border), pinned by test/modules/glyph-outline-flag.mjs because a stale count fails silently in both directions: stuck-true pays a second pass every frame, stuck-false erases every outline.

The cost, priced. A gen-25k-wrap-outline renderer-bench row joins the outline-free wrapped-label scene it varies. On the amd gcn-4 adapter: the outline-free row is unchanged against the published 13 Aug baseline to the microsecond (fit-all labels 4.555 ms, zoomed-in labels 5.946 vs 5.948 ms device p50), and the outlined row pays +0.087 ms (+1.5%) device time at the zoomed-in label view, nothing measurable at fit-all, where the LOD has faded most glyphs. Wall stays at the 16.7 ms vsync floor everywhere.

Verified by the planned coverage, each control measured: label-outline-words (three multi-word outlined labels — one rotated 38°, one boxed; dark ink, white outline) fails by 1,984 px (1.653%) on the pre-95 renderer — the notches are the diff, exactly as planned. label-visuals regenerated (170 px moved on its outlined node's word); no other golden moved, and the full visual project is green. ?network=v3-default drove clean in a scripted browser (206 glyphs, no device errors), its outlined edge labels intact at zoom 3.

Two measurements the plan did not predict. First, the close-up tier: the pre-95 render differs by only 15 px at zoom 4 on the same words. At 14 px the requested 2 px outline saturates the 0.45 SDF-unit cap, so the zoom-1 notches are that capped ring's fwidth fringe bleeding into the neighbour's ink — a fringe zoom 4 narrows fourfold. The close-up golden stays as a regression pin with a comment saying exactly what it does not test; the zoom-1 golden carries the discrimination. Second, the parity ratio cannot see outline defects at all through cross-renderer raster noise: the pre-95 notches measure 2.105% against 2.103% fixed, and even zeroing the v4 outline entirely reads 2.097% — a pale outline on the white page sits under any workable pixelmatch threshold, and a saturated red one raises ambient to 4.975% without separating either. So parity-outlined-labels splits the assertion per round 55's actual lesson: the ratio guards placement (ambient 2.103%, bound 2.5% — tuned downward from the suite's 3%), and outline presence is the ink floor's job — v4 inks 10,840 px with outlines against 4,872 without, floor 8,000, decisive where the ratio is blind.

The v3-default eh, restored by bypasses

The maintainer: on ?network=v3-default, edge eh "seems not quite right". Investigated, and the renderer is exonerated:

  1. Given v3's exact parameters, v4 routes eh identically — measured through the parity page: segmentPoints(), endpoints and midpoint agree to the last float ((140,150)/(200,150)/(260,150); endpoints ±76.96/323.04), and the side-by-side render matches. The defect is not in round-segments.
  2. The debug sheet cannot give eh its own arrays, and says so. v3 styles #eh as round-segments with segment-distances: [-50,-50,-50], weights [0.25,0.5,0.75], radii [50,50,50] (v3/debug/init.js:141-147). v4's list-valued curve props take constants only (the recorded 12b scope note, src/style.mts:3486-3489), so the whole edges section shares one parameterisation — [20,-80]/[0.25,0.5]/[20,20] (debug/styles.js:672-676), chosen to demo the family, not to match eh. On screen that is a different curve entirely (a two-segment S against v3's three-segment flat-top), and the sheet's own header records the deviation (debug/styles.js:564-572).
  3. Round 63's bypasses section closes it, verified: a bypass entry carrying v3's arrays for eh produces v3's exact segmentPoints — bypasses are per-element constants, which is precisely what per-edge list props are. The sheet's header even names bypasses as "the other spelling" without noticing that for the list props it is the only spelling (no mapper form exists).
  4. What remains visually after the arrays match is arc faceting at the radius-50 corners — round 93's defect, deliberately not this round's.
  5. The segment families have zero numeric parity coverage: routing.spec.js probes bezier/taxi-era scenes and nothing with segmentPoints() (grep) — which is why nothing was watching this configuration.

96.1 — the sheet carries v3's per-edge arrays

debug/styles.js's v3Default sheet gains a bypasses section with v3's arrays for the four edges v3 parameterises individually: ab (control-point-distances/-weights), bc, ed, eh (their three different segment arrays). The shared constants remain as the family defaults for every other edge. The header's deviation paragraph is rewritten in the same commit — after this it records a closed limitation with the bypass spelling shown, and the "what is genuinely lost" sentence dies, because nothing is.

Verified by test/modules/debug-harness.mjs growing an assertion that the v3-default fixture's eh/ed/bc/ab route points match v3's values (hard-coded from the v3 probe — the fixture is the spec's fixture, so the numbers are stable), and by driving the page against v3's own debug page side by side, per the standing rule. Control: drop the bypass section once — the spec must go red on all four edges.

96.2 — segment-family routing parity scenes

routing.spec.js gains segments / round-segments / round-taxi scenes (v3's eh and ed parameterisations verbatim, plus a negative-distance and an extrapolated-weight case), comparing segmentPoints(), endpoints and midpoint through the existing symmetric probe. No adapter, no pixels — the suite's whole point — so no hasAdapter skip (the standing note). Control per round 27: perturb one array in one side's options and watch the probe name the field.

Risks named at planning

  • Bypasses interact with selection styling in the harness (the sheet re-states selection affordances); a bypass on curve params touches no colour channel, so nothing should move — the debug-harness selection spec is the existing gate.
  • The v3 probe values encode v3's current build; if v3's fixture ever changes (it must not — it is a parity baseline), the spec says so loudly.
  • None of this touches src/; if 96.2's probe surfaces a real routing divergence in the negative/extrapolated cases, that is a finding for its own fix, not something to absorb into this round.

Open: whether the other demo sheets (edge-types ports) want the same bypass treatment for their per-edge arrays (check while in there; same mechanism); whether MIGRATING.md's list-props note should name bypasses as the porting path for v3 sheets that mapped them (recommended: yes, one sentence).

Landed (2026-08-28)

Both items shipped as planned, the opens were both taken as recommended, and the negative/extrapolated risk did not materialise — the new scene measured those cases clean, so there was no finding to absorb or to ledger.

96.1 — the sheet. The v3Default sheet's bypasses section carries v3's four arrays verbatim; the header paragraph now records a closed limitation, and the "what is genuinely lost" sentence is gone, because nothing is. One thing the plan did not have right: the four-edge control was impossible as specified, because the sheet's shared family constants were ab's and bc's arrays — the port had used those two edges' parameterisations as the family defaults, so half the recorded deviation had never been visible and dropping those two bypass entries would have changed nothing. The family defaults are re-chosen distinct from every bypass entry (they now parameterise only an edge toggled into a family, since every edge that starts in one is bypassed), and with that the control means what it says: deleting the section moves all four edges.

96.1 — the numbers. The spec pins the four edges' route points at the page's own layout (grid, cols 3) with the viewport-derived bounding box made explicit, so the values are deterministic headless. Their provenance is a probe run through playwright-page/parity.html with both libraries at those exact positions and parameters: ed and eh agreed v3-vs-v4 to the last float — the plan's fact 1, re-verified at the fixture's own geometry — so their pinned segment points, endpoints and midpoints are v3's own values. ab and bc route through node b, a round-hexagon, whose boundary v4 resolves as its inscribed ellipse (the ledgered tier), and sat within 0.9 model px of v3; their pinned values are v4's, endpoints left to the parity suite. One measurement worth keeping: the border widths feed the intersection frame identically in both libraries (border 0 vs 1 moved both sides' numbers by the same amount), so the probe had to carry the real sheet's borders before its numbers matched the headless run.

96.2 — the scene. segment-arrays joins the matrix: v3's eh and ed parameterisations verbatim at the fixture's own orientations, v3-default's round-taxi, a mixed-sign-distance case and an extrapolated-weight case — with the v4 side carrying the per-edge arrays as bypasses, so the scene also measures the bypass path against v3's id selectors. Landed clean: 50 fields, 0 diverged, max delta 2.55e-6 model px (39x headroom). The round-27 control needed one correction en route: the first perturbation edited the scene's shared rows table, which feeds both sides, and read clean — the perturbation has to land on one side's built style, which the scene's comment now says. Done properly (-50.5 in the bypass copy only), the probe named eh/interior[1].x by 0.5000 and eh/mid.x by 0.4965.

The opens. The edge-types sheet's single unbundled-bezier row got v3's 120/0.1 back through a bypass on its edge (s1-0), so it and the multi row draw different curves again — the demo's point; its segments and round-segments rows share one array in v3 too, so nothing else there was collapsed. MIGRATING.md's bypass section gained the sentence naming bypasses as the porting path for per-edge list-prop arrays.

Verified by npm run -s verify, the debug-harness spec (control run: section deleted, spec red), the routing suite (28 passed, control run: one-sided perturbation named the field), and both debug pages driven side by side — v4's ?network=v3-default against v3's own page on :3334, where eh, ed, bc and ab now draw the same four curves.

The pick order: leaf, then edge, then parent

The maintainer: on the clustered enrichment-map network, a click on an edge inside a compound parent selects the parent. Reproduced (cy.pick at an edge midpoint inside a parent answers the parent) and pinned:

  1. renderer.pick() consults the CPU node pick first and returns any node hit — parents included — without ever asking the edge tile (src/render/renderer.mts:552-557: cpuPickNode hit ⇒ return). The edge pick (cached tile or GPU pass) runs only when no node was hit.
  2. The node scan itself already knows better: cpu-pick.mts scans leaves first and lets parents answer only when no leaf hits ("a parent can never swallow its children's picks", src/render/cpu-pick.mts:77-79) — the tiering exists, it just is not exposed to the node-vs-edge combine.
  3. The correct order is the draw order, which v4 itself renders: parents under edges under leaves (the structural z-order the sheet docs record, debug/styles.js:16-18). What you see is what you pick is the pick pass's own stated contract (renderer.mts:2526-2527). So: leaf > edge > parent.
  4. Both gesture seats route through the same renderer.pick() (press at src/interact/pointer.mts:746, hover at :1579), so one fix covers click, tap-select, hover and the cursor work round 89 builds on hover.

97.1 — the three-tier resolve

cpuPickNode splits its answer into the tier it already computes (leaf hit vs parent hit). pick() returns a leaf immediately; otherwise it consults the edge path (cached tile, then the async pass) and returns the edge on a hit; otherwise the parent, if one was under the point. The parent slot is held across the await — the CPU scan does not re-run.

The cost is confined to clicks and hovers inside parent bodies: those go from synchronous-parent to awaiting the edge tile once (the tile is 64×64, cursor-centered, cached across picks at the same spot, and pick-only frames skip scene work — picking.mts:6-11, renderer.mts:53). Hover is already throttled (25 ms) and async end to end, so the added latency class already exists; the press path awaits today for plain-background edge picks, so no new state machine. Measure pick latency on em-web-clustered (the stats overlay prints it) before and after; the parent-miss fast path (point in no parent, no edge) must not regress at all.

Verified by a Playwright spec in the renderer project: an edge crossing a parent's body, pick/tap at its midpoint → the edge; tap beside it inside the parent → the parent; tap a child leaf → the leaf (the tier the fix must not break); the same three through a press-select gesture, since selection is where the defect was felt. Control: with the tier split reverted, the first assertion must go red. A headless-safe unit for the leaf/parent split lands in test/modules/ against cpu-pick directly (the module is pure over the view snapshot — no adapter needed). And the standing rule: drive ?network=em-web-clustered and click edges inside clusters.

Risks named at planning

  • pads semantics: the edge halo (edgePadPx) and node halo (nodePadPx) are v3's findNearestElement thresholds (57.9) — a padded parent hit must not beat an exact edge hit; the tier order resolves before halos widen anything, and the spec covers a padded gesture pick explicitly.
  • text-events/pointer-transparent nodes already fall through the CPU scan (cpu-pick.mts:99) — the tier split must preserve that fall-through on both tiers.
  • The hover path (round 89's cursor map rides it) starts answering "edge" where it answered "parent"; that is the fix working, but the hover-driven HOVERED flag styling on parents changes visibly — call it out in the round record and CHANGELOG (v3 behaved this way, so it is parity restored, not new behaviour).
  • A pick awaited mid-destroy must tolerate teardown — the existing pick promise path already does; the held parent slot must not outlive a compaction (re-validate the slot after the await, the epoch is at hand).

Both opens, decided by the maintainer (2026-08-27)

The two questions this plan left open were put to the maintainer with the draw orders measured on both sides first, because the answer to the first turned on a premise the plan had stated backwards.

The premise, corrected. The plan wrote that "v3 draw order says any node body above the edge layer wins", implying v4 has an edge layer that parents sit above. It does not, and neither library is shaped that way:

  • v4 draws every compound parent in one pre-edge stream, sorted (depth asc, slot asc) — src/render/cull.mts:150 ("compound parents draw in their own pre-edge stream", round 14.9) over HierarchyIndex.parentOrder() (src/store/hierarchy.mts:55-59, :214-231). So on v4's screen every parent is under every edge, at every nesting depth. There is no per-depth interleave to match.
  • v3 interleaves by compound depth. zIndexSort (v3/src/collection/zsort.mts:16-56) orders by z-compound-depth first and only within a depth puts edges under nodes (z-index-compare: auto); findNearestElements then walks that list in reverse draw order and takes the first hit (v3/src/extensions/renderer/base/coord-ele-math/coords.mts:328-337, returning near[0]). v3's pick order therefore is v3's draw order, and a depth-1 parent does beat a depth-0 edge there.

So "match v3" and "match what v4 draws" are different fixes, and the first would have pick answering a parent the renderer drew beneath the edge — breaking the pick pass's own WYSIWYG contract (renderer.mts:2526-2527), which is the contract this round exists to restore.

Call taken: the flat rule — leaf > edge > any parent, at every depth. It is the reverse of v4's actual draw order (parents, edges, leaves, labels) and therefore consistent with the screen, which is the UX test the maintainer set. Nesting depth changes nothing about the node-vs-edge combine; the deepest-parent-wins ordering within the parent tier stays exactly as cpu-pick.mts already computes it (reverse parentOrder()).

v3's per-depth interleave becomes a recorded deviation, and it is a consequence of a decision already taken rather than a new one: v3's order is driven by z-index and z-compound-depth, both of which v4 dropped outright on 2026-08-01 (the third design sitting; debug/styles.js:15-18 states the replacement — draw order in v4 is structural). A library with no z-index cannot reproduce an order derived from one. Also not ported, and recorded with it: v3's compound-only preference for an edge's connected nodes over the edge itself (coords.mts:245-249), which exists to soften the same interleave.

Call taken: yes, the tier order is documented. cy.pick's JSDoc states leaf > edge > any parent as contract — it ships as editor hover text through the shipped declaration and into the generated docs, and this defect is precisely a contract nobody had written down. The CHANGELOG entry carries it too, together with the visible consequence the risks list names: hover styling on parent bodies stops firing where an edge lies under the cursor.

Landed (2026-08-27)

The tier resolves leaf > edge > parent, in both hosts and at both gesture seats, and the contract is written down. Four things the plan did not have right are recorded below, because two of them were the work.

97.1 — the three-tier resolve. cpu-pick.mts gains pickNodeTierAt, which answers { slot, isParent }; pickNodeAt is now one line over it, so its ~30 existing callers (specs, benchmarks, the two hosts' pickNodeSync) are untouched and the parent pass still runs only when no leaf hits — the tiered form costs exactly what the plain one did. Renderer.pick() returns a leaf immediately (no GPU work, as before), holds a parent hit while the cached tile and then the GPU pass answer, and spends it only over background. WorkerRenderer.pick() does the same across the message channel, where it also has to: the node columns are canonical on the main thread and the worker only mirrors them, so only an edge id from the worker may outrank the parent held here.

The held slot is guarded by compactEpoch rather than by hope — compaction moves slots (19.4), so if the epoch changed across the await the scan re-runs against the current columns, which is cheap next to the roundtrip just paid.

The plan's premise about nesting was backwards, and the maintainer's call turned on it. The plan said "v3 draw order says any node body above the edge layer wins". There is no such layer: v4 draws every parent in one pre-edge stream sorted (depth asc, slot asc) (cull.mts:150, HierarchyIndex.parentOrder()), so every parent is under every edge at every depth, while v3 interleaves by compound depth (v3/src/collection/zsort.mtsz-compound-depth first, edges under nodes only within a depth) and picks by walking that same z-sorted list in reverse (coords.mts:328-337). "Match v3" and "match what v4 draws" were therefore different fixes, and the first would have picked a parent the renderer drew beneath the edge. Both opens were put to the maintainer with that measured (the decisions are recorded in full above): the flat rule at every depth, and yes, document it.

What the plan got wrong about the gesture seats, and what it cost. The plan asserted that "both gesture seats route through the same renderer.pick()", so one fix would cover click, tap-select and hover. The hover seat does. The press seat does not: onPointerDown resolves pan-vs-grab from the synchronous, nodes-only nodeAt, and only calls resolvePressTarget when that found nothing (mode === 'pan' && grabbed == null). With a parent under the cursor the sync pick answered the parent, the async pick was never started, and the release tapped down.grabbed — so cy.pick said "edge" while the click still selected the parent. The Playwright spec caught it in exactly that shape: the pick assertion went green and the selection assertion stayed red on p. Selection is where the defect was reported, so this was in scope, and it took two further changes:

  • A parent grab is provisional (DownState.provisional). It still starts synchronously, so dragging a parent body keeps its zero-latency feel, but the press also kicks off resolvePressTarget; if the edge tier outranks the parent before the press moves, the grab is dropped (dropProvisionalGrab) with the free/freeon that balances the grab/grabon already emitted (17.2), and the gesture becomes a pan — which is what a press on a bare edge is. A press that has already moved owns its drag and keeps it.
  • A release that did not move waits for that answer before it taps. A click can easily be faster than the tile, and the tap target is read synchronously at release, so without this the fix is invisible to exactly the gesture that reported it. The wait is confined to the provisional case; every other press taps synchronously as before. tap reads this.domEvent for originalEvent, and the listener wrapper clears it in a finally (41.4), so the DOM event is captured and restored around the deferred call.

Two residuals, deliberate and recorded (in src/README.md beside the fix): the pan-vs-grab decision and the taphold target still read the nodes-only sync pick, so a press-and-drag starting on an edge inside a parent drags the parent. The sync seat cannot see edges without the GPU tile — the same limit dragHoverPick already records — and deferring the grab itself would put a roundtrip in front of every parent drag, which is the cost this design exists to avoid.

Verification. test/cpu-pick.mjs grows five tier specs (leaf tier, parent band, nested-parent depth, the events: 'no' fall-through on the parent tier, background); playwright-tests/renderer.spec.js grows the pick spec (edge inside the parent → the edge; inside the parent clear of it → the parent; on a child → the leaf; off-graph → null) and the press-select spec that drove the seat finding. Controls, both run: with pickNodeTierAt's parent branch reporting the leaf tier, two headless specs go red; with renderer.pick()'s tier check reverted to "any node hit wins", both browser specs go red. Green: verify, the Node tier (test:node:quiet), and the full renderer project (144 passed, 1 skipped).

One repo-hygiene note for the next round: collapse import { x, type T } rather than adding an import type line beside it. The second line shifted src/render/renderer.mts by one and broke four throw-coverage specs against the MISATTRIBUTED allowlist's renderer.mts:150 — a line-numbered exemption is a tripwire on any import edit in an audited file.

The runtime rounds

The ask: first-class support for Bun and Deno in addition to Node, and a third round that scopes what other JavaScript environments are worth supporting. Orthogonal to round 49 (cross-platform validation is about OSes and GPU backends; this is about JS runtimes) and feeding rounds 50 (publishing) and 78.4 (the WebGPU-outside-a-browser investigation, which already names Deno's WGPU as a candidate). What the code does today, verified:

  1. src/ imports zero runtime built-ins and zero bare specifiers — no node: import anywhere, no dependency in package.json (dependencies is absent), and the one grep hit for a bare specifier (from 'fcose-gpu', src/layout/contract.mts:8) is inside a JSDoc example, not an import. The library is runtime-clean by fact — but not by gate: test/modules/import-graph.mjs classifies bare specifiers as "a dependency, not a repo edge" and skips them (its own comment, ~line 73), so a node:fs import added tomorrow passes the invariant that reads as pinning this.
  2. The headless path already speaks web-platform, not Node: TextEncoder/TextDecoder (src/wire.mts:228,438, src/store/id-map.mts:34-35), queueMicrotask (src/store/dirty.mts:153), performance.now() with a Date.now() fallback (src/animation.mts:2241), and the animation auto-driver already guards requestAnimationFrame behind a typeof check with a 16 ms setTimeout fallback (animation.mts:1741-1748). Everything named is in the WinterTC minimum-common-API baseline.
  3. The GPU algorithm tier needs no canvas and no DOM. src/algorithms/algo-gpu.mts says it in its header — "a headless instance can run GPU algorithms wherever navigator.gpu exists" — and gates on globalThis.navigator?.gpu (:44). Deno ships native WebGPU, so executor 'gpu' plausibly runs under plain Deno with no browser in the room. The renderer proper stays browser-bound (canvas + container, gpu-context.mts:32-35).
  4. Nothing runs any artifact of this repo under any runtime but Node and the Playwright browsers. engines says node >= 24, CI is ci-node + the ci-browser matrix (tests.yml:13,39), the Node tier runs src/ through tsx, and the standing note that almost nothing exercises the built bundles applies doubly here: no bundle has ever been loaded by Bun or Deno on this repo's watch.
  5. The test suite is node:test + chai behind a shim (test/node-test-setup.mjs), over .mts sources imported through .mjs specifiers with tsx doing the remap. Whether Bun's runner/Node-compat and Deno's do both of those jobs is a measurement, not an assumption, and the plan below treats it as one.

The split: round 98 makes the support true and gated (the invariant pinned, a cross-runtime smoke tier over the built bundles, CI). Round 99 makes it first-class (the native test runners measured, Deno's own adapter driving the GPU executors, the install and publish story). Round 100 is the scoping round for everything else.

Bun and Deno run the package: the contract pinned and smoked

98.1 — the runtime-clean invariant, pinned

test/modules/import-graph.mjs gains the assertion its header already implies: the set of non-relative specifiers under src/ is empty — which forbids node:*, bun:*, deno:* and bare package imports in one clause, and turns "runs on any standards-shaped runtime" from a fortunate fact into a gated one. Care point, named before it bites: the scanner is regex-over-text, and src/layout/contract.mts:8 shows a bare specifier inside a doc-comment example today — so the scan must strip comments (or join-then-classify, signatureOf-style) rather than grow a file:line allowlist that round 37.1 taught us goes stale by insertion. Control: add a node:path import to one module and watch the new clause fail; re-run with the import inside a doc comment and watch it not fail.

98.2 — the cross-runtime smoke, one file, three runtimes

test/runtimes/smoke.mjs: plain asserts, zero test framework, zero imports beyond the bundle under test — the same file runs as node smoke.mjs, bun smoke.mjs, and deno run --allow-read smoke.mjs, and the exit code is the contract. It loads the built bundles (ESM on all three; CJS on Node and Bun, and on Deno if its require-compat holds — a measurement recorded either way), which makes this tier the bundle-level coverage the testing notes keep saying barely exists. What it asserts, drawn from the tiers that must work headless:

  • factory + headless init with headlessWidth/headlessHeight set (the standing rule — a smoke that inherits 800×600 by luck is testing a different graph);
  • definition-form load and the wire round-trip, asserting each dictionary column still carries values after the trip — the round-46.5 lesson, verbatim, because a compat layer that hands back a subtly wrong TextDecoder produces exactly that plausible-looking graph with no labels;
  • style: a sheet with constants, scale mappers and a bypass compiles and reads back expected values (assert values, never completion);
  • layouts: grid + a few CPU-force ticks;
  • algorithms: one sync, one async through the promise tier with executor: 'cpu' — which also pins microtask/timer semantics (dirty.mts's queueMicrotask flush ordering) on each runtime;
  • events, json(), and the bypasses section export.

Wire it as test:runtimes:node / test:runtimes:bun / test:runtimes:deno npm scripts, each run-s build … so a stale bundle cannot pass for a fresh one (the 2026-08-06 lesson). Control: point the smoke at a bundle path that does not exist and at a deliberately degraded reader (the 46.5 dict-as-array control) — both must fail on all three runtimes, loudly, never a soft-skip (the parity-suite rule: a smoke that quietly stops running is worth less than one that is absent).

98.3 — fix what the smoke finds, budgeted

Expected small — item 2 above is why — but the budget is real and each fix lands with its assertion added to the smoke, so the fix is pinned where it was found. If a fix wants a runtime conditional, the shape is the animation driver's existing one (feature-test the global, never typeof Deno-style runtime sniffing): capability checks age well, identity checks are the UA-string mistake wearing a new coat.

98.4 — CI: ci-bun and ci-deno

Two new jobs in tests.yml, shaped like ci-node: checkout, official setup action, root npm ci, npm run build, run the smoke. No v3 install (the Node-tier invariant extends to these jobs). Version policy: latest stable plus a pinned floor, the floor recorded where the docs state support (98.4 writes the sentence, 99.3 gives it a home) — engines cannot express Bun or Deno and stays node >= 24. Before pushing, reproduce both jobs in a detached worktree with a fresh install — round 53's rule, and both of round 53's chronic failures were exactly fresh-checkout artifacts.

Risks named at planning

  • A compat layer can pass a smoke while differing subtly — which is why every smoke assertion is on values and ordering, not on "it didn't throw".
  • Bun and Deno move fast. A runtime bump that breaks the job is handled like a browser bump breaking a golden: read the failure, then move the pin — never widen the assertion.
  • The smoke file must stay import-free and framework-free or it silently becomes a fourth test tier with its own compat needs; a test/modules/ spec can lint that (its import list is enumerable).

Open: whether the smoke also loads the minified bundles (recommended: yes for ESM-min — it is what CDN users run, and the cost is one more import); whether ci-bun/ci-deno gate merges from day one or observe for a week first (recommended: gate — an observing job is a soft-skip with extra steps).

Landed (2026-08-28)

Landed as planned, both opens taken as recommended: the smoke loads the minified ESM too (it is what CDN users run, and the cost was one more import), and ci-bun/ci-deno gate merges from day one (an observing job is a soft-skip with extra steps).

98.1, as shipped. test/modules/import-graph.mjs gained the clause its header always implied: the set of non-relative specifiers under src/ is empty — one clause forbidding node:*, bun:*, deno:* and bare packages alike. The scanner strips comments first with a string-aware character walk rather than another regex (the plan's care point: a regex stripper eats '//' inside a string and any import after it on the line), and the specifier pattern also matches bare import '…' and dynamic import('…') forms, so the clause covers shapes v4 has none of today. Both planned controls run and behaved: a real node:path import added to src/math.mts failed the clause naming the file; the same line inside a doc comment passed clean — no file:line allowlist anywhere, and the stripper carries its own inline control spec.

98.2, as shipped. test/runtimes/smoke.mjs: one framework-free file, plain asserts, run as node smoke.mjs, bun smoke.mjs and deno run --allow-read smoke.mjs, exit code as the contract. It loads the ESM, minified-ESM and CJS bundles and runs 46 value-and- ordering assertions per bundle: headless init with headlessWidth/headlessHeight set; the definition-form load and the wire round-trip with every dictionary column checked value-for-value (the 46.5 lesson verbatim); a sheet with constants, a linear scale mapper and a bypass read back as values; grid plus five CPU-force ticks; bfs depths sync and pageRank async with executor: 'cpu' (which pins the promise/microtask tier per runtime); event dispatch order and arguments; json() and the bypasses section export. The require-compat measurement the plan asked for: Deno's holds — 2.9.6 loads the CJS bundle through createRequire — so CJS is asserted on all three runtimes, contract rather than bonus. One deviation from the plan's letter, recorded: "zero imports beyond the bundle under test" became two — loading a CJS bundle from an ESM file needs node:module's createRequire plus node:url's fileURLToPath, both of which all three runtimes provide — and the risk the plan named (the smoke silently becoming a fourth test tier) is handled exactly as it suggested: test/modules/runtime-smoke.mjs enumerates the import list, so a third entry is a red build. That spec also pins the scripts' shape (test:runtimes:node/:bun/:deno, each run-s build … per the 2026-08-06 lesson, each with a registered quiet twin) and runs both planned controls as specs on Node; the controls were additionally run by hand on all three runtimes — a missing bundle path and the dict-as-array degraded reader both exit 1 loudly everywhere, the latter failing on a.label: expected "alpha", got undefined, the 46.5 no-labels graph by name.

98.3, unspent. The smoke found zero defects on Bun 1.4.0 and Deno 2.9.6 — all three bundles, all 46 assertions, both runtimes, first run — which is what item 2 of the runtime-rounds note predicted: the headless path already spoke web-platform, and the budget existed in case it did not. No runtime conditional was added anywhere; the animation driver's capability check remains the only one in the tree.

98.4, as shipped. ci-bun and ci-deno in tests.yml, shaped like ci-node (checkout, official setup action beside setup-node, root npm ci, npm run build, the smoke under the runtime; no v3 install), each a two-entry matrix of latest stable plus the pinned floor — Bun 1.4.0 and Deno 2.9.6, the versions measured green at landing. engines stays node >= 24; the support sentence lives in src/README.md's new "Runtimes" section until round 99.3 gives it its documentation-site home. Both jobs were reproduced before landing per round 53's rule — a detached worktree, fresh npm ci, npm run build, then the smoke under all three runtimes: green, FRESH-REPRO clean.

Bun and Deno, first-class: the native runners measured, Deno's adapter, the install story

99.1 — the full suite under the native runners, measured

Can Bun and Deno run the real Node tier — node:test + chai + the .mjs.mts remap tsx does today? This is an experiment with a written record, not assumed work. Method: run test:js's glob under each runtime's Node-compat, and compare the executed test count to Node's — the enumerator rule, because the failure mode to fear is not red, it is a runner that skips what it cannot parse and reads green at a fraction of the suite. Acceptable outcomes, pre-declared: (a) a runtime runs the suite whole, and a CI job is added running it; (b) it cannot, the record says exactly where (the shim? the remap? chai?), and the 98.2 smoke stays the cross-runtime contract — the suite remains Node's. Both are fine; a silent half-run is the only failure.

99.2 — Deno's native WebGPU drives the GPU executors

The flagship: the round-65 async algorithm tier with executor: 'gpu' under plain Deno — no browser, no Dawn-in-Node build, algo-gpu.mts already gating on navigator.gpu alone. Go/no-go criteria written before the probe, 78.4-style: pipelines compile on our WGSL set; CPU-vs-GPU parity holds by reusing the algorithms-gpu parity spec shape (same fixtures, same bounds) in a Deno-runnable form; the adapter is identified in the record (the benchmark tier refuses SwiftShader for pricing — the same honesty here: name Deno's backend, and note wgpu is not Dawn, so this is also the first non-Dawn WGSL compile our shaders get). Findings feed 78.4's decision memo either way — a go here is the cheapest "WebGPU outside a browser" answer on the table. Standing rule adapted: no "blocked, no adapter" without a probe from a real Deno script with the permission flags right.

99.3 — the install story and the publish surfaces

  • Docs: an install section covering npm, pnpm, yarn and bun, plus Deno's npm:cytoscape@^4 specifier and the CDN <script> form — landing in src/README.md/MIGRATING.md now and in the round-46 site when it builds (round 46's plan carries the matching bullet as of today). One snippet per manager, kept adjacent so drift is visible.
  • A runtime-support statement with the tier language round 100 firms up: which runtimes are CI-gated, at what floor.
  • JSR: a decision memo, not a decision — the 78.3 shape, because publishing is round 50's to own. Priced options: npm-only (Deno consumes npm: fine today), or an additional JSR publish (what it wants from dist/, what it does to the release workflows, who asks for it).

99.4 — close

CHANGELOG row, MIGRATING.md (the headless/cytosnap audience overlaps the server-runtime audience), AGENTS.md gains the smoke tier under Development flow, EXECUTIVE_SUMMARY.md rewritten per the standing rule, gates green.

Risks named at planning

  • 99.1's compat surface is the moving target 98 already named, squared — pin the versions the record was taken on, and date the record; a "Bun cannot X" sentence is a claim future rounds must re-measure, per the plans-are-claims rule.
  • 99.2 can eat unbounded time chasing wgpu/Dawn divergence; the pre-written go/no-go and the scratch-tree constraint (no src/ changes from an investigation) bound it, as they bound 78.4.
  • Coordination: ledger item 29's worker pool spells its workers node:worker_threads / browser Worker — if round 74 lands first, its runtime seam should prefer the Web Worker API where it exists (Bun and Deno both have it; Node does not), or the pool becomes the one v4 feature that is Node-shaped. Named here so neither round discovers it in review.

Open: whether a green 99.1 job gates or only ci-node does (recommended: the smoke gates everywhere, the full-suite jobs gate only where they run whole); whether 99.2's parity subset joins CI on a Deno runner or stays a recorded manual probe until GitHub's runners say what adapter they give it; the JSR memo's recommendation (leaning npm-only until someone asks).

The runtime horizon: which other JavaScript environments are worth supporting

An investigation round with a written record — the WebGL-scoping shape. No src/ changes except where a one-line capability guard buys a whole environment (the animation.mts rAF-fallback shape), each such line with a spec.

100.1 — the capability ladder, stated once

What each tier of v4 actually needs, so environments are judged against requirements rather than vibes:

  • T0 — headless core (store, wire, style, layouts, CPU algorithms, json(), and round 77's svg() when it lands): the WinterTC baseline v4 already confines itself to — typed arrays, TextEncoder/Decoder, queueMicrotask, timers.
  • T1 — + Web Workers: the round-74 pool.
  • T2 — + WebGPU: the GPU executors (no canvas — 99.2's tier), then the renderer's export path if 78.4 goes.
  • T3 — + DOM/canvas: the full renderer, glyph atlas, image decode, gestures — browsers and browser-shells only.

100.2 — the candidates, each run through the 98.2 smoke

Measured, not assumed — the smoke is the instrument, and for each environment the record says which tier it reaches and names the first failing assertion when it misses:

  • Cloudflare Workers / workerd (locally via the wrangler dev runtime): the real use case is server-side layout, metrics and svg() at the edge; the thing to measure is T0 under the CPU budget an isolate actually grants, on a real fixture, not a toy.
  • Vercel Edge and friends — workerd-adjacent; record, do not re-investigate.
  • Electron / Tauri-with-Node-sidecar: expected to be Node + Chromium wearing a trenchcoat; verify with the smoke and one renderer sanity check, one line of record each.
  • React Native / Hermes: T0 would put the graph model and algorithms in apps; Hermes' standard-library gaps are exactly what the smoke enumerates.
  • Embedded engines (QuickJS, GraalJS): long tail; run the smoke where it is cheap, record-only, no support claim.
  • Service workers / worklets: T0/T1 contexts inside the browser; round 86 owns the worker-hosted renderer, so this round only checks the model tier loads there.

100.3 — the deliverable: a support matrix with teeth

A tiered statement, docs-side: Tier 1 — CI-gated (Node, Bun, Deno, the Playwright browsers); Tier 2 — expected-to-work (WinterTC-baseline environments; the smoke is run against them at release time, the round-51 bake being the natural first occasion); Tier 3 — recorded as unsupported with the failing assertion named, so the answer to "does it run on X" is a link, not a shrug. Anything that earns real work becomes a ledger item with its measurement attached, not a bullet in this round.

Risks named at planning

  • The environment zoo is unbounded; the pre-agreed candidate list and the ladder bound it, and "record-only" is a legitimate verdict.
  • A smoke that completes in an exotic environment with subtly wrong values is the 98.2 risk again, and the same answer applies: the smoke asserts values and ordering, so "runs" means "computed the right numbers".
  • Publishing a support matrix creates an expectation of maintenance; Tier 2's release-time cadence is the deliberate ceiling, and the matrix says so in its own text.

Open: whether workerd joins CI as a Tier-1½ (cheap and high-signal if the wrangler runtime is stable on runners); whether the matrix lives in src/README.md or becomes a docs-site page in round 46 (recommended: README now, page at 46); whether React Native demand justifies a tracked example app (default no — wait for an issue with a real use case).

Quiet verification: the scripts agents run print only failures

The ask: quiet variants of the verification scripts — npm run test:quiet and friends — that agents run instead of the loud ones, so a green suite does not spend thousands of lines of context saying so. Two constraints, set by the maintainer at planning: no dot reporters, and the output of any run is only actual failures — a green run prints nothing at all where the tooling allows it, the exit code is the contract, and a red run prints the failing tests and nothing else: no pass lines, no suite headers, no progress marks, no tallies, no diagnostics. Tooling-only; no src/ changes.

What a green run costs today, measured 2026-08-20 (lines / bytes of combined stdout+stderr, npm's banner included):

  • test:js3,072 lines / 180 KB: the default reporter writes a ✔ line for each of 2,245 passing tests plus every suite header, and none of it is information — a green run's entire content is "nothing failed".
  • test:modules — 661 lines / 39 KB (same reporter, plus the build step it runs first).
  • test:soak 44 lines, test:throws 9, build 11, lint 6, typecheck 4 — the audits and compilers are already terse; most of their residue is npm's banner and a success summary.

So test:node green is ~3,800 lines / ~226 KB, 98% of it the two big node:test tiers. The failure path was probed too (a two-test file, one failing): the runner's end-of-run "Failed tests" section carries the failing test's name, its location and the assertion diff — everything a reader acts on is in that section, so printing only it loses nothing. Node's built-in dot reporter was measured for scale (133 lines / 2.8 KB on the same green test:js) and is ruled out by the first constraint: dots are still noise, and silence is the success signal.

101.1 — the failures-only reporter

test/quiet-reporter.mjs, wired as --test-reporter=./test/quiet-reporter.mjs: consumes the runner's event stream, emits zero bytes while everything passes, and on failure prints each failing test's block — name, file, assertion message and diff — and nothing else. Passing tests, suite headers, diagnostics and the tally are all dropped; red is recognisable by the exit code and by the presence of any output at all. One question the events API answers at implementation: a test body's own console.log arrives as test:stdout/test:stderr events, so the reporter can replay a failing test's output inside its block while dropping the green tests' — the loud run stays the tool for watching a suite think.

101.2 — the capture wrapper for tools with no quiet mode

scripts/quiet-run.mjs, invoked as quiet-run -- <command …>: spawn the command, capture both streams, exit with the child's code — on zero print nothing, on nonzero replay the capture byte-for-byte. The uniform answer for tools that own their own output — rolldown's bundle table, oxlint's summary, tsc (silent on success already; its npm banner is not) — and for composing tiers. Known cost, accepted: a hung child shows nothing until it is killed; the diagnosis for a hang is a rerun of the loud twin, and the wrapper does not try to be a progress UI.

101.3 — the variants, the Playwright reporter, and the docs

  • Every verification script gains a :quiet twin — test:js:quiet, test:modules:quiet, test:soak:quiet, test:throws:quiet, lint:quiet, typecheck:quiet, build:quiet — plus the composites test:node:quiet, test:playwright:quiet and test:quiet, built from the quiet pieces with the same structure as the loud ones (test:modules:quiet still builds first, and so on).
  • A quiet twin is the same command modulo the reporter flag or wrapper prefix — enforced, not hoped: test/modules/quiet-scripts.mjs parses package.json and asserts each pair differs only in those tokens, because a twin that drifts (a quiet variant losing its build step, say) fails green in the worst way.
  • Playwright: the same principle as 101.1, as a small custom reporter (playwright-tests/quiet-reporter.js — print failures as they land, nothing on green), since no built-in reporter is silent on green without also being silent on red.
  • npm's banner (two lines per npm run) belongs to the caller; npm run -s drops it, and whether -s on the outer call silences the nested run-s children's banners too (loglevel should inherit through the environment) is measured at implementation and the answer written into AGENTS.md.
  • CI stays loud, deliberately: its logs are the record, read after the fact, and a red CI run wants the full transcript.
  • AGENTS.md's development-flow list is rewritten to name the quiet twins as what agents run; the loud originals remain for humans watching progress, for debugging, and for CI.

Controls named at planning

  • Green is zero bytes, asserted as zero — the reporter spec runs a passing file and compares captured output to the empty string, not to "short".
  • Red names the failure — run a deliberately failing file and assert the output carries the test's name and the assertion message. The round's most important spec: a quiet reporter that swallows failures is strictly worse than the noise it replaced, and (the round-27 rule) it runs once with the reporter's failure branch broken to prove it can fail.
  • Exit codes survive both layers — reporter and wrapper: green exits 0, red nonzero, and the wrapper's replayed output matches the unwrapped run's byte-for-byte.
  • The twins cannot drift — the quiet-scripts spec above, controlled by desynchronising one pair and watching it fail.

Open: whether the interactive scripts (test:js:debug and kin) are explicitly exempt (recommended: yes — quiet is for verification, not debugging); whether the status build and the benchmark runners join later (default no — agents run those for their output).

Landed

The plan above, implemented as specified, with one deviation the events API forced (101.1) and one measurement the plan left open (101.3), both recorded here.

101.1 — the failures-only reporter (2026-08-24)

test/quiet-reporter.mjs, wired as --test-reporter=: zero bytes while everything passes, and on failure one block per failing test — suite path, file:line:column, the unwrapped error (the assertion message and diff), and the file's captured output. Suite echoes are dropped by failure type (subtestsFailed, cancelledByParent), so a failing test inside a describe prints one block, not two.

The deviation: the replayed output is the failing file's, not the failing test's. The plan hoped test:stdout/test:stderr events would let the reporter replay a failing test's own output while dropping the green tests' — measured at implementation, they do not. An event dump over the red fixture shows both tests' writes arriving before the first test:pass (the child's stdout pipe is decoupled from its result stream), so clearing the buffer on pass drops a failing test's own noise, and per-test attribution does not exist at this API. Whole-file replay never loses failing output; the price — a green neighbour's noise rides along — is paid only on red, and the spec asserts the behaviour so a future "improvement" that quietly reintroduces pass-clearing fails.

One trap worth recording for any spec that spawns the runner: this spec itself runs as a node:test child, and a spawned node --test that inherits NODE_TEST_CONTEXT behaves as another child — serialised events, no reporter, exit 0 with empty stdout, which reads exactly like the reporter being broken. The spec strips the variable from the fixture runs' env.

101.2 — the capture wrapper (2026-08-24)

scripts/quiet-run.mjs, as planned: spawn, capture both streams, exit with the child's code; on zero print nothing, on nonzero replay byte-for-byte on the right streams. Byte-for-byte is asserted by comparison against an unwrapped run of the same command. Also pinned: -- is accepted, no command is usage + exit 2, a missing command is exit 127 naming the command.

101.3 — the twins, the Playwright reporter, the drift gate (2026-08-24)

Fifteen :quiet scripts: build, lint, typecheck, test:js, test:modules (+:run), test:soak, test:throws, test:playwright (+:install, :build, :build:v3, :run), test:node, test. Reporter tiers differ from their loud twins by the one --test-reporter= / --reporter= token, tool tiers by the node scripts/quiet-run.mjs prefix, composites by :quiet on each step — and test/modules/quiet-scripts.mjs normalises those tokens away and asserts equality, enumerating in both directions (every registered pair exists; every *:quiet in package.json is registered), so a drifted twin or a stray one fails the build.

The Playwright reporter is playwright-tests/quiet-reporter.mjs.mjs, not the plan's .js, because the Node spec that drives it imports it through tsx, which treats a .js in this no-"type" package as CJS; Playwright loads .mjs reporters fine. It buffers each test's stdio by test id (Playwright does attribute, unlike node:test), prints a failing test's block on its final attempt only, always prints worker-level onError, and takes an injectable write sink so its spec never touches the real stdout. Driven directly with the shapes Playwright hands a reporter, in the Node tier — spinning a real browser suite up to red is what it saves.

The npm-banner question is answered: npm run -s silences the nested banners too. Measured with a run-s composite: loud is 89 bytes of banner; npm run -s is zero bytes end to end, because loglevel inherits through the environment into every nested npm run the run-s children spawn. So the agent-facing invocation is npm run -s <script>:quiet, and AGENTS.md's development flow now says so.

The reporter file lives in test/, so the test:js glob (and its test:js:debug and scripts/throw-coverage.mjs copies) grew a third exclusion: !(types-*|node-test-setup|quiet-reporter).

101.4 — measured, and the controls (2026-08-24)

The headline numbers, same measure as planning (combined stdout+stderr):

green run before after (npm run -s …:quiet)
test:js 3,072 lines / 180 KB 0 bytes
test:node ~3,800 lines / ~226 KB 0 bytes

Zero is asserted, not observed: the reporter spec compares a green run's captured streams to the empty string, and the full test:node:quiet was run end to end and measured at 0 bytes on both streams, exit 0. The first end-to-end run was red, and usefully so: it printed one block — 920 bytes naming status-site.mjs's planned-paths gate, which had gone red because this round's own files began resolving (the exact lifecycle that gate enforces) — and nothing else. The failure mode the round exists for, demonstrated on its own change.

The four controls named at planning, each run broken and restored:

  • reporter's failure branch deleted (the yield dropped) — 4 red-run specs fail, the green-run specs stay green, which is the point;
  • wrapper's replay branch disabled — the byte-for-byte spec fails;
  • one twin desynchronised (test:modules:quiet loses its build step) — the drift gate fails on that pair;
  • Playwright reporter's failure branch short-circuited — 3 of its 5 specs fail.

The open questions resolved as recommended: the interactive scripts (test:js:debug, test:modules:debug) have no quiet twins — quiet is for verification, not debugging; the status build and the benchmark runners stay loud — agents run those for their output. CI stays loud, as planned.

The ecosystem rounds

A sweep for new rounds serving performance and the flagship apps (Cytoscape Web, GeneMANIA, EnrichmentMap web), checked against rounds 71–101 and ledger items 30–50 so nothing here duplicates scheduled or logged work, and every premise below verified against the source before planning (the round-37 rule). The maintainer approved all six in direction on 2026-08-20; each plan is a first independent expansion, to be refined with maintainer feedback before scheduling — the open-questions lists name the decisions that need that feedback, and nothing below is committed until a plan survives it.

One decision taken the same day, recorded because the tenth sitting had left it provisional: CX2 import/export stays out of core. The rationale, the maintainer's: it is too big, and the core-versus-extension line has to be drawn somewhere — a format conversion layer carrying its own visual-style model is the textbook extension. If it gets built, round 71's toolchain is the vehicle and item 50's port list is where it slots; no core round will take it.

Who each round serves: 102 and 104 are the visible-app-impact pair (every flagship app's hover idiom; labels at scale); 103 is perceived load time at app scale; 105 is GeneMANIA's signature shape plus the fixture that anchors it; 106 unblocks the item-50 navigator/minimap tier and the round-47 devtools direction; 107 is server-driven apps and item 46's React wrapper.

Transient emphasis: hover highlight without touching the sheet

The gesture every flagship app implements: hover or select a node, emphasize its neighbourhood, dim everything else, restore on leave — re-evaluated on every mousemove. What v4 gives them today, verified:

  • The interact tier already runs a throttled hover pick and drives FLAG_HOVERED (src/interact/pointer.mts hoverPick, ~:1563; ::hovered in src/contract.mts:137), so the hovered element itself is sheet-addressable state.
  • The other half — membership of "the hovered neighbourhood" and "everything else" — is not expressible as a state condition: v4 has no classes by decided design (src/README.md:1303), so an app must write per-element state on every hover change — round-63 bypasses over the neighbourhood plus a dim over the rest, or a data field every element's mapper reads. Both are whole-graph writes per mousemove: the exact shape round 60.4 had to rescue for select (banded select+unselect 53.7 µs on the diff path against 392 µs when a lone id clause nulled the partition).

Two candidate designs, both carried to the measurement because they trade different things:

  • (a) Emphasis as store state. New flag bits (FLAG_EMPHASISED, FLAG_DIMMED — or one bit with "dimmed is everyone else" as a sheet-side reading), a bulk setter (cy.emphasize( eles ) / eles.emphasize()) that writes the flag delta as one diff pass, and the sheet conditions on the new pseudo-states like any other. Reuses the 60.4/61 diff machinery, keeps stored truth and style() readback honest, and the app decides what dimming looks like in its own sheet. Cost: a banded style reapply per hover change, and two contract bits (a src/contract.mts-first change, its own rule).
  • (b) Emphasis as a renderer overlay. A per-element u8 column consumed by the shaders as a compositing factor (dim multiplies toward a configured colour/alpha; emphasized draws unchanged or brightened — the shader hover-brighten precedent exists), written straight from a collection and never entering the style engine. Cheapest per-event cost by construction, but it is a second styling mechanism beside the sheet — the thing rounds 8/29.3 spent removing — and style() readback would not see it, which demands an explicit "transient view state, never truth" contract if it is chosen.

The measurements decide, through the built bundle at ndex-x-large scale (19,607 nodes / 464,657 edges):

  1. The app spelling today: bypass-based neighbourhood emphasize + rest-dim per hover change — µs per mousemove, and whether it holds a moving pointer at 60 fps. If this already fits the frame budget, the round collapses to a documented recipe plus at most the bulk-setter sugar.
  2. Design (a): flag delta + banded reapply for a typical neighbourhood (degree ~10) and for a hub, since the hub is what an app actually hovers.
  3. Design (b): column write + upload cost, and the full-frame shader cost of the multiply.
  4. The query itself: neighborhood() per mousemove at that scale — if the query dominates, the fast path needs a slot-native neighbourhood walk, not a style mechanism.

Controls named at planning: the perf spec asserts the shape — per-event cost O(neighbourhood), not O(V), pinned by measuring two graph sizes and asserting the ratio; if (b) is chosen, the dim look gets a golden built the round-56 way (a scene where the dim is what the pixels measure, degrade control proving it).

Open (maintainer): whether dimming is a style concern (design a, sheet-visible) or a view concern (design b, overlay) — the same instinct as the CX2 line, applied inward; whether the setter is core API or the first cyext gesture example (round 71 wants a non-layout validation case); naming — emphasize / highlight / spotlight — and whether ::dimmed is derived or set; whether select gets the same treatment ("dim unselected") in the same round.

Progressive ingest: a first frame before the last byte

Rounds 66/67 took monolithic init from 2899 ms to 1756 ms and then to 622 ms headless (1.92× in one A/B), but the pipeline shape is unchanged: fetch → parse → init → first frame, strictly serial, first pixel after the last byte. For app-scale loads — GeneMANIA results over a slow link, Cytoscape Web sessions — the next factor of perceived speed is not another 2× on init, it is showing a correct partial graph early. The verified constraints that shape the design:

  • A wire/columnar payload is self-contained by construction: edge endpoints are u32 indices into that payload's nodes (src/public-types.mts:90-97; the wire sections mirror it), so a later chunk's edges cannot name an earlier chunk's nodes at all today. Chunking therefore means one of: vertex-closed subgraph chunks with cut edges carried in definition form (id-keyed, the slow path, but it works today); or an id-keyed endpoint mode in the columnar/wire ingest — a format evolution that belongs with item 43's version-header work, not alone.
  • The round-67 browser decomposition (fetch 105 / parse 175 / convert 105 / init 1150 / ready 100 / first frame 85–400 ms) is the baseline instrument and stays the harness for this round; the wire path already removes the parse row.

The plan, measure-first:

  1. The zero-format-change baseline: split ndex-x-large into k = 10 chunks, cytoscape() on chunk one, cy.add() per subsequent chunk, cut edges as definitions. Measure time-to-first-frame, total time versus monolithic (the churn factor), and where the churn lands — per-add style apply, curve re-derivation (CurveIndex re-derives a pair when a member arrives), renderer reallocation cadence under 10× growth. This number decides whether the round is an API round or first a churn-fixing round.
  2. The API sketch, refined after (1): a chunk-accepting load — cytoscape( { elements: asyncIterable, ... } ) or an explicit cy.load( stream ) — with a viewport policy stated up front (fit once on the first chunk, then hold; never re-fit per chunk — the screen must not jump), progress events per chunk, and cy.ready meaning "first chunk rendered" with a second signal for "complete" (naming open).
  3. Positions: the streamed case that matters ships server-computed positions (preset), which is both flagship apps' shape. Running a generated layout per chunk is explicitly out of scope; one open question below covers the layout-after-complete convention.
  4. The wire evolution decision, taken jointly with item 43: if (1) shows cut-edge definitions dominating, the id-keyed endpoint mode (or a row-group segmented format) becomes the payload of promoting the format, and the two rounds should merge rather than evolve the header twice.

Controls: the chunked load's end state must be columns-equal to the monolithic load of the same fixture (the round-42 method applied to store state, not files); the first-frame spec asserts a frame rendered while a later chunk is knowably absent (assert the precondition, the 48.5 rule).

Risks: event semantics are public API (what does add batching look like per chunk; does a layout started mid-stream see a moving target — recommended: refuse or queue); a progressive render shows an incorrect partial graph by design, and the docs must say what is guaranteed (every rendered element is correct; completeness arrives).

Open (maintainer): API spelling (options-form async iterable versus explicit load()); the ready/complete event names; whether chunking joins item 43's public format now (one header evolution) or stays app-side (the app slices its own subgraphs); minimum chunk granularity worth supporting before overhead eats the win.

Label decluttering: priority and collision at scale

Verified: label LOD is zoom-fade only — labelFadePx / labelMinPx (src/public-types.mts:537-545) — and nothing in src/render/ knows whether two labels overlap. At fit zoom on a large graph the label layer is soup, which is why apps hide labels wholesale; GeneMANIA's actual requirement is sharper and better: the top-ranked genes are always labelled. v3 has nothing here either, so this is a v4 deviation-by-addition, documented as such.

Three pieces:

  • Priority, data-driven. A style property (label-priority, mapper-able — data( score ), degree, or a constant per group), so importance comes from the graph, not from insertion order. Ties break by slot for determinism.
  • The cull. A screen-space occupancy pass over the drawn labels, highest priority first — greedy grid, not exact geometry: a label claims its screen rect's cells, a lower-priority label that would land on claimed cells is culled (hidden, not faded — a half-faded loser reads as a rendering bug). Runs on viewport settle and on label dirtiness, CPU-side first: the drawn-label count at fit is what the census below measures, and a GPU variant is a logged follow-up only if the CPU pass misses budget.
  • Stability. Hysteresis — a shown label keeps its claim until the challenger beats it by a margin — so slow pans do not strobe winners; winners deterministic across frames at fixed viewport. This state is renderer-local by contract (never stored truth, never serialized, never readback).

Measure first:

  1. The census: drawn labels at fit on em-web and ndex-x-large, and what fraction overlap another label's rect — the number that says how bad the soup actually is.
  2. The greedy pass's cost at those counts (it is a sort plus a linear claim walk; the sort is the suspect).
  3. A scripted-pan flicker probe: winners across 60 frames of a slow pan, count of flips without hysteresis and with.

Sequencing note: rounds 38 (CJK) and 94 (label fidelity under zoom) touch the same pipeline; this round is orthogonal to both (it decides whether a label draws, they decide how), but the three should not interleave mid-flight.

Controls: the cull spec renders a dense scene with the cull disabled and asserts the overlap count jumps (the control is the census re-run); the priority spec swaps two elements' priorities and asserts the winner swaps; the fade interaction is pinned by a spec at the fade boundary — cull decides membership, fade decides alpha, and the order (cull sees the pre-fade set) is asserted, not assumed.

Named file: src/render/label-declutter.mts.

Open (maintainer): default off (recommended for 4.0 — opt-in via label-declutter: cull, default none) or on; whether edge labels join in the same round (they overlap worst, but their rects move with routing); the property names; whether the priority property should also drive the fade order at the LOD boundary (probably yes, and cheap, but it changes an existing behaviour).

Parallel edges at GeneMANIA width, and a GeneMANIA fixture

GeneMANIA's signature look is tens of parallel edges per gene pair, one per interaction network, coloured by network type. Verified: v4's bundling is v3's verbatim (src/store/curve-index.mts — pair-keyed membership, lazily built, per-member offsets) with no cap on bundle width, and haystack ships (haystack-radius, CURVE_HAYSTACK). So the premise is not "a feature is missing" — it is that nothing has ever measured or even rendered the width-30 shape in this repo, and the pair map, derivation, pick and draw all have width-dependent costs nothing prices.

The work:

  • The fixture first. A real GeneMANIA result network with a hand-authored v4 sheet joins debug/styles.js and the networks list — the em-web pattern, where a real flagship sheet became the anchor for the style benchmarks and the harness's most-opened page. If the export needs slimming, the derivation is recorded the debug/slim-ndex.mjs way — a re-runnable script, not a mystery blob. The fixture is also the visual acceptance test: opened beside genemania.org.
  • The width sweep: benchmark/bundles.mjs — ingest, curve derivation, a render frame, and pick, at bundle widths 2 / 8 / 32, v3 beside, each row asserting the width it is named for (the 39.1 rule: a fixture can be styled into a mode it never enters, and bezier bundles multi-edges only).
  • Pick on dense bundles: with 30 edges in one corridor the hit halos (57.9) mean many candidates within threshold — what v3 resolves, what we resolve, and whether the answer is stable frame to frame. A spec per outcome, not a shrug.
  • The look: a parity scene with wide bundles at both tiers — zoom 1 and the round-56 close-up — built by the count-the-ends rule (more members, not fatter ones), plus a golden. The offsets formula is v3's, so parity should be tight; if it is not, the diff names the field via the routing harness, which already speaks controlPoints().
  • A priced question, not a feature: whether wide bundles deserve LOD aggregation (draw one representative edge per bundle below a zoom threshold). If the sweep shows width dominating frame cost at GeneMANIA scale, that finding is logged toward round 82's proxy tier with the number attached; building it here is out of scope.

First measurement: the sweep, and the fixture on screen beside the real app — in that order, so the numbers exist before opinions do.

Open (maintainer): which GeneMANIA export (organism, query size) makes the canonical fixture; whether the fixture's sheet uses per-network colour via dictionary data column (the natural v4 spelling) — worth deciding deliberately since it becomes the reference sheet for the multi-edge idiom; whether haystack at width belongs in the sweep (v3's answer for this shape at scale) so the docs can recommend a mode by number.

N viewers, by cloning

Replanned 2026-08-27 with the maintainer. This round was first expanded as "N viewers, one store": consumer cursors in the DirtyTracker, a view-state inventory, and a GraphView spike — per-view renderer + viewport over the one shared store. The measurement pass for that plan was taken (the inventories below), and then the maintainer raised the Cytoscape desktop lesson: multiple views of one core caused an explosion of complexity there, because every subsystem had to become view-aware. Three architectures were evaluated against the measured code, and the round now takes the third. Two calls taken (2026-08-27):

  1. Direction: clone + reconcile. A second "view" is a full independent instance — cy.clone() over the existing serialize/ingest path — kept current by round 107's id-keyed reconcile, driven by a throttled consumer of the master's dirty stream. Every instance keeps v4's one-core / one-viewport / one-renderer invariant, and no "whose viewport?" semantics ever enter the public API.
  2. A clone owns its state. Selection / hover / grab / active are not synced; linked brushing is explicit app wiring over the two instances' events (an opt-in sync flag can come later if an app proves the need).

The evaluation (what the measurements said)

What the shared-store route actually costs. The single-consumer inventory found the assumption far wider than dirty.mts's documented drain: six drain-once channels a second reader would starve — takeDelta itself (plus the four blob pools drained inside it, graph-store.mts:581-611), takeMapperSpans (graph-store.mts:1057), takeLabelDirty (graph-store.mts:4109), and ImageRegistry.takeReady/takeFreed (image-registry.mts:256,269) — plus three singleton leases: the animation clock (attachDriver, one sink, host.mts:105-114), the image decoder (setDecoder, last-writer-wins, cleared unconditionally at renderer destroy), and GPU column ownership (setGpuOwned/setTweenOwned — a column authoritative on view A's device is stale on view B's mirror). And one genuine model/view mixing: hover/grab/active are flag bits in the shared store (contract.mts:33-37), so hover in one view styles the element in every view. Each item is solvable — a full five-commit design was drafted (drain-and-republish cursors; label/image/clock leased to the primary; a GraphView reusing Renderer wholesale over a per-view RenderHost) — but the list is the desktop direction in miniature: view-awareness seeping into channel after channel. Kept as the fallback design if same-frame fidelity is ever proven necessary.

What a full live replica would cost. The worker path's RemoteModelView proves span-level replication works — but it is a render-only subset by construction (RenderStoreView: no id map, no data store, no adjacency, no hierarchy, no selectors, no style reads). Feeding a full GraphStore by spans needs a structural op stream — slot allocation, free lists, compaction remaps — that does not exist. The most new machinery, plus the 2x memory anyway. Rejected as the worst of both.

What the clone route already has. cy.serialize() (round 46.5) covers positions, ids, parent links, endpoints, selected/selectable and data(); at ndex-x-large (19.6k nodes / 465k edges) it is 9.2 MB, ~5 ms decode, ~80 ms columnar ingest, then the whole-graph style apply. Live sync composes from already-planned work: round 107's id-keyed reconcile, triggered by a dirty-stream consumer — no new replication protocol at all. Per-view style (the minimap's simplified sheet), per-view selection and per-view hover fall out free — the first was explicitly out of scope under the shared-store plan, the others were shared. The costs, stated honestly: ~2x memory (columns are ~192 B/node, ~156 B/edge from COLUMN_SPECS, so ~80-90 MB duplicated at ndex-x-large; trivial at typical app scale), duplicated style/label CPU, and per-burst rather than per-frame fidelity — ~85 ms a burst at 465k edges, sub-ms at small scale, so a minimap lags during a drag on a huge graph. Documented and measured beats silently complex.

Scope, deliberately layered

  1. Consumer cursors in DirtyTracker — unchanged from the first plan, because every future needs them: the reconcile trigger and the round-47 devtools observer are both second consumers of the dirty stream. Design: drain-and-republishmark()/markResized()/touch() stay byte-identical (the mutation hot path), and the first consumer to drain takes the live state exactly as today's take() does, then folds the result into the other consumers' pending buffers, so fan-out costs at frame rate, not mutation rate. registerConsumer() returns a DeltaConsumer (take/hasDirty/onInvalidate/dispose); the legacy surface delegates to a non-disposable primary cursor so no call site changes; a late registrant starts full-sync (resized both groups); the microtask bail at dirty.mts:156 becomes a per-consumer wake, so a synchronous drain by A no longer cancels B's wake-up. GraphStore.registerConsumer() wraps the blob-pool and mapper-span folds. src/contract.mts is the co-signed source of truth: its takeDelta/consumer docs change first in the diff, with the mirror docs at dirty.mts:93-102 and graph-store.mts:574-579 in the same commit. The benchmark row (mark-only, and mark+take at one consumer) lands before the change so before/after numbers exist; the gate is zero within noise.
  2. cy.clone( opts ), one-shot. Serialize → new instance ingest; the style sheet carried (opts.style overrides it — the minimap's simplified sheet); viewport carried unless opts set it; container per clone. The losses are documented, not silent: scratch, animations, listeners; measure and record exactly which element flags and bypasses each source form carries (the wire form is the fast path; the json() element form carries locked/grabbable/pannable and bypasses at definition-ingest speed).
  3. Live follow. A registered dirty consumer (layer 1) driving a throttled round-107 reconcile, master → clone. Sequencing: this round delivers layers 1-2 plus the measurements; the follow layer is round 107's proof case — building it here would mean building 107's reconcile early, and 107's own plan already names its measurement fork.
  4. Minimap proof in debug/. A clone with a simplified sheet in a second container, following via reconcile — lands with layer 3, i.e. with round 107.

Measurements first

Clone cost (serialize + ingest + style apply) at three scales; reconcile burst cost at the same three (from 107's harness); the duplicated-memory figure re-stated as measured rather than computed from COLUMN_SPECS.

Controls

The cursor control: two raw takeDelta() calls on today's code — the second reader starves; the spec fails without the change. The two-consumer different-cadence convergence spec (each consumer's local copy equals the store's columns byte-for-byte at the end — the round-46.5 columns-equal method), plus late-register full-sync and the per-consumer microtask-wake spec. The one-consumer perf row as a regression gate. Clone equivalence: a clone's element json() equals the master's, and model columns equal after ingest. Dispose and destroy-order specs (cursor disposed mid-stream leaves peers unaffected; core destroy with a live extra consumer neither throws nor fires dangling callbacks) in the soak tier's isolation suite.

Out of scope, recorded

Shared-store views (the fallback design above); flag-bit syncing (a clone owns its state — linked brushing is app wiring); worker-host clones (untested combination; nothing forbids it later).

Resolved by the replan

The first plan's open forks — view handle versus sibling facades, viewport event semantics, whether png() binds to a view — all dissolve: each clone is a full core with its own events and its own png(). The round-47 devtools "read-only observer" question resolves to a cursor registration: layer 1 is the tap.

Drive-by findings (logged, not fixed here)

  • Viewport animations emit viewport twice per tick: core.mts:1800 passes ['pan','zoom','viewport'] to _emitViewportEvents, which appends 'viewport' itself (core.mts:3202). Small real bug, independent of this round; fix separately with a listener-census spec.
  • Premise corrections from the verification pass: the worker path needs no cursor change (remote-view.mts drains its own tracker instance); and the three single-callback store slots (onStructureChange, images.onChange, onDictRemap) are wired only by the core and the store themselves — never hazards.

Patch: id-keyed reconcile of a fresh payload

Verified premise: cy.json() is export-only by decided design — the import form throws (src/core.mts:2802-2817), and the record says restoring from kept definitions is the app's job. This round does not reopen that decision, and the plan says so explicitly: json( obj ) restores a serialized session; patch reconciles a data refresh — the same logical graph, next query result — into the live instance. Compute adds / removes / updates by id, apply as one batch, and everything attached to surviving elements survives: selection, positions (unless the payload moves them), bypasses (id-keyed, so they survive by construction), running animations, listeners, viewport. The consumers exist before the API: GeneMANIA re-query, Cytoscape Web backend sync, and item 46's React wrapper, whose prop-diffing is exactly this and would otherwise be reimplemented per app, badly. Round 106's replan (2026-08-27) added a fourth: a live-following cy.clone() syncs by throttled reconcile, and its minimap proof lands with this round.

Semantics fixed at planning (the maintainer-feedback core):

  • Mode: default reconcile — an id absent from the payload is removed; merge (absent = kept) available by option. The default matches "this is the new result".
  • Data: per-element replace of the data record, not deep merge — deep merge is the API nobody can predict; merge mode's keep applies per element, not per key.
  • Positions: present in payload → written; absent → kept.
  • Endpoints: an edge whose source/target changed identity is a remove + add, not an update — rewiring is not a patch.
  • Never touched: the sheet, the viewport, listeners, scratch. Elements only.
  • Returned: the diff — { added, removed, updated } collections — because every consumer's next line wants it.

Input forms: definitions, columnar, wire buffer — one funnel with round 66's load path, not a parallel one. The columnar and wire paths are the fast tier: ids resolve through the id-map (string id ⇄ slot, blob-native), and a data column can be compared column-against-store without materializing objects; the definition form goes through the same reconcile at definition speed.

Measure first, because the fork between "sugar over remove/add" and "store-level columnar reconcile" is a factor nobody has priced:

  1. destroy + recreate (the honest baseline apps use today);
  2. app-side diff through the public API (remove() + add() + batchData);
  3. a store-level columnar patch prototype; — each at 100k elements with 90% / 50% / 10% id overlap. GeneMANIA-shaped refreshes are high-overlap, so the 90% row is the headline; the 10% row guards the degenerate case (a patch that is worse than recreate below some overlap must say so in its docs, with the number).

Controls: the identity patch — payload equal to state — must produce an empty diff, zero element events, and zero net dirty spans (spec asserts all three, and its control perturbs one data value to prove the assertions bite); the event contract — adds fire add, removes fire remove, updates fire data / position events, once each, inside one batch — pinned by a listener-census spec; end-state equivalence — patch(A→B) leaves columns equal to a fresh load of B modulo the preserved state, the columns-equal method again.

Named file: src/store/patch.mts.

Open (maintainer): the name (patch / reconcile / mergepatch recommended; merge is the mode name); whether a keepPositions option class is needed beyond present-wins (Cytoscape Web may want "never move what the user moved" — arguably app policy via the returned diff); whether patch emits one summary event (patch, with the diff) beside the per-element events, for apps that only want the summary; whether the React wrapper (item 46) should be sequenced immediately after as its first consumer, which would validate the API before it hardens.

Agent ergonomics: what the two agents load, search and trip over

The ask: review the repo for coding-agent performance, keep it working with both Codex and Claude Code, and propose the lowest hanging fruit with the biggest impact. The constraint that shapes every item below is the portability one: whatever is added has to be read by both agents, which in practice means AGENTS.md, nested AGENTS.md files, package.json scripts and ordinary files on disk — Claude Code reaches the same content through the one-line CLAUDE.md that imports AGENTS.md, and nothing here is written in a format only one of them parses. The maintainer ruled out agent-specific harness config (a committed .claude/settings.json permission allowlist was offered and declined) for exactly that reason.

Seven findings, all measured on 2026-08-24 against 12eccd14:

  • F1 — the preamble costs 15.7k tokens before the task is known. AGENTS.md is 62,818 bytes, and both agents load it verbatim at session start, every session, whatever the task. It is no longer an instruction sheet: it is this repo's lessons-learned essay, and the essay is good — most of it is a defect that was paid for once and written down so it is not paid for twice — but an agent fixing a typo in a benchmark comment still reads the golden-image tolerance history, the SwiftShader worker ratio and the Fedora libjpeg.so.8 recipe. Its longest line is 2,021 characters.
  • F2 — PLAN.md cannot be read by the thing it is written for. 1.5 MB, ~381k tokens, 147 ## sections. No agent can open it whole; the ones that try lose the session. Appending a round — which the process rules require — means editing a file no read tool will open, so every round-closing edit is done blind at the tail. The record is the repo's most valuable document and its least reachable one.
  • F3 — most of what a search returns is v3. 821 of the 1,290 tracked files are under v3/. Probe terms, repo-wide vs outside v3/: style 457 → 189, renderer 213 → 117, boundingBox 116 → 73, controlPoints 37 → 20. So roughly 55–60% of every search result is a codebase the agent is not working on, and nothing on disk says so: there is no nested AGENTS.md in v3/, so an agent that opens v3/src/style/index.mts and starts "fixing" it is following the evidence it was given.
  • F4 — a leftover agent worktree breaks the build and would ship to npm. This is not hypothetical; it is the state of the maintainer's checkout right now. .claude/worktrees/round-90 (352 MB, branch round-90-api-cleanup, still registered in git worktree list) makes test:modules red: the round-44.2 markdown allowlist in test/modules/packaging.mjs sees eight documents where four are expected, four of them the worktree's own copies. The failure is not the spec being fussy — it is the spec doing its job, because .npmignore is a denylist with no .claude entry, so npm pack --dry-run lists 141 of 278 files from inside a dead agent worktree. A release cut from this tree publishes another agent's abandoned branch.
  • F5 — the verification loop's shape, measured. Quiet twins on this machine: typecheck 0.2 s, lint 0.2 s, build 1.0 s, test:js 7.4 s, test:soak 1.6 s, test:throws 31.5 s, test:modules 46.9 stest:node88 s, of which the two audits and the npm pack/status-site tier are 89%. Round 101 fixed what a green run prints; nothing has yet named what an agent should run while iterating, so agents either run the 8-second trio or the 88-second chain by guess.
  • F6 — tracked cruft with no owner. d2.scratch.mjs, tmp/x.json and tests-examples/demo-todo-app.spec.js are tracked at the root, referenced by nothing, and read to an agent as part of the project.
  • F7 — the instructions are organised by repo topic, not by task. An agent arrives knowing what it is about to do ("change an arrowhead", "add a public method", "add a benchmark") and has to reconstruct, from prose organised by directory, which commands and which cautions apply. Every one of those routes exists in AGENTS.md today; none of them is addressable.

108.1 — AGENTS.md becomes an instruction sheet; the lessons become docs/agents/

The root file is rewritten to ≤ 16 KB (~4k tokens) and holds only what is true for every task: the repo's shape, the command table, the hard invariants that a wrong guess breaks silently (.mts on disk / .mjs in specifiers, oxlint-not-ESLint, oxfmt-not-Prettier, node:test-not-Mocha, rolldown-not-Rollup, the v3 boundary), and a routing table keyed by task — F7's fix: changing the renderer → these commands, read docs/agents/rendering.md; adding a public member → these, read docs/agents/documentation.md; adding a benchmark → these, read docs/agents/benchmarking.md.

Everything else moves verbatim into docs/agents/*.md, split by the area it warns about — nothing is deleted, and each note keeps its round attribution:

  • docs/agents/testing.md — the testing-notes tier: controls, goldens, parity, Playwright's frame driver and worker ratio, the soak/throw/leak rules, the "a spec's name is not evidence" rule.
  • docs/agents/benchmarking.md — the measurement lessons: __name, the harness fingerprint, --repeat 3, --jobs, the rows that measured nothing, the v3-side styleEnabled/layout bias.
  • docs/agents/documentation.md — the JSDoc gates, the stranded doc block, the audit-scope rule, EXECUTIVE_SUMMARY.md's maintenance rule.
  • docs/agents/architecture.md — the directory-by-directory map that AGENTS.md's "Repository structure" section is today, minus the war stories that go to the three files above.
  • docs/agents/rendering.md — WGSL minification, the debug harness, the load-error phases, the fixture/wire-format notes.

Both agents follow a relative link when told to; neither needs a new mechanism. The root file's job becomes routing, and its budget is what keeps it doing that job.

Gated, not hopedtest/modules/agent-docs.mjs:

  1. AGENTS.md is under the byte budget. Without this the file grows back, which is exactly how it got here.
  2. Every rooted path mentioned in AGENTS.md or any docs/agents/*.md resolves on disk — round 42's lesson (existsSync, allowing for the .mjs-specifier convention), applied to the agent docs for the first time.
  3. Every docs/agents/*.md is linked from AGENTS.md, and every link from AGENTS.md into docs/agents/ names a file that exists — an unlinked note is an invisible note.
  4. Every npm run script named in the agent docs exists in package.json, and (the reverse) every verification script in package.json is named somewhere in them.

108.2 — PLAN.md splits into plan/rounds/ plus a generated index

One file per round: plan/rounds/NNN-slug.md, carrying that round's plan section and its landed section together — which is also a small win in itself, since today a round's plan and its record sit thousands of lines apart. PLAN.md keeps the three standing sections it already maintains rather than appends to ("Open calls for the maintainer", "Suggested sequencing", the process rules), plus the generated index; it lands at a few hundred lines and stays readable by an agent in one call.

  • scripts/plan-index.mjs regenerates plan/INDEX.md and the index block inside PLAN.md from the round files' front matter (round number, date, title, status: planned / landed).
  • test/modules/plan-record.mjs gates it: the index matches the files on disk, round numbers are unique and unbroken, every round file has a title and a status, and no round file is orphaned.
  • Adding a round becomes writing one small file — which is what makes this an agent-performance item and not a filing exercise.

The control is byte-identity. The split is behaviour-neutral only if the text survives it: concatenating the standing sections and every plan/rounds/* in round order must reproduce the pre-split PLAN.md modulo the inserted headings and front matter, and that comparison is run and recorded at implementation, not asserted. Round 42's rule — a restructure is behaviour-neutral only if you check every file, not every test — is the reason.

  • v3/AGENTS.md, ~15 lines, picked up automatically by both agents the moment they work in that subtree: this is Cytoscape.js v3, kept whole for the comparison benchmarks and the pixel-parity harness; do not change it unless the task says v3; it builds and tests as its own project (cd v3 && npm install && npm test); its documentation describes v3 and v4 deviates deliberately. The cheapest item in the round and probably the highest value-per-byte.
  • A root .ignore listing v3/ (and status/, test-results/) so ripgrep and fd skip it by default — F3's ~57% noise cut. The escape hatch is documented beside it in the routing table, because v3 is searched deliberately for parity questions: rg --no-ignore -g 'v3/**' …, and the existing "grep v3/documentation/docmaker.json for the v3 API" workflow in AGENTS.md is rewritten to carry the flag.
  • Measured after, not assumed: the four probe terms are re-counted through a default rg invocation and the before/after written into the landed section.

108.4 — an agent's worktree cannot break the build or reach npm

F4 has three halves and all three are fixed:

  • .npmignore gains .claude — with a packaging spec case asserting nothing under .claude/ is packed. Its control is the live one: this very round is being written from a worktree, so the case must fail on today's .npmignore and pass after.
  • Every tool that walks the tree is audited for the same blind spot and excludes .claude/worktrees explicitly: jsdoc-coverage.mjs, throw-coverage.mjs, docs-generate.mjs, the status build, packaging.mjs, quiet-scripts.mjs. A walker that finds a second copy of src/ reports on the wrong tree, and the only reason this surfaced as a test failure rather than as a wrong audit number is that one spec happened to use an allowlist.
  • The process rule joins the closing sweep in AGENTS.md: git worktree list is checked when a round closes, and a landed round's worktree is removed. The round-90 worktree and its 352 MB go with this item.

108.5 — the inner loop gets a name

npm run -s verify — typecheck + lint + test:js, ~8 s measured — declared in the routing table as what to run while iterating, with test:node:quiet (~88 s) as the gate before handing work back and test:quiet for anything broad. No new machinery: it is one run-s line naming what the fast tier already is, and it replaces a guess every agent currently makes separately. test:throws (31.5 s) and test:modules (46.9 s) stay out of it deliberately — they are gates, not a loop.

108.6 — the cruft goes

d2.scratch.mjs, tmp/x.json and tests-examples/ are removed (they are tracked, so removal is recoverable), and .gitignore gains /tmp so the next scratch file does not become a tracked one.

Controls named at planning

  • The byte budget can fail — add 10 KB of prose to AGENTS.md and watch agent-docs.mjs go red.
  • The path check can fail — introduce one bad path in a moved note; it must be named, not merely counted.
  • The .claude pack exclusion can fail — measured against the live worktree, before and after.
  • The plan split is byte-identical — the concatenation comparison above, run and recorded.
  • The search-noise claim is re-measured, not assumed, after .ignore lands.
  • No lesson is lost — the moved text is compared against its pre-move source, not eyeballed; a note that quietly loses a paragraph in the move is the one failure mode this round could have that nothing else would catch.

Decided at planning (maintainer, 2026-08-24)

  • AGENTS.md: lean root plus linked notes, nothing deleted.
  • PLAN.md: split into plan/rounds/ with a generated index.
  • v3: all three mitigations — the guard file, the documented search default, and the repo .ignore.
  • Harness config: portable only — no committed .claude/settings.json, nothing either agent cannot read.

Open: whether the notes live at docs/agents/ (assumed) or beside the code they describe; whether PLAN.md keeps its name as the entry point once it is an index (assumed yes — every document in the repo points at it).

Landed (round 108 — 2026-08-24)

All six items, in the order they were done, each verified as it landed. npm run -s test:node:quiet green (zero bytes) at the close, plus test:types:all.

108.4 — the worktree that could neither ship nor be seen. Done first because it was breaking the maintainer's build at the moment the round was written. .npmignore gains .claude; control run live — with a file under .claude/, npm pack --dry-run listed 2 such files before the entry and 0 after, against 141 of 278 in the tree that prompted it. .gitignore gains /.claude/worktrees/, because Claude Code writes that rule into .git/info/exclude, which is local to one clone and never travels. test/modules/packaging.mjs gains the case.

The tree-walking audit the plan called for came back clean, and that is worth recording rather than quietly dropping: every other walker roots at a specific directory (newestMtime at src/, the jsdoc and throw audits at src/, bench coverage at benchmark/, the goldens page at its PNG directory), so npm pack was the only tool that saw the second copy of the repo — it reads neither .gitignore nor .git/info/exclude for this.

The round-90 worktree was not deleted. It holds 8 commits not on v4 (round-90-api-cleanup, through 8b992283), which is real work, not garbage; removing it is the maintainer's call and the .npmignore fix already makes the tree green and safe to release. The closing-sweep rule (git worktree list) is in the process list either way.

108.3 — v3 frozen and quiet. v3/AGENTS.md (plus the one-line v3/CLAUDE.md that imports it, as at the root), and a root .ignore. Measured after: style 462 hits -> 186, renderer -> 112, boundingBox -> 73, controlPoints -> 20, with rg --no-ignore -g 'v3/**' documented in both files as the way back in.

108.5 — the inner loop. npm run -s verify:quiet (typecheck + lint

  • test:js) at 9.6 s measured, against test:node's ~88 s. Both twins land together and the new twin is registered in test/modules/quiet-scripts.mjs, which enumerates in both directions.

108.6 — the cruft. d2.scratch.mjs (which carried an absolute path into the author's home directory), tmp/x.json and tests-examples/ removed; /tmp added to .gitignore.

108.2 — the record becomes files. 146 sections moved to plan/rounds/NNN-slug.md; PLAN.md keeps the standing sections and lands at 108 KB from 1.5 MB. The byte-identity control passed on the first run — head plus every section file, concatenated in order, reproduces the pre-split file exactly, 1,524,666 bytes over 146 files — and it passed because nothing was added to the section text: a round file is its ## section verbatim, and every column the index shows is parsed back out of the heading, which already carried round number, date and kind. scripts/plan-record.mjs + scripts/plan-index.mjs (npm run plan:index, with --check), gated by test/modules/plan-record.mjs; the status site assembles the whole record through the same function and still renders it as one 1.9 MB page.

108.1 — AGENTS.md becomes an instruction sheet. 62,818 -> 10,303 bytes, an 84% cut (~15.7k -> ~2.6k tokens paid by every session of both agents), with 58 KB of lessons moved verbatim into five docs/agents/ notes. What replaced them is the thing F7 said was missing: a routing table keyed by what you are about to do. test/modules/agent-docs.mjs gates the budget, the links in both directions, every rooted path and every npm run name.

What the round found that it had not planned for

Four gates fired on work this round did, and each one is the answer to "was that gate worth its maintenance?":

  • .npmignore is a denylist, so every new top-level directory ships. It happened twice in one round — plan/ and docs/ both went into the tarball until they were listed — and both were caught by round 44.2's markdown allowlist, which exists precisely because the denylist cannot notice a directory nobody thought of. The 141-file worktree was the same failure in a third guise.
  • A spec that reads a document from disk stops reading the document. test/modules/status-site.mjs verified the path exemptions against PLAN.md; after the split that was 8% of the record, and it reported 33 live spellings as dead exemptions. It reads through the document's assemble hook now, as the build does. Generalisation worth keeping: when a document gains an assembly step, every reader of it is a caller to check, not just the renderer.
  • The agent-docs gate found drift the moment it existed — three paths written relative to src/, five round-42 spellings that are quoted rather than pointed at. The five became an audited exemption list on the same two-direction terms as the status site's HISTORICAL_PATHS.
  • The site would have lost what the root file gave up. Moving 58 KB out of AGENTS.md would have deleted it from the published contributor guide; the five notes are published pages now.

Noticed while verifying: a pre-existing flake

test/force-layout.mjs:265"uncurls a chain: the spectral seed reaches what refinement cannot (59.4)" — failed 2 of 4 full test:node runs on this machine and passed 3 of 3 when its file was run alone, always with the identical value (346.456 against a bound of 936). A repeated identical number rules out ordinary stochasticity: the spec is seeded (seed: 7), so the likely mechanism is that the force phase's iteration count is load-dependent, which makes the result a function of how busy the box is rather than of the layout.

Not this round's doing, and not this round's to fix: git diff 12eccd14..HEAD -- src is empty, so nothing here can reach that code path. Logged because a spec that fails 50% of the time under load is worse for an agent than one that fails always — it teaches the agent to re-run rather than to read. Worth its own item.

Open, carried forward

  • Whether .claude/worktrees/round-90 and its branch are kept — 8 unmerged commits, the maintainer's call.
  • The notes live at docs/agents/, as assumed at planning; PLAN.md keeps its name as the record's entry point.

The record's filenames carry the round

Round 108.2 split PLAN.md into one file per section and named each NNN-slug.md, where NNN was the section's position in the original document. That number did its job — it reproduced the record's order, and the split was verified byte-for-byte against the 1.5 MB original — but it was the wrong number to put in a filename, for a reason that only shows up in use: nobody has ever asked for section 143. They ask what round 106 said. The one identifier a reader arrives with was the one the name did not carry, so every lookup went through plan/INDEX.md or a grep, including for the agent this restructure was done for.

So the names became YYYY-MM-DD-NN-rndRRRR-description.md: the date the section was written, a counter among the sections sharing that date, the round it is about, and the slug 108.2 already generated.

What the shape had to absorb, all of it found by reading the 147 files rather than assumed:

  • 24 sections are not rounds at all — the ten pass-1 notes, six design sittings, the parity gap analysis, and standalone landed items like the pick-ring look. They take rnd0000. Giving them a bucket of their own was considered and dropped: it would have pulled the design sittings out of the chronology they are only meaningful in.
  • Seven rounds have two sections (10, 11, 13, 19, 43, 90, 101 — a plan and its landed record). The round is not unique and was never going to be; the date and counter are what separate them, which is why the round could not simply replace NNN.
  • Eleven rounds are fractional or lettered (9.4–9.7, 12a–12c, 41.5, 46.5, 46.6) and four headings scope a span (15–18, 91–97, 98–100, 102–107). The round keeps its written form after the padding — rnd0009.4, rnd0012a — and a span joins with _, since - already separates the fields.
  • Twelve sections carry no date in their heading. The ten pass-1 notes predate the round record; git dates the base pass (e30542cf4..9b177c193) 2026-07-22, so that is what they took — a measured date, not an invented one. The two "Logged" notes took the date of the round they sit beside (2026-07-27, 2026-07-29).

What the rename actually moved

Sorting by date rather than by the old sequence reordered four sections, and each one is a correction: the old sequence had them sitting before sections written earlier than they were. The largest is the round-43 review pass (2026-08-05), which had been filed ahead of seven sections dated 2026-08-04. Nothing else moved, and no section text changed — git recorded all 147 as pure renames.

The failure mode this adds, and the gate for it

A name that repeats what a heading says can disagree with it, and a disagreement is worse than the grep it replaced: a reader who trusts the name reads the wrong round's history. The heading stays the source of truth, and test/modules/plan-record.mjs now asserts the name agrees with it — roundKey(roundOf(title)) against the filename's field, and the heading's date against the filename's when the heading has one. roundKey() has its own cases beside it, because a mapping that is uniformly wrong would leave the agreement gate green.

The old gate — sequence numbers unique and gapless 1..N — is gone with the sequence itself. In its place: the within-date counter runs 1..n in order, which catches the same two things (a hand-added duplicate, a deletion) on the field that now does the ordering. seq survives as the index's # column but is derived from sort position rather than stored, since a stored ordinal goes stale the moment a section is inserted between two others.

Verified by: the 11 record gates green, including the two new ones; test:modules and verify (2,236 specs) green; the status site built and its assembled Development record page carrying 147 sections plus PLAN.md's two standing ones, in the corrected order.

PLAN.md stops summarising itself

Round 108.2 split the record into one file per section and left PLAN.md holding "only the parts that are maintained rather than appended to". That was the intent. What it actually still held was 1,442 lines, of which two thirds were append log in two places — and the measurement that decided this round is that both stop at round 64, while 44 further rounds landed behind them.

The first is the head: ~385 lines of round-by-round prose narrating the work from round 12 onward. The second is inside the ledger, and is covered below. Between them, the only mentions of a round past 64 anywhere in PLAN.md were in the Process rules.

So the file opened by summarising itself up to a point it no longer reached, under a paragraph describing that summary as "kept current rather than appended to". It was neither.

Why removed rather than migrated

The obvious repair — move the prose into the per-round files it describes — is wrong, and reading it says why. It is not per-round content. It is cross-round connective tissue: "rounds 24–28 closed the remaining ledger work, after which what was left of the ledger was open calls rather than effort"; "rounds 29–30 therefore work a different axis: not what is unbuilt but what is unpinned". Those sentences belong to no single round, and the per-round detail they compress is already in each round's own file — verbatim, since 108.2 verified the split byte for byte. Migrating would have duplicated what the round files already say and scattered a synthesis into places that cannot hold it.

The deeper reason is that the narrative was the third copy of one history, and the only one nothing obliged anyone to update:

Job Where it lives What keeps it current
Per-round detail the round files written with the work, gated
The cross-round five-minute read EXECUTIVE_SUMMARY.md AGENTS.md requires a rewrite at every round close
Navigation plan/INDEX.md generated; cannot drift
the removed narrative PLAN.md's head nothing

Removing the copy with no ritual is what fixes the drift. Reviving it would only have restarted the clock on the same failure.

What was kept

Two parts of that preamble are standing truths that exist nowhere else, and one is the most important prose in the file:

  • "v4 is not close to a release, and this record is not a route to one", with its explanation of why it needs saying — a per-round record reads optimistically by construction, every round closes green, and a file made of closed rounds reads like a finished thing. Kept whole; only "the round list" was reworded, since the list it named is the thing this round removed.
  • The routing paragraph and the Status: framing, both trimmed.

In their place the head now says where the history is — the summary, the index, and src/README.md for what v4 is as opposed to how it got here — and says plainly that the narrative was removed and why, so the absence reads as a decision rather than an oversight.

The ledger had the same disease, and worse

Removing the head's narrative exposed that "Open calls for the maintainer" — the section the file advertises as kept current — was 1,287 of the remaining 1,442 lines and had accumulated the same append log in two forms.

First, a sitting-by-sitting history stood between the section's rules and its entries: the fifth through ninth design sittings, each paragraph restating a running count of open questions. It stops at round 64, exactly like the head's narrative did, at "down to three: 18, 23, 27". Replaced by one paragraph stating that count and the date it was last confirmed, which is the honest form — rounds have landed since without a sweep.

Second, and against the section's own written rule that "an item leaves only when the call is made", most of the ledger was closed items. Read individually, each says so in its own words: "this item is closed", "Executed — round 38 landed 2026-08-08", "Call taken (2026-08-04): stays out", "Resolved by measurement". Removed on that evidence, item by item:

Removed Why
"Scope calls" 1–8, the whole subsection every one executed or decided
9–13, 24, 28 (contradictions) executed by rounds 37, 41.2, 42.6, 58, 64
"Public-surface changes" 14–16, the whole subsection all three ratified at the sixth sitting; its own text says "this section's review debt is cleared"
17 condition measured false — round 52
25 landed as round 63

Kept: 18, 23 and 27 — the three the last sweep named as genuinely open — plus 19–22 (recorded deviations with priced options), 26, and the 29–50 backlog of logged ideas, which is live planning input.

Item numbers are not reused, so the survivors keep theirs and the gaps are deliberate: round records across plan/rounds/ cite items by number, and renumbering would silently repoint every one of them. The section preamble now says so, because a reader who sees 18 follow 27 will otherwise assume a mistake.

The ledger goes from 50 entries to 30, and PLAN.md from 1,442 lines to 750.

The stale references the sweep turned up

Removing the block exposed how much of the head pointed at things that were already gone:

  • "Suggested sequencing" was named twice as a section to start from and to sweep every round. It does not exist.
  • The Process rules named three blocks that "drift on almost every round and are worth grepping by name" — the "Needs a call" gap ledger, "Suggested sequencing", and "Gaps with direction already set". None of the three exist. The paragraph was instructing every future agent to grep for three names that return nothing. Rewritten to keep its lesson (a standing summary of what remains is what drifts worst; the seventeen-round example is kept) while pointing at the two places that do exist.
  • Five "at the end of this file" pointers to round plans that 108.2 moved into plan/rounds/ three days earlier.

EXECUTIVE_SUMMARY.md's "PLAN.md is the source; this file is derived from it" was also now wrong in the way that mattered most — it named the file whose summary had just been deleted. It and docs/agents/documentation.md now name the round files as the source, and both record that the summary is the record's only cross-round narrative, so the rewrite-on-close rule is visibly the thing holding the history up rather than one of two.

What this does not fix

src/README.md cites PLAN.md about fifteen times for round records that have lived in plan/rounds/ since 108.2 ("see the round-57 plan in PLAN.md"). Each still resolves for a human — the record is PLAN.md plus the round files — but they point a reader at the wrong file. Left alone deliberately: it is a separate sweep, and folding it in would have made this change a src/README.md edit wearing a PLAN.md commit message. Logged for the next docs round.

A numbering collision, caught late

This round and 108.7 were first written as 108.3 and 108.4, which round 108 had already used (108.3 froze v3/; 108.4 was the worktree safety rule, and its PLAN.md Process entry is still there naming it). Round 108 ran 108.1–108.6, so the free numbers were 108.7 and 108.8. Caught only because pruning the Process rules put that 108.4 entry under the eye — a reminder that the sub-round space is a namespace, and the round's own plan file is the register: read it before claiming a number, rather than assuming the highest one you have seen is the highest one taken.

PLAN.md goes 109 KB → 45 KB (1,442 lines → 750). Size was never the driver — the 200 KB gate had ample room — staleness was; the size is just what honesty costs when two thirds of a file was a record of decisions already taken.

Verified by: test:modules and the 11 record gates green; a content-anchored prune rather than a line-numbered one, after the first attempt's hardcoded ranges over-cut and swallowed the openings of items 18 and 19 — the second pass finds each block by its own heading and asserts the survivors by number (30 of them, the expected set); the status site rebuilt, its assembled Development record page carrying PLAN.md's standing sections plus all 149 section files, and no longer opening with a summary that contradicts the 44 rounds beneath it.

The headings become titles

Round 108.7 put the round, the date and the counter into every section's filename. What it did not do was take them back out of the heading, so each section said everything twice:

## Round 15 plan — background images (planned 2026-08-01)
## Landed (round 9.6 — image export + the visual regression harness, 2026-07-27)
## Round 48 — robustness + soak (planned 2026-08-04; Node tier landed 2026-08-04; complete with 48.6, 2026-08-08)

Three fields of bookkeeping wrapped around the two or three words that say what the section actually is. 109 of the 149 headings opened with Round N or Rounds N–M, 19 opened with Landed (, and 94 carried a parenthetical lifecycle date — and the index rendered all of it, so scanning plan/INDEX.md for the section about background images meant reading past the same four words 149 times. Average heading length was 72.6 characters; it is now 33.9.

So the heading is a title now — ## Background images, ## Image export and the visual regression harness, ## Robustness and soak — and the filename carries a fourth field for the one piece of metadata that was still only in the prose:

YYYY-MM-DD-NN-rndRRRR-kind-description.md, kind being plan, landed or note.

Why the kind had to move into the name

It was the only metadatum with nowhere else to live. Round, date and counter were already in the filename; kindOf() read the kind out of the heading's wording — /^landed\b/, /\bplan\b\s*—/ — which is exactly the prose-parsing this round removes. Putting it in the name also disambiguates the seven rounds that have two sections (10, 11, 13, 19, 43, 90, 101): rnd0019-plan-slot-moving-compaction and rnd0019-landed-slot-moving-compaction sort adjacently and read as what they are. A planned round that lands is now a rename of that one field.

What the parser stopped doing

roundOf(), kindOf() and dateOf() are gone — three regexes over English prose, each a guess that a heading was written to a convention nobody enforced. In their place parseName() returns the whole record of a section from its filename, and roundLabel() is the exact inverse of roundKey() (rnd0009.49.4, rnd0102_0107102–107), so the index's Round column is a mapping rather than a match.

The agreement gate went with them: with nothing repeated, there is nothing to disagree. What replaces it asserts the absence — a heading may name another round in passing (Fixing what the benchmark sweep measured was fixing what round 33 measured), but it may not open by labelling itself one, and it may not carry a (planned …)-style date. That is the failure mode a future round would actually hit: writing the next section in the old style and having the index quietly say it twice.

What did not change

No section text. Only the first line of each file was rewritten, and git recorded 146 of the 149 as renames (three names were already right, their old slug having begun with landed-). assemble(), the status site's Development record page, and the byte-for-byte provenance of the 108.2 split are all untouched.

Verified by: the 11 record gates green, including the two rewritten ones; npm run -s verify and test:modules green; plan/INDEX.md regenerated at 149 sections; the status site built and its Development record page rendered.

The spectral-seed spec fails without the setup preload

Found incidentally by round 86, which ran test/force-layout.mjs by itself while chasing an unrelated red and got a deterministic failure the full tier never shows. Everything below was verified on 2026-08-26 on the round-86 branch, and the failure reproduces at the branch base (73873cd0), so it predates that round's changes.

The observation, exactly. The tier runs every spec with the shim preloaded:

node --import tsx --import ./test/node-test-setup.mjs --test test/…

Run that way, test/force-layout.mjs passes 13/13 — standalone or in the tier. Run without the preload —

node --import tsx --test test/force-layout.mjs

— the file still loads (it imports ./node-test-setup.mjs itself) and twelve specs still pass, but "uncurls a chain: the spectral seed reaches what refinement cannot (59.4)" fails deterministically: the n0→n39 spread measures 346.4557 against the >936 bound (a straight 39-link chain at ideal length 60 spans ~2340; 346 is a curled scatter). The same wrong number every run, so this is not noise — the layout genuinely runs differently under the two invocations.

Why this deserves a round rather than a shrug. A spec whose result depends on how the harness was invoked is measuring something about the harness, and both possible resolutions are cheap once the mechanism is known:

  1. Find the mechanism. The import-order difference is the only known variable: preloaded, the shim initializes before the test runner begins; unpreloaded, it initializes during the file's own evaluation. What that changes for exactly one spec — plausibly beforeEach registration timing altering which instance/seed state the spectral test inherits from its siblings — is the question. Instrument the shim to log registration order under both invocations and diff.
  2. Then either fix the sensitivity or refuse the invocation. If the spectral spec depends on sibling state, that is a spec bug to fix (it should seed everything it needs). Independently, the shim should probably fail loudly when it is imported without the preload rather than half-working — a one-line guard (process.execArgv or an env sentinel), so the wrong invocation becomes an error message instead of a wrong measurement. That is this repo's standing policy (fail loudly) applied to its own harness.

First measurement: the shim's registration-order diff between the two invocations, run on this one file — it either names the mechanism in one sitting or falsifies the import-order hypothesis and the hunt widens to what else differs (module cache order through tsx, test runner concurrency defaults).

Non-goals. Nothing about the force layout's quality: the spectral seed itself is round 59.4's, verified there; under the documented invocation it still reaches what refinement cannot.

Landed

Landed 2026-08-26. The plan's premise was wrong in its first sentence and right about the spec: there is no invocation-shape sensitivity, and there is an intermittent failure, which this round measured, named, and left instrumented rather than fixed.

The stated repro does not run. The plan says the file "still loads (it imports ./node-test-setup.mjs itself)" without the preload. It does not, and it never did: no spec under test/ imports the shim (test/modules/worker-renderer.mjs is the only file in the tree that does), so

node --import tsx --test test/force-layout.mjs

dies at describe is not defined before a single spec runs — verified at HEAD and at the round-86 base 73873cd0, where src/layout/ and test/force-layout.mjs are byte-identical to today's. So the import-order hypothesis had nothing to instrument: it was falsified, not tested. Round 108 had in fact already recorded the true shape and this round's plan did not find it — the spec failed 2 of 4 full test:node runs on 2026-08-24 and passed 3 of 3 run alone, "always with the identical value". A round reading only round 86's note inherited round 86's diagnosis.

What 346.4557 is. Not a curled scatter near some bound — it is exactly what this fixture produces with the spectral seed skipped. Both paths, same seed, same process:

init n0→n39 spread
spectral (default) 3207.7853
scatter 346.45575009003477

Round 86 recorded 346.4557 and round 108 recorded 346.456. So in every failing run the spectral seed contributed nothing — which is a much narrower statement than "the layout runs differently", and it rules out the whole family of explanations the plan was reaching for: spectralSeed is pure and deterministic (pivot choice, BFS order, a hash-seeded power iteration, the jitter), it reads no global state, there is no Math.random in src/layout/ outside the random layout, and neither the seed nor the sim has any wall-clock or iteration-budget term that a busy machine could move.

Reproduced once here, and not with a probe attached. One failure in 3 uninstrumented test:js runs (the same value to 17 digits); then 0 in 20 further runs — 12 with a probe inside the seeding branch, 6 with a failure-only probe carrying the layout's inputs, 2 under 16 concurrent CPU hogs. Every probed run recorded identical inputs (meanL 60, one component of 40, 39 edges, byte-identical seeded positions) and a spectral embedding that worked. Instrumenting the branch appears to perturb whatever the failure needs, which is itself evidence: the inputs are not what varies.

So the round left the diagnosis in the spec instead of a fix. Three rounds have now read the same bare assertion failure — "346.4557 is not above 936" — and each guessed a different mechanism from it. The spec now re-measures the scatter path in the failing process and says which of the two possible failures happened:

chain spread 346.45575009003477; the scatter path measures
346.45575009003477 in this same process, so the spectral seed did not run

A sibling spec asserts the same discrimination on every run (scatter under the old bound, spectral clearing it by 4x), so a regression that falls back to the scatter seed fails by name rather than as a number near a threshold. Control for both: spectralSeed's call site stubbed out — both red, printing the message above.

The residual, logged as ledger item 52: the flake itself, with the next measurement named (catch one failing run with a probe that is cheap enough not to move it, and compare the seeded positions, since the inputs are now known to be constant).

The one real harness defect, fixed. A wrong invocation does not half-work, but it does fail illegibly — ReferenceError: describe is not defined names neither the shim nor the preload — and the tier offered no way to run a single spec file, which is why the round-86 agent hand-rolled one. npm run -s test:js:one -- test/<file>.mjs is the tier's own invocation with the glob left off; it is in AGENTS.md's command table and explained in docs/agents/testing.md. The plan's other idea — a guard in the shim that fails loudly when it is not preloaded — was dropped as unbuildable in that form: the shim is the preload, so it cannot observe a run that omits it.

Non-goal held: src/ is untouched. The diff is one spec file, one npm script, and two documentation lines.

Zero-copy: the copy census, and what each copy buys

Raised by the maintainer on 2026-08-26, off the back of round 86's SharedArrayBuffer head-to-head: performance is a top-priority goal for v4 — along the lines of SAB, can copying be removed generally, not only at the worker boundary? This is the feasibility record and the prospective plan. Planning read the code rather than the question, and the finding that organises the round: v4 is already at or near the zero-copy floor on most paths, and the floor itself is set by WebGPU, not by v4 — so the round is a census that prices every remaining copy, then removes only the ones whose price survives measurement. Copies are not free to remove: each one below buys coherence, growth, isolation or portability, and the analysis says what, per path.

The inventory — every copy a byte pays today, verified

  1. Wire decode is already zero-copy. deserializeElements returns typed-array views over the incoming buffer: the encoder aligns every section to its element width (alignTo, wire.mts:236/254) exactly so the decoder never copies, and the one copy in the path fires only for a misaligned view handed in (wire.mts:297-303, base not 8-aligned) — a caller-induced case. Nothing to remove here; the design already made this trade in round 46.5.
  2. Bulk ingest copies payload columns into store backing. _addColumnar writes the payload's per-element columns into the store's own arrays (which carry growth slack and live beside ~40 style-derived columns the payload does not have). This is the one load-time copy with removal potential: the store could adopt a payload column's buffer as its initial backing for the columns the wire carries, copy-on-grow afterwards. What the copy buys today: a capacity policy independent of payload size, and a store that never aliases caller memory (a caller mutating the buffer it handed in cannot corrupt the model). Whether it is worth buying back is a measurement: rounds 66/67 took monolithic init to 622 ms headless, and nobody has measured what share of that is the column copy against parse, id-map build, adjacency and style apply. Expectation to falsify: the copy is single-digit milliseconds of the 622 and adoption is declined with the number recorded.
  3. The per-frame CPU→GPU upload is the floor, and it is WebGPU's. ColumnMirror uploads one coalesced span per dirty column via queue.writeBuffer — which the WebGPU spec defines as a copy into a driver staging area (Dawn manages the ring). WebGPU has no persistent host-coherent mapping; the alternatives (mapAsync staging rings, mappedAtCreation for full uploads) re-implement Dawn's own machinery and still copy once. The dirty span discipline already minimizes bytes; the per-byte copy cannot go to zero on this API. The census still measures it (stats().uploadedBytes exists) so the record can say "at the floor" with a number rather than a shrug.
  4. The worker boundary (round 86): 0.086 ms/frame at harness scale, 0.747 ms at 500k nodes, versus a flat ~0.02 ms for a SAB span-notice design — measured head-to-head, recorded in 86.1's landed record. What the copy buys: transactional coherence (a batch is a snapshot; SAB needs double-buffering or epoch fencing), no COOP/COEP demand on embedders, trivial capacity growth, and a protocol testable headless via structuredClone. The SAB tier is a designed-but-deferred pass of this round (110.3), armed by 86's own trigger: a real app measuring span traffic above ~1 ms/frame.
  5. Export readback does per-pixel CPU work, not just a copy. readbackExport maps the staging buffer, then walks every pixel in JS: row-unpad, BGRA swizzle, un-premultiply (renderer.mts, the readbackExport loop) — at a 4K-class publication figure that is ~33 MB touched byte-by-byte in JS, then cy.png copies again through canvas2d putImageDatatoDataURL. This is the plainest win on the list: a small compute pass can un-premultiply and compact rows on-GPU so the mapped buffer is already final pixels, and the result can transfer to the caller (the worker host already transfers it). Rounds 77/78 (SVG export, headless figure generation) sit next to this.
  6. Small, already-cheap crossings: label sidecar entries cross the worker boundary as structured clones (tens of bytes each, label-dirty only); algorithm results and force settle readbacks are one-shot per run, not per frame. The census confirms they stay under the line rather than assuming it.

What "remove the copying" cannot mean here

  • Not the GPU upload — the API has no zero-copy path (item 3).
  • Not aliasing caller memory silently — a store that adopts a buffer must own it; the wire path can promise that (the payload is purpose-built), options.elements arrays from an app cannot.
  • Not SAB by default — cross-origin isolation is an embedder-wide demand a library cannot impose (86.1's recorded decline); it can only ever be an opt-in tier behind a capability check.

The plan

  • 110.1 — the copy census, measured. Instrument bytes-copied and ms per pathway — ingest (parse / id blob / column copy / adjacency / style apply split), per-frame mirror upload, worker batches, export readback — on ndex-x-large at load and on an animated session, headless and rendered. Publish the table in the record and src/README.md. The gate for every later pass: a pathway proceeds only if it prices at ≥1 ms/frame sustained or ≥5% of init; everything under the line is recorded as at the floor / declined with its number, the way 86.1 recorded SAB.
  • 110.2 — zero-copy bulk ingest (adopt wire columns), if the census says so. The store adopts aligned payload buffers as initial backing for the per-element columns the wire carries; copy-on-grow restores the ordinary policy on the first capacity change. Constraints named now: adoption applies to the wire/columnar path only (ownership is promisable there); it fits the fresh-instance bulk load, where slots allocate contiguously from 0 so a payload column's layout is the store's layout — edge.endpoints is the exception even then, since ingest remaps payload node indices to slots (addEdgesColumnar), a transform rather than a copy, though on a fresh instance the identity mapping makes even that adoptable; the fuzz gates (48.3) must run against adopted backing too, and round 103's progressive ingest composes — each chunk's columns adopt the same way. Ties to ledger item 43: if the wire format goes public, its alignment guarantee becomes contract, and this pass is the reason to write it down.
  • 110.3 — the SAB tier for the worker host, designed now, built on trigger. Write the design in full — per-column double-buffered epochs (the worker reads buffer epoch % 2, main writes the other; the batch notice flips), growable SAB with a declared ceiling (ties to item 35's scale-ceiling round), a capability probe (crossOriginIsolated), loud rejection without it, spelled renderer: { worker: true, sharedMemory: true } — and land the spec of the design, not the code, unless 86's ≥1 ms/frame trigger has fired by then. The 86.1 head-to-head (0.02 ms flat vs 0.086–0.747 ms) is the whole benefit side of that ledger; the design doc is what makes the trigger actionable in an afternoon instead of a round.
  • 110.4 — export post-processing moves to the GPU. A compute pass un-premultiplies and row-compacts into the staging buffer, so the map yields final pixels; png()/jpg() keep their encoders but stop double-copying through an intermediate canvas where a direct encoder path exists. Verified by the existing WYSIWYG export specs plus one new golden-sized-export timing row. First measurement before building: the current JS loop's ms at 1×, 2× and full-graph export sizes — if even the 4K figure is single-digit ms, this pass is declined too.
  • 110.5 — the record. Whatever the census declines is written as a decided-against with its number, in src/README.md's design decisions — so the next "can we remove the copying?" starts from a table instead of from this question again.

Sequencing and non-goals

Run 110.1 any time; it is instrumentation plus one sitting of measurement. 110.2 and 110.4 are independent afterwards; 110.3's design can be written with 110.1, its build waits for the trigger. Nothing here touches the dirty-span discipline, the wire format's layout (beyond documenting the alignment promise), or the round-106 multi-consumer question — though 110.3's epoch scheme is deliberately shaped so a second consumer (106's cursors) could share it.

Non-goal: chasing relative ratios. The SAB row reads "34× faster" at 500k nodes and is still 0.73 ms of a 16.7 ms budget; every pass above gates on absolute cost against the frame or init budget, which is the lesson 86.4 just paid for (the occupancy win that "obviously" existed measured at ~0.2 ms because the architecture had already removed it).

The kind field tells the truth

The maintainer asked a question the record could not answer: how can you tell which rounds are completed? Round 10 is logged as a plan, but it's probably already completed. It was — on 2026-07-27, seventeen items, each its own commit. Its file was still named plan, and so were thirty-two others.

Why it drifted, precisely. Round 108.7 put the bookkeeping in the filename so nothing has to parse prose, and 108.2 had already made a round's plan and its landed record one file, renamed on landing. Both rules work. Neither was retroactive: the rounds written before them had their plan amended in place, so the outcome is inside a file whose name still says plan. Round 60's items each read — landed:; round 10's opens **Round complete (2026-07-27): all 17 items landed**; round 42's opens **Landed 2026-08-04.**. Read through plan/INDEX.md — the way an agent reads it — all thirty-three looked unbuilt, and the planned queue looked three times its real size.

The record's own doctrine says what to do about that: the filename carries the state, so the fix is renames, not a status list someone maintains.

What the files say, and what they were renamed to

Each of the three groups was decided by reading the file, not by its date.

  • 33 rounds renamed planlanded: 10, 14–18, 20–37, 39, 41, 42, 57, 59–63. Twenty-two declare their own completion in the text; the rest have a checklist whose every item is ticked with a landing date. Four of them landed with one item held for a call and say so in place — 27.8 (border-style, stopped for a scope call), 34.0 and 35.0 (the docs-first items, never ticked), and 41.5 (functional preventDefault(), open call 12, since declined in its own round). A round with an item held for a call is a landed round; the file says which item, and the index does not need to.
  • 5 rounds merged into the one-file shape: 13, 19, 86, 90, 101 each had a separate plan and landed section, the pre-108.2 pair. The landed text is appended verbatim under a ### Landed heading in the plan's slot — where the round begins, which is where 108.2 keeps a round's one file — and its item headings are demoted one level so they nest under it. The five ## heading lines this drops are the only text the round removes, and they are kept here rather than lost: ## Style-prop parity, ## Slot-moving compaction, ## The worker-hosted renderer, landed, ## API review and cleanup, ## Quiet verification: green prints nothing, red prints only failures.
  • 3 sections re-kinded landednote: rnd0091_0097, rnd0098_0100 and rnd0102_0107. These are planning sweeps that scoped rounds — 13, 58 and 29 lines, no landed item between them, one of them saying outright that "nothing below is committed until a plan survives" maintainer feedback. Filed as landed, they marked seventeen unbuilt rounds as shipped. They are notes, like the rnd0015_0018 design sitting they resemble.

Within-date counters were renumbered where a merge left a gap. Nothing else in any section changed.

What holds it

Three gates in test/modules/plan-record.mjs, and a table that is generated rather than remembered.

  • A plan file may not record its own landing. landingEvidence() reads the two forms the record used: the self-declaration and a checklist with items ticked and none open. The declaration is matched with its round number, compared to the file's own — round 28's plan opens by noting round 27 is complete apart from 27.8, and a gate that ignored whose round it was would have renamed 28 for a sentence about 27. The control: renaming round 34's file back to plan turns the gate red.
  • No round is both a plan and a landed record. That is the one-file shape, now true of the whole record rather than of everything since 108.2 — which is what makes the state below a single lookup instead of a merge.
  • plan/INDEX.md publishes the derived state. A new "Which rounds landed" table: a round is landed once a section names it so, its own or one of its sub-rounds' — round 12 landed as 12a/12b/12c, round 108 as 108.7/108.8/108.9 — and planned while only its plan and the notes around it are on file. It is regenerated by npm run plan:index and gated by the index-drift assertion that was already there, so it cannot go stale the way a hand-kept status list does. Today it reads: landed 7–39, 41–48, 52–70, 86, 90, 101, 108, 111; planned 40, 49–51, 71–85, 87–89, 91–100, 102–107, 109–110.

What this round does not claim. The state is read off the names, and the names now match what each file says about itself. A round whose file overstates what shipped still overstates it — this gate makes the record consistent with itself, not with the source tree. EXECUTIVE_SUMMARY.md remains the document that is checked against the code.

Verification. npm run -s verify; test/modules/plan-record.mjs 15/15 with the control run red on round 34; npm run plan:index clean; 148 sections (was 153 — five merges).

A round that shipped nothing is still closed

Round 111 renamed thirty-three files whose round had landed while the name still said plan, and built the gate that stops it recurring. It missed one, and the miss has a shape worth keeping: round 40 shipped nothing on purpose, and every form 111 taught the gate looks for evidence that something shipped.

The seventh design sitting (2026-08-09) read round 40's own taxonomy — 198 error sites classified, only two families recoverable, one of them meaningless to demote while v4 has no fallback renderer — and took a smaller call than even the measured recommendation: no cytoscape.warnings(), no errorPolicy, no re-tiering. The file says so in as many words: "So round 40 ships nothing". Its lasting outputs are the taxonomy, now the recorded rationale for the fail-loudly contract standing whole, and the closure of ledger item 4's second half.

Verified against the source, not the prose: no warnings anywhere in src/, gc() present as Core.prototype.gc = Core.prototype.compact (src/core.mts:3307) — the sitting's decision is what the code does.

So for nineteen days the derived state read planned 40, … while another section of the same record already read "Round 40 closed with no new surface" (the parity-gap analysis, at its 2026-08-09 sweep), and EXECUTIVE_SUMMARY.md — the document that is checked against the code — had never listed it under "not yet built" at all. Two of the record's three views were right and the generated one was wrong, which is the inversion worth naming: the derived table is trusted because it cannot go stale, so when its inputs are incomplete it is the most confidently wrong thing in the repository.

What changed

  • rnd0040 is renamed planlanded. Not because code shipped — none did — but because the kind field is read to answer what is left to do, and a round closed by decision is not left to do. This is the same reading 111 applied to the four rounds it renamed with an item held for a call: the file says what the outcome was; the index only says whether one exists.
  • landingEvidence gains closes, beside declares: the round number matched against the file's own, exactly as the landing forms are, because round 79's plan says an item of its own "ships nothing" (test-only) and must not read as its round closing.
  • The gate rejects a plan file that records its own close, and the index's derived table moves round 40 into landed7–48 now runs unbroken.

Verification

npm run -s verify; test/modules/plan-record.mjs green, run once with round 40's file renamed back to plan — red on the new clause, with the file named in the message. Two unit controls beside it: the same sentence attributed to round 39 does not close it, and round 79's item-level "ships nothing" does not close round 79. npm run plan:index clean, 149 sections.

What this does not claim, restating 111's own limit: the state is read off the names, and the names now match what each file says about itself. A scan of every remaining plan file for closure language found round 40 alone, so the correction is complete for the forms the record has used — not proof that no future round invents a fourth way to end.

The flow layout: a Sugiyama-class built-in

Raised by the maintainer on 2026-09-01, taking up open call 49's layered/hierarchical half: v4 gets a built-in layered layout, named flow, comparable to dagre, ELK layered and Graphviz dot — and the call's build-vs-port question is answered build. The literature survey that opened this round found that the reference JS implementation embodies exactly the defects a new implementation would exist to avoid: dagre is unmaintained, predates the Brandes–Köpf erratum (arXiv:2008.01252, whose second flaw no prior implementation had solved), normalizes long edges into O(V·E) dummy chains, and gets cluster rank spans wrong (dagre #117); elkjs is a ~1.5 MB GWT transpilation; dot's network-simplex x-pass tops out around a thousand nodes. Nothing in the JS ecosystem combines a correct BK-with-errata coordinate pass, Barth–Jünger–Mutzel cross counting, Eiglsperger segments and compound support — that combination is the round.

The decisions

  • cy.layout({ name: 'flow' }) (maintainer's name), riding the extension contract exactly as force does — new CustomLayout(this, { ...options, impl: FlowLayoutImpl }) — so the Layout union does not change; the dispatch throw message gains 'flow'.
  • Compounds get both modes, global first. The default is one global layering with nesting constraints (Sander TR A/03/96 border ranks; Forster 2004 constrained crossing reduction for contiguity) — the scheme dagre attempts and ELK calls INCLUDE_CHILDREN — because it is the only one that keeps cross-boundary edges direction-correct. A per-compound recursive escape hatch (compoundMode: 'separate', ELK's SEPARATE_CHILDREN) is a later pass of this round.
  • No emitted bend points, ever (maintainer, in review): where dagre and ELK hand back per-edge polylines, flow is designed so that style-driven edges — taxi above all — route themselves well from node positions alone. Routes derive live in evalTaxi (curve-geometry.mts:1389), so a dragged node keeps a sane edge without any layout re-run. The layout's side of the contract is placement: rank rows leave node-free horizontal bands for taxi turns, and long edges reserve a vertical corridor that coincides with a taxi leg.
  • CPU is the spec (round 59's rule). The survey found no credible GPU Sugiyama — the phases are sequential and combinatorial — so the implementation is typed-array CPU work; worker offload for very large graphs is a possible later pass, not v1.

The algorithm stack

Chosen per phase from the survey, favouring the near-linear modern choice with a budgeted quality dial:

  1. Cycle removal — greedy FAS (Eades–Lin–Smyth 1993) with bucket lists, O(V+E), model-order tie-breaks for determinism; a DFS variant behind an option.
  2. Layering — network simplex (GKNV TSE 1993: tight tree, incremental cut values, balance) with an iteration cap, seeded by longest-path; layering: 'auto' drops to plain longest-path past ~50k nodes. Optional node-promotion post-pass (Nikolov–Tarassov).
  3. Long edges — Eiglsperger–Siebenhaller–Kaufmann segments (JGAA 2005): a long edge is one object with a p-dummy on its first interior rank and a q-dummy on its last, not a per-rank dummy chain. Linear dummies; the biggest structural advantage over dagre. v1 implements the inter-rank containers as plain arrays (correctness first); the O((V+E) log E) container tree is a measured follow-up.
  4. Crossing minimization — bidirectional layer sweep, barycenter with median tie-break, then transpose; best permutation kept by exact count, counted with the Barth–Jünger–Mutzel accumulator tree (O(E log V), weighted). thoroughness (1–10, ELK-style) scales the budgets. Compound contiguity and user order constraints go through Forster's constrained two-level reduction — engaged only when present, so the flat path stays lean.
  5. X-coordinates — Brandes–Köpf per the 2020 erratum, node-size-aware separation (Rüegg: halfW(u)+halfW(v)+nodeSep, plus group padding across compound boundaries), inner-segment priority, four alignments balanced by aligned median. Optional Rüegg 2016 scanline compaction later.
  6. Y-coordinates — rank rows from cumulative max half-height plus rankSep; computed canonically downward, transformed to direction at the end (breadthfirst's vocabulary).

The taxi contract, concretely

Built against evalTaxi's actual math: (a) span-1 edges with taxi-direction: downward and the default 50% turn put the horizontal run mid-gap between rank rows — a band the layout guarantees node-free; rankSep's default 60 clears twice taxi-turn-min-distance (10), so the ideal route always engages. (b) When BK aligns a chain, src.x === tgt.x and the taxi route degenerates to a straight drop — straightness comes free of any coupling. (c) A taxi edge turns exactly once, so a span ≥ 2 edge's reserved corridor must coincide with a taxi leg: alignLongEdges: true (default) biases x-assignment to place the corridor at the target endpoint's x, so with the recommended taxi-turn: 20 (px) the long vertical leg is the corridor. With the default 50% turn only span-1 is guaranteed clean — a documented style contract, the same status dagre-plus-taxi has today, and the docs and debug harness ship the recommended stylesheet.

The shape of the implementation

Five files under src/layout/, force's multi-file pattern:

  • flow.mtsFlowLayoutImpl implements LayoutImpl; defaults merge, loud validation, mappings resolved once; per weak component build → FAS → rank → order → position → y → direction; then ctx.packComponents; bare runs take the columnar ctx.setPositions path, animate/transform/subset take the ctx.layoutPositions finisher (grid's hybrid rule).
  • flow-graph.mts — compact reindex of nodeSlots() into a typed-array FlowGraph (src/tgt/weight/minLen/reversed edge columns, CSR both directions, halfW/halfH from the size column, group columns from paddingSumsOf in global compound mode); parallel edges collapse to weights, loops drop; greedyFAS + dfsFAS.
  • flow-rank.mtsrankLongestPath, rankNetworkSimplex, promoteNodes; global compound mode synthesizes per-group border ranks (bTop/bBot virtual nodes, nesting edges of minLen 1 and weight 0) so every compound spans a contiguous rank interval.
  • flow-order.mts — the segment model, sweepOrder, countCrossings, orderConstrained (Forster meta-node merging).
  • flow-position.mtsassignX (BK erratum), assignY, applyDirection.

Placement writes leaves only — parents derive through HierarchyIndex.flush(), which is the store's standing rule, so flow's compound support is spacing-aware placement, not parent writes.

Options v1 (FlowLayoutOptions in public-types.mts, joining the LayoutOptions union and the types-surface audit): direction, nodeSep (50), rankSep (60), layering ('network-simplex' | 'longest-path' | 'auto'), thoroughness (7), minLength and edgeWeight (number | score mapping | fn), acyclic, alignLongEdges (true), rankConstraints (min/max/same id lists — no selector strings), orderConstraints (before/after id pairs), compoundMode ('global' | 'separate'), componentSpacing (40). Every validation failure throws with a message-asserted spec (the throw-coverage gate).

The passes

  • 112.1 — the quality harness (the call's required first measurement, before any implementation): benchmark/layout-quality.mjs over real DAG fixtures (dependency graphs, workflow DAGs, a layered compound fixture; small → 10k nodes), measuring geometric crossings, edge-length mean/variance, bounding area and runtime for @dagrejs/dagre and elkjs as devDependencies (outside src/, which imports nothing beyond itself). Landed 2026-09-01 — the baselines are below, and they moved the bar: at 10k neither reference engine is usable, so "comparable to dagre" is a bar dagre itself clears only on small graphs.

  • 112.1's measured baselines (i9-9900K, Node v22.22.2, dagre 3.1.1, elkjs 0.12.0, --stack-size=8192 — both engines overflow the default V8 stack near 10k nodes; per-cell cap 300 s, the maintainer's rule that no interactive use waits five minutes for a layout; time is median of 3 after a warmup; crossings count the engines' own emitted polylines, endpoint-sharing pairs excluded; rerun with npm run benchmark:layout-quality):

    fixture (n / m) engine crossings len mean / cv area Mpx² time
    deps (428 / 510) dagre 4,279 1,934 / 1.08 33.3 466 ms
    deps elk 3,382 1,964 / 1.12 94.4 336 ms
    workflow-1k (960 / 1,914) dagre 20,570 6,224 / 1.35 103.4 10,793 ms
    workflow-1k elk 21,825 4,945 / 1.55 554.9 1,674 ms
    deep-skips (1,045 / 2,458) dagre crash
    deep-skips elk 19,198 4,372 / 1.61 547.1 2,676 ms
    compound (846 / 1,742, 34 parents) dagre hang (killed)
    compound elk 21,165 2,936 / 0.58 192.1 1,618 ms
    workflow-10k (10,363 / 21,621) dagre DNF, 20+ CPU-min (killed)
    workflow-10k elk 62 s single run (over the cap)

    The failures are the finding: dagre crashes on the long-skip stressor (Error: Not possible to find intersection inside of the rectangle), never returns from the 846-node nested-cluster fixture, and ran a single 10k-node layout past 20 CPU-minutes before being killed; elkjs survives everything but needs 62 s for one 10k run. Both reference engines also emit routed polylines whose crossings the table counts — flow's taxi contract will be measured on the geometry cytoscape actually draws.

  • The bar for 112.2, set from the table: on the fixtures dagre completes, flow's crossings and area within ±15% of the better engine and runtime strictly under dagre's; on the fixtures dagre fails, flow must simply complete well inside the 300 s cap — the working target is single-digit seconds at 10k, i.e. roughly an order under elkjs, which typed-array columns and Eiglsperger segments make a plausible ask rather than a hope.

  • 112.2 — the core pipeline, flat graphs: the five modules, options and validation, dispatch, types, test/layout-flow.mjs, the migration-guide built-ins array and MIGRATING.md, src/README.md section, debug dropdown, CHANGELOG, and a benchmark/layouts.mjs row (fit:false, shared box, in-row crossings/area assertion outside the timed loop). Gate: suites green and the 112.1 bar met.

  • 112.3 — compounds, global mode: border ranks in layering, Forster contiguity in ordering, padding-aware separation in both axes, and an explicit design plus spec for edges incident on parent nodes (ranked against the border nodes). Gate: contiguity/containment/padding specs green, and the flat path's bench row unmoved when hasCompounds() is false.

  • 112.4 — polish: long-edge endpoint alignment, node promotion, optional compaction, thoroughness tuning against the harness, the taxi demo, and the container tree if a benchmark says arrays hurt.

  • 112.5 — later: compoundMode: 'separate'; possible worker offload past ~100k nodes.

The specs that discriminate

test/layout-flow.mjs, modeled on radial's (headless 400×400, every property with a documented control): layer validity (shuffled ranks go red), simplex quality on a fixture where longest-path is suboptimal, crossing counts against known optima (skipping transpose goes red), BK straightness (chains share an x; rank-centering goes red), the long-edge corridor (no node body in the strip; alignLongEdges: false goes red on the crafted fixture), brute-force optimal-x comparison on ≤ 8-node graphs for the erratum port, FAS minimality and termination on cycles, bit-identical determinism across runs, compound contiguity and containment (disabled Forster merging and dropped pad separation each go red), rank constraints, a taxi spec asserting an aligned chain's segmentPoints() are collinear, and the finisher plumbing (fit/spacingFactor/transform/animate, subset scope, locked nodes).

What landed (2026-09-01, passes 112.1–112.4)

All four passes shipped the same day; the specs, gates and quality harness ran green at each commit. The final four-engine sweep (i9-9900K, 300 s cap, median of 3; flow-taxi scores flow's node positions under the recommended taxi polyline):

fixture (n / m) engine crossings len mean / cv area Mpx² time
deps (428 / 510) dagre 4,279 1,934 / 1.08 33.3 505 ms
elk 3,382 1,964 / 1.12 94.4 409 ms
flow 4,407 1,251 / 1.35 24.2 45 ms
flow-taxi 5,168 1,424 / 1.25 24.2 45 ms
workflow-1k (960 / 1,914) dagre 20,570 6,224 / 1.35 103.4 22,208 ms
elk 21,825 4,945 / 1.55 554.9 1,688 ms
flow 19,482 3,256 / 1.14 94.3 116 ms
flow-taxi 26,851 3,351 / 1.11 94.3 117 ms
deep-skips (1,045 / 2,458) dagre crash
elk 19,198 4,372 / 1.61 547.1 2,778 ms
flow 23,956 3,253 / 1.47 393.8 173 ms
workflow-10k (10,363 / 21,621) dagre timeout (300 s)
elk 913,324 61,914 / 1.97 85,218 53,326 ms
flow 893,089 62,379 / 1.50 3,917 7,104 ms
compound (846 / 1,742, 34 parents) dagre hang
elk 21,165 2,936 / 0.58 192.1 1,736 ms
flow 27,664 3,725 / 1.21 81.1 100 ms
reactome (227 / 245, real bio DAG) dagre 73 407 / 1.72 6.8 86 ms
elk 54 497 / 1.64 9.4 137 ms
flow 64 460 / 1.38 7.3 10 ms
flow-taxi 3 514 / 1.27 7.3 11 ms

(The reactome fixture joined after the round's close — Reactome's human Immune System pathway hierarchy, R-HSA-168256, CC0, derived by derive-reactome.mjs — and it inverts the taxi finding: on a real tree-like hierarchy the taxi polyline scores 3 crossings against dagre's 73 and elk's 54, because shared-parent fans merge into orthogonal buses. The taxi contract loses on dense layered meshes and wins outright on hierarchies — both halves now measured.)

Against the 112.1 bar: runtime strictly under dagre's everywhere (45 ms vs 505 ms at the small end; 116 ms vs 22.2 s at 1k), completes every fixture dagre fails, and 7.1 s at 10k meets "single-digit seconds" — 7.5× under elkjs with fewer crossings and 22× less area. flow is the best engine outright on workflow-1k. The one missed clause: crossings within ±15% of the better engine on deps (+30% vs elk; +3% vs dagre) and on deep-skips/compound (+25–31% vs elk) — the recorded residue, with sifting and the segment container as the named levers.

Deviations from this plan, recorded when taken:

  • Dummy chains, not Eiglsperger segments, in v1. Correctness first; every fixture keeps spans small. The trigger for segments is now met with a number: a dense non-layered input (em-web, 569 nodes / 6,899 edges) explodes to 179k layered items and 4.6 s of ordering. Until segments land, an explosion valve (nTotal > 8·n + 1000) drops such graphs to a lean sweep budget (4.6 → 1.8 s); fixture numbers are untouched by the valve.
  • Block-graph longest-path compaction instead of BK's class/shift machinery — the erratum documents two defects there; a block-DAG pass has neither, and the four-way balance recovers the rest. Validated by a fuzz spec (separation and order over random DAGs × 4 alignments).
  • alignLongEdges was withheld, not shipped. 112.4 measured the taxi polyline (50% turn) scoring more geometric crossings than the straight-line drawing on every fixture (deps 5,168 vs 4,407; 1k 26,851 vs 19,482) — corridor/taxi coincidence needs taxi-aware ordering, not a placement flag. The harness keeps the flow-taxi adapter so the gap stays measured; the option returns with the taxi-aware pass (112.5).
  • compoundMode was withheld with it — global is the only mode until 'separate' exists (112.5); an enum of one is not a surface.
  • Ordering gained what the plan did not name, each step measured: DFS initial order (dot's init_order), transpose equality passes on odd sweeps (dot's reverse alternation), and a dual restart (barycenter-primary + median-primary, better final order kept) at thoroughness ≥ 5.
  • Component packing packs body boxes, not position boxes — the harness's overlap column caught packComponentsExact overlapping deps' 164 singleton components (130 body overlaps), and flow packs its own extents via shelfPack.

112.5 (later): the named levers. Taxi-aware ordering (count the taxi polyline's crossings, not the chain's) and the returned alignLongEdges; the Eiglsperger segment container (trigger met); sifting as a quality mode for the elk crossing gap; compoundMode: 'separate'; a worker offload past ~100k.

Known risks, recorded up front

The 50%-turn default only guarantees span-1 taxi cleanliness — the px taxi-turn stylesheet is a documented contract, not a silent assumption. Parent-incident edges need their own 112.3 design. Network-simplex worst cases are bounded by the iteration cap and the longest-path seed; determinism requires strict model-order tie-breaking. Compound label space is not reserved (padding only) — the same stance every other layout takes, recorded as a deviation note.

The performance review: the full suite against the 13 Aug baseline

The maintainer's directive, 2026-09-01: run the full benchmark suite, fix the straightforward regressions, log the complex ones. The last published runs were the post-merge baseline at 0d3561de (13 Aug: the all, renderer and algorithms-gpu profiles); 43 source commits had landed since — rounds 85–98 (the layout, renderer and runtime rounds), 90's API review, 101, 108–112. This round is the measurement of that span, read the way the archive's own rules say to read it: medians of three, screened against each row's own band, with the frozen v3 twin as the control.

What the review found, in one table

profile run rows shared with 13 Aug drift screened regressions v4-slower pairs
all (Node 22 — discarded) results-2026-09-01T22-32-32-993Z 450 +7.9% 72 2
all (Node 24) results-2026-09-01T23-46-31-527Z 450 (281 epoch breaks) −0.8% 1 1
renderer results-render-2026-09-02T00-36-36-160Z 0 (348 epoch breaks) 0
algorithms-gpu results-alggpu-2026-09-02T00-59-16-334Z 171 −4.5% 0

The library did not regress across the span. The one screened regression is a row whose own band is 17% and which moved 19% (below); the one v4-slower pair had read at parity in the baseline too and was fixed in 113.2. The first hour of the review measured the wrong thing entirely, and two of the three fixes this round shipped are about the instrument, not the library — which is the round's actual finding.

113.0 — the review that measured two engines (2026-09-01)

The sweep was launched from a shell that had not activated mise, so the system Node ran: 22.22.2 against an archive measured on 24.18.0. Nothing refused it. The machine fingerprint ignores the Node version by design — round 46.5 decided an upgrade must not split a box's history — and so nothing downstream can tell a V8 change from a library change. The comparison came back with 72 screened regressions, +7.9% whole-run drift, and every frozen v3 control moving too (+17% to +49% on the traversal rows, +78% on pan() get). That last column is what gave it away: v3 is frozen code, so when it moves, the box or the engine moved. An hour of measurement, discarded.

The same run failed layouts.mjs on all three passes, and came back under a different machine fingerprint (0e0e967f against the archive's 2d2ea233) on the same i9-9900K, because a kernel upgrade (6.19.14 → 7.1.10) had moved os.totalmem() by 1,187,840 bytes and total RAM entered the hash byte-exact. buildComparison refused to draw a line from any published run to the new one — the cross-machine rule doing its job on the wrong input.

113.1 — three instrument fixes (2026-09-01)

  • The flow row's fixture is one component (317305a0). The failure was "90 distinct rows for ~45 stages — ranks are not forming rows", and the assertion was right: the fixture's skew edge used a fixed column map (i * 7 % stageW), which is a permutation of the columns, so the staged DAG split into one component per orbit and flow packed the components as stacked tiles. The same count reproduces at 240d80a2, the commit that added the row — it had never passed at the size the --all table runs it at. Adding the stage to the skew links different column pairs at each rank; the fixture is one component, flow draws 36,538 crossings to breadthfirst's 45,473, 45 rank rows, 178 ms/iter.
  • The fingerprint rounds RAM to the GiB; the archive re-stamped (5107c36e). fingerprint() hashes Math.round(totalBytes / 2 ** 30); a fitted 16 GiB still changes the id, a kernel's reservation does not, and the spec pins both. scripts/benchmark-backfill-fingerprint.mjs recomputes the id from each published run's stored meta.machine block — the harness backfill's shape from 65.12 — and all 21 runs moved from 2d2ea233 to 5cf3f79c, which is also what the new box state hashes to. One machine, one history.
  • The runner refuses a Node other than .nvmrc's (0f7ff936). report.mjs exits before spawning anything on a different major (--any-node is the deliberate override, for pricing a runtime rather than the library); benchmark:publish carries meta.nodeVersion into the index as node and warns when it differs from the previous run of that (machine, profile). The benchmarking note gained a "before the run" section with all three lessons.

The Node 24 review run, read row by row

--all --repeat 3, serial, Node 24.18.0, at 5107c36e (tree clean; the two 113.1 commits touch no src/). 45.4 min. 450 rows shared with 13 Aug; 281 epoch breaks, all in four suites whose harness files changed in the span — layouts (85.1/85.2's radial and mapping rows, and 113.1's fixture), spatial (92.1's exact-tier bounds rows), surface (90.5's removals) and labels (94.1's EDT rows) — which the page renders as breaks rather than percentages, as 65.12 designed. Those suites are read within-run only: every v3-vs-v4 pair in them reads v4-faster.

The one screened regression. data · query: predicate function (both sides) · gpu, 76.1 → 90.3 µs (+19%), its own band 17% on both sides, v3 control +3.3%. Three probes before touching code, per the benchmarking note:

  1. Through the built bundle, the same operation (nodes().filter(n => n.data('weight') > 3), 2,000 nodes) read 68.6 µs at 0d3561de and 73.4 µs at HEAD — +7%, not +19%; the suite's tsx __name wrapper amplifies closure-heavy rows (round 34's lesson).
  2. Finer: handle-only iteration was faster at HEAD (10.0 → 8.5 µs), a single handle's data() read was equal (36.2 vs 35.8 µs per 2,000 reads). Nothing on the path got slower in isolation.
  3. Per commit: the bundle was built and the micro run at each of the 43 source commits in the span. nodes.forEach(data) read 45.8 to 52.5 µs, median 47.7, with docs-only commits at both ends of the band (51.7 µs at a34e96bd, a record rewrite) and no step anywhere.

So the row's movement is the row's band. Logged, not fixed: what it needs is not a code change but a narrower instrument — the (x32) amplification 62.6b gave the nanosecond rows, applied to a row whose per-element cost is ~40 ns.

Unscreened movers (13): eight one-shot rows in curves and arrows (edge.controlPoints ×4000 +94% beside boundingBox ×20 −49% in the same suite — the one-shot pattern 62.7 named), and five inside their own bands. None carries evidence; none was acted on.

The pairs. 269 v3-comparative pairs, geometric mean 10.6× (13 Aug: 270 pairs, 10.7×). One pair v4-slower: core: filter(fn) at 0.96×. Worth recording against the executive summary's standing claim that "all 366 pairs read v4-faster": the 13 Aug baseline already had two pairs at or under parity (style('background-color') 0.98×, getElementById() 1.00×), and filter(fn) had read 1.01× there. The claim was true when round 62 wrote it and was not re-measured by the rounds that published after — exactly the staleness the summary's own rules warn about. This round re-measures it.

113.2 — the predicate filter stops re-interning (2026-09-01)

core: filter(fn)cy.filter(e => e.isNode()) over 6,000 elements — read 429 µs against v3's 414. Not a regression (1.01× on 13 Aug, 0.96× now, band 5%) but a pair at parity for a year-old reason, and a straightforward one. Two costs, both on the predicate path only (1cf40818):

  • Core._query answered a function by scanning. It compiled an empty query and built a fresh whole-graph collection — 6,000 handles re-interned through _eleFromRef — before the predicate saw one element, while elements() had answered the same question from the round-34.2 memo since round 34. It now filters _allOf(restrict), so cy.filter(fn), cy.nodes(fn) and cy.edges(fn) all start from the memo.
  • Collection.filter(fn) re-interned every kept ref to build the result, though the handle it had just called the predicate with is exactly what re-interning returns. It now passes those handles through the constructor's handles path, which slice() has used since 62.4 for the same reason.

Measured through the built bundle (Node 24.18, medians of 9), then in the suites:

row before after v3
bundle: cy.filter(isNode), 6k elements 260 µs 101 µs
bundle: nodes.filter(isNode), 2k nodes 71 µs 60 µs
bundle: nodes.filter(data('weight') > 3) 68 µs 64 µs
suite core: filter(fn) 429 µs (0.96×) 101 µs (4.1×) 414 µs
suite iter: filter(fn) 127 µs 107 µs 249 µs
suite sweep: filter(fn) 265 µs 79 µs 427 µs
suite data: predicate function 90 µs 86 µs 669 µs

The data row barely moves because its cost is the data() read per element, which confirms probe 2 above. Nothing observable changes: order is store order either way, the result is always a new collection (never the memo itself), the handles are the same interned objects, and a thisArg still binds — the new spec in test/collection-building-filtering.mjs pins each, plus that a structure change between two calls is seen. npm run -s verify and npm run -s test:node:quiet green.

The renderer profile: an epoch, read raw

All 348 rows are harness breaks — the render bench changed in 87.2 (the --layout rows) and 95.1 (the outlined-labels row), and the page refuses the line. (Round 117, after the fact: every --layout row on both sides of that break was a nine-iteration run — the GPU convergence readback had never delivered a value, found and fixed in 116.1 — so the first converged live-layout figures are round 116's, and item 54 carries the note.) Read raw, knowing that, the span's renderer rounds show as they were priced:

  • Device time down 50–66% on every scene's far-zoom pan (1.41 → 0.69 ms) and on the compaction pans (0.98 → 0.34 ms compacted) — the 91–94 rounds' resize, exact-fit and atlas work.
  • The curved scene's device time up: fit-all 10.6 → 13.2 ms, far-zoom 7.2 → 12.9 ms. Round 93's table priced exactly this when it chose 32 segments over 24 (13.19 / 14.14 ms measured then); a deliberate trade, recorded there, confirmed here.
  • init: create + ready (labels) on the wrapped-labels scene: 190 ms on 13 Aug, 307 ms in this run, 224 ms on an immediate single-scene rerun, with its outlined twin at 209 / 215. A one-shot row (unscreenable by rule) with a 40% spread between two readings an hour apart; the candidate is 94.1's atlas zoom tier, which does more work at first raster. Logged — see the follow-ups — because the row cannot say whether it moved.

The algorithms-gpu profile

171 rows, no epoch break, drift −4.5%, nothing screened (the profile runs once per row, so every mover is unscreened by rule). The GPU side read 20–52% faster on the n=512 families (affinity propagation 114 → 55 ms, heat kernel −37%, Floyd–Warshall −24%) with no commit under src/algorithms/ in the span — a driver or box state change, and the cpu control's ±1% says the CPU side did not move with it. Two CPU one-shots read up (affinity propagation n=1024 1.10 → 1.39 s, neighbourhood similarity n=1024 90 → 110 ms), and the sub-millisecond cpu rows (katz, pageRank) move by 0.1 ms steps because that is the profile's timer resolution. Logged with the other one-shots.

Follow-ups logged, not taken

Each is a measurement question before it is a code question, and none met the round's bar of straightforward:

  1. The wrapped-labels init one-shot (190 → 307 / 224 / 264 ms). Give the renderer's init rows repeats, or at least the two-reading band the --repeat runs record, before deciding whether 94.1's first-raster work is a cost worth a lazy tier.
  2. The predicate-row band (data · predicate function, 17%). A row at ~40 ns per element needs the (x32) amplification or a larger N to carry evidence; until then it will flag on every third run.
  3. The algorithms-gpu one-shots (AP n=1024 +27%, neighbourhood similarity +22%). The profile's rows are single-shot by design (a GPU sweep at three repeats is 50 minutes); a --repeat for the CPU side alone would screen them.
  4. The GPU-side speedups with no source change. Worth one npm run gpu and a driver-version line in meta.adapter so the next such step has a suspect on the record.

Verification run (2026-09-02)

A clean re-measurement at 9ed49abc (the record commit; src/ is 113.2's) — the renderer and algorithms-gpu profiles again, because their first runs ended on a tree carrying the then-uncommitted 113.2 edit and are flagged dirty, then --all --repeat 3 — published alongside the 5107c36e review run (four runs, all Node 24.18.0, all under the one fingerprint 5cf3f79c):

profile run against 13 Aug against the review run
all results-2026-09-02T02-03-57-714Z drift −1.4%, 0 regressions, 3 improvements (core: filter(fn) −76%, sweep: filter(fn) −69%, iter: filter(fn) −14%) 749 shared rows, drift −0.6%, the same two filter rows as improvements
renderer results-render-2026-09-02T01-25-18-372Z epoch break (by design) curved device 13.2 / 12.9 ms again; wrapped init (labels) 264 ms
algorithms-gpu results-alggpu-2026-09-02T01-47-37-618Z drift −4.5%, 0 screened AP cpu n=1024 back to 1.07 s (13 Aug: 1.10)

The pairs, re-measured for the summary: all 269 v3-comparative pairs, geometric mean 10.7×, minimum 1.02× (data: get), zero v4-slower; renderer 104 pairs, geometric mean 31×, zero v3-faster; algorithms-gpu 57 cpu/gpu pairs, geometric mean 7.7× (13 Aug: 7.2× — the summary's "13×" was a different sample, round 65.9's seven families; this is the whole executor sweep including the propagation tier's CPU-favoured sizes). So the round-62 property — every comparative pair v4-faster — holds again at 373 pairs, having been broken at one pair (and the baseline at two) without anyone measuring.

Two things the verification run added to the log rather than the ledger. One screened mover against the review run, compaction · control: nodes({ selected }) scan · peak +13% (22.4 → 25.2 µs, bands 4–7%) — a v4-vs-v4 control row whose source did not change between the runs; its compacted twin did not move, and it goes on item 54's list as a fifth row whose band is narrower than its behaviour. And the wrapped-labels init one-shot's fourth reading, 264 ms — the row's readings are now 190 / 307 / 224 / 264 against its outlined twin's 188–215, which is the shape of a row that needs repeats, not a fix.

Layout cleanup: overlap, locks, animation and the debug page

Raised by the maintainer on 2026-09-02 as a cleanup round over the whole layout portfolio (open call 49's "one excellent layout per use case" needs the ones that exist to be correct first). The ask, in the maintainer's words: the debug page's Preset should restore each graph's initial positions; the page needs a force "animate to end vs live" choice, a layout-appropriate edge-type toggle (taxi for flow, others for others) and a hover effect that dims or hides everything outside the hovered neighbourhood, for judging edge quality; fitting is broken in flow; spiral and radial do not prevent overlap by default; spiral does not animate; every layout should have an option to avoid overlaps including labels, not just bodies; force's animate: true should work like the discrete layouts' end tween, with a separate boolean for live updates; every layout should handle locked nodes sensibly; and a general review with more quality-of-output tests.

The decisions

Four were put to the maintainer before a line was written:

  • Spiral stays the debug page's extension-contract example, not a built-in. It is fixed through the contract (new context helpers) and its class moves to debug/spiral-layout.js, so the module suite can run it headless against the real library.
  • Labels join the overlap boxes by default: nodeDimensionsIncludeLabels defaults to true. A recorded v4 deviation from v3 (whose default was false), consistent with round 16.4's decision that boundingBox()/fit() include labels.
  • Full v3 lock semantics. A locked node — or every node under cy.autolock(true) — holds its position against position() writes, position tweens and every layout; it still takes part in a layout's structure and is an obstacle for overlap avoidance.
  • animateLive: true is force's live-streaming spelling; animate: true becomes settle-then-tween through the shared finisher.

And one in review of the plan: no generic overlap remover. A structure-blind push-apart pass would smear the rings, rows and wedges the discrete layouts just built, iterate where a constructive rule is one pass, and add a public contract member for what is really force's post-pass wearing a generic name. Each layout handles overlap in its own geometry; the shared pieces are the dimensions and body-box component packing, because every layout needs the same numbers.

What the survey found

Verified against HEAD bd92bbda before planning:

# Defect Where
1 flow + animate: true never fits: flow merges its defaults into a local object, but ctx.layoutPositions(fn) forwards the raw user options (fit undefined), and the finisher tests if (options.fit) where every bulk path tests fit !== false. This is the debug page's "flow fit is broken" — its animate box defaults on. flow.mts:197-248, contract.mts:346, collection.mts:5378,5414
2 The finisher's else if (zoom != null && pan != null) applies neither when only one is given collection.mts:5425
3 radial: no avoidOverlap, never reads node dimensions; levelSpacing is purely min(bb.w, bb.h) / 2 / (maxRing + 1) radial.mts:267-290
4 force: no avoidOverlap, never reads node dimensions; point-box component packing (packed components can overlap by a node width); animate: true is live streaming only; ignores spacingFactor / transform / animateFilter / duration / easing / zoom / pan / nodeDimensionsIncludeLabels force.mts:498-665
5 flow: body-only extents (labels ignored); applyBoundingBox ignores extents so bodies overflow a boundingBox flow.mts:326-336, 930-975
6 nodeDimensionsIncludeLabels honoured only by circle / concentric / breadthfirst (via layoutDimensions); grid, radial, force and flow ignore it collection.mts:5246
7 Locked nodes: only force and flow respect the lock (through nodeSlots()). grid, circle, concentric, breadthfirst, random, radial and preset all move a locked node, and so does node.position() — against the locked() JSDoc ("immovable, by layouts and position writes alike"). A headless probe confirmed every one. collection.mts:1777, every discrete layout
8 spiral: no node dimensions, a hardcoded cy.fit, bypasses the finisher so animate does nothing; the page never forwards the animate box for it debug/init.js:185-206, debug/layout.js:44-46
9 The debug page keeps no record of initial positions; Preset is passed { name: 'preset' } alone — a fit-only no-op debug/layout.js, debug/init.js:212-216

The passes

114.1 — shared node dimensions. src/layout/dims.mts: nodeDims(store, slots, { includeLabels, padding }) returns slot-parallel node-local boxes (x1/y1/x2/y2, plus maxW/maxH) from the size, border and label-box columns — the store's own bbox term, with the label box unioned in when asked (so the box is asymmetric: a label below makes y2 > -y1), hidden nodes sanitised to 1×1, padding added as half per side. Outline, overlay and ghost are deliberately excluded, as v3's layoutDimensions excluded them. LayoutContext.nodeDimensions() exposes it to extensions; Collection#layoutDimensions is reimplemented over it.

114.2 — finisher fixes and ctx.finish. fit !== false in the finisher; zoom-or-pan animates whichever is given; ctx.layoutPositions(fn, overrides) merges an impl's defaults while re-pinning the wrapper's stop (which resolves promise()); a new ctx.finish(slots, xy, overrides) picks finisher-vs-bulk with one rule. Flow's run() collapses to it, which closes defect 1.

114.3 — locked nodes, one rule. _positions skips locked slots and returns early under autolock; locked() reads autolock too; the finisher places only unlocked leaves and frames locked ones where they stay; the animation position channel filters locked refs; grid and circle (which place by index) exclude locked nodes from their cell count; concentric, breadthfirst and radial keep them in the structure and let the finisher hold them; preset's bulk map form skips them. Drag already refused them.

114.4 — body-box component packing. Flow's private packBodies moves into pack.mts as packComponentBodies (point boxes when no dims are given, so packComponentsExact becomes a wrapper); ctx.packComponents packs body boxes by default.

114.5 — force. animate: true settles silently then tweens through the finisher (spacingFactor, transform, animateFilter, duration, easing, zoom and pan now apply); animateLive: true streams as before, on both executors; avoidOverlap (default true) with avoidOverlapPadding (default 10) — a private post-settle separation over the body boxes, pinned nodes as obstacles, run before the body-box repack. Invisible under the tween; one end-of-run adjustment under animateLive, the same class as the repack shift round 59 recorded.

114.6 — flow, radial and the rest. Flow's extents take the shared dims (labels included), it packs body boxes, and applyBoundingBox scales and centres by extents. Radial gains avoidOverlap / avoidOverlapPadding: per-ring radii grown for radial clearance between rings and tangential clearance between angular neighbours (concentric's chord rule, applied per pair because wedges are not uniform), wedge angles untouched. Grid, circle, concentric and breadthfirst route their dimensions through the helper. Preset and random get no avoidOverlap — positions are the user's, and a pushed-apart scatter is neither random nor uniform.

114.7 — the debug page. Preset restores a snapshot taken synchronously after construction (the factory runs the load layout synchronously, flow included); a force-only Live checkbox; a layout-appropriate edge-type checkbox (default on) that re-applies the sheet with an edge override on Apply — flow and breadthfirst take round-taxi in their direction, the ring and grid layouts bezier, force haystack, preset and random the sheet's own; a hover select (none / dim / hide) over the closed neighbourhood, timed in the console so the page doubles as round 102's first measurement; the spiral example rewritten over the new context helpers. The pure parts live in debug/layout-config.js and debug/spiral-layout.js for the module suite.

114.8 — the layout quality suite. test/layout-quality.mjs: every layout (spiral included, through the contract) over six fixtures — a balanced tree, a 40-leaf fan, a rank-skipping DAG, three components with singletons, long labels, a locked node — asserting placement, fit (the rendered gap equals the padding on the binding axis), boundingBox containment, no body overlap by default and no label overlap with labels on, locked holds and is not overlapped, animated runs end where the sync run ends and fit, lifecycle once, components disjoint — each overlap row paired with the control testing.md requires, asserted red.

114.9 — docs, types, gates, close.

What landed (2026-09-02, passes 114.1–114.9)

Every pass landed as planned, in order, each its own commit; what the work found on the way is the part worth reading.

  • 114.1 src/layout/dims.mts and ctx.nodeDimensions(); Collection#layoutDimensions reimplemented over it, its default flipped to labels-on.
  • 114.2 The finisher tests fit !== false and animates a lone zoom or pan; ctx.layoutPositions( fn, overrides ) re-pins the wrapper's stop; ctx.finish(); flow's run() collapses to it. The debug page's "flow fit is broken" was exactly this.
  • 114.3 The lock, everywhere: _positions, _shift, the animation position channel, the finisher, grid and circle's index placement, preset's bulk form, force's pinned set under autolock, locked() reading autolock. cy.json() keeps exporting the node's own flag — the one existing spec the change turned red.
  • 114.4 packComponentBodies shared; ctx.packComponents packs body boxes by default and takes the impl's own array (positions), which the spiral example needs so nothing lands before a tween.
  • 114.5 Force: animate tweens, animateLive streams, avoidOverlap separates. The first separation — Gauss–Seidel sweeps alone — cleared sparse fields in a sweep but left a 200-clique of wide labels with 164 overlaps after 200 sweeps: a pile expands under pairwise pushes only slowly. The shape that landed is sweeps → an exact per-component scale about the centroid for what remains (a similarity transform, so the sim's structure is kept; pinned components left to the sweeps) → sweeps, up to four rounds. Measured: ~100 ms on a 20k-node, 18 s CPU run; 4025 overlaps on a 2000-node labelled tree cleared for 14 ms; the force spec file green ten runs of ten (open call 52's chain spec included).
  • 114.6 Radial's rings grow; flow's extents and box; grid, circle, concentric and breadthfirst on the shared reading. Radial's first chord rule used a box's longer side and let two 40 px squares meet corner-on at 45°; the diagonal is the guarantee.
  • 114.7 The page. Verified in a scripted Chromium (the extension was not connected): npm-deps under ?layout=flow&animate=true fits; em-web's force tween and live runs both fit; Preset returns every node to its load position exactly; the edge-type box switches em-web's haystack to bezier for circle and back; hover dim and hide touch 7,465 of 7,468 elements and restore to zero; a locked node in a dragged selection stays while the others move. A follow-up from the maintainer's review: the DAG sheets (reactome's pathway names run to 91 characters, npm-deps' package paths to 45 with no whitespace) drew their labels as one line across the neighbours — they now wrap to a column under the node, breaking anywhere, and a harness spec requires wrapping of any fetched network whose labels are long at the 90th percentile (the anywhere rule when most of those have no whitespace).
  • 114.8 test/layout-quality.mjs: 170 specs in under three seconds. The controls found two things the green rows had not: concentric let two 30 px squares meet corner-on (v3's chord rule, fixed to the diagonal as radial's was), and the labelled ring never exercised flow's extents — cycle removal makes it a chain with one node per rank — so a labelled fan joined the fixtures. Run once each with force's separation stubbed, radial's ring growth off, the label term dropped and flow's extents zeroed, the suite goes red every time.
  • 114.9 Docs, types, the benchmark row, this record. The row (layout: force settle — avoidOverlap on vs off, in the seed block of benchmark/layouts.mjs, BENCH_OP=overlap) reads at the table's N=2000: 131 ms with the separation against 121 ms without, twenty iterations — the post-pass is ~8% of a short run and vanishes into a converged one.

Verification run (2026-09-02)

npm run -s verify green (2,558 unit specs); the module tier green (612); test:throws:quiet and test:node:quiet green; build:types regenerated and committed; the page driven in Chromium as above. Deviations from v3 recorded in MIGRATING.md: force's animate meaning, nodeDimensionsIncludeLabels defaulting to true, and the lock honoured by writes.

Known risks, recorded up front

  • Default output changes: force, flow and radial (overlap avoidance on), and labels-on dimensions for circle, concentric, breadthfirst and grid.
  • Force's animate: true changes meaning (streaming → tween); animateLive is the escape hatch, and MIGRATING.md says so.
  • fit !== false in the finisher: an extension relying on "no fit unless asked" now fits, which is v3's default.
  • The GPU lease order must stay readback → finishForce → settle → finish, so the finisher's tween takes its own lease on a released one.
  • Open call 52 (the chain spec's intermittency): the sim is untouched; the settle path is not, so the spec is re-run loud.
  • The separation pass at 100k nodes on the GPU path is capped, and a benchmark row makes its cost a number.

Follow-ups logged, not taken

  • Size-aware (disc) repulsion inside the sim, CPU and WGSL, so a live force run is overlap-free during the run rather than at settle — a shader change with parity tests.
  • Force ignores boundingBox; the sim has its own frame.
  • A locked child still travels with a dragged compound parent through shiftSubtree; v3 leaves it behind and re-derives.

Exact overlap separation: the layouts stop over-separating

Raised by the maintainer on 2026-09-02, the day round 114 closed: its overlap avoidance "seems to avoid overlap, but at the expense of over-separating the network". Every layout on the debug page came out "way too spread out — far more spread out than even v3 with the option to include labels in the bb", the discrete layouts should be "comparable to v3, broadly but not necessarily exactly", the force layout was "also way too spread out", and the round was to bring better tests and a look at the page itself, zoomed in on particular nodes, since "things are not only good with respect to tests but also visually". Two asks followed mid-round: checkboxes on the page for the overlap behaviour, and overlap avoidance off by default on the page — "perhaps a Chesterton's Fence scenario. Just the defaults."

What the measurements said

The browser extension was not connected, so the page was driven in Playwright's Chromium: each layout on em-web (569 nodes, 6,899 edges), a fit screenshot, a 1:1 screenshot centred on the highest-degree node, and one set of numbers per run — the bounding box, the median nearest-neighbour distance, the median gap between label boxes, and the overlap counts — beside the same runs on v3's page with nodeDimensionsIncludeLabels: true.

em-web v4 at 114 (labels on) v3, labels on v4 at 114, force with avoidOverlap: false
circle diameter 57.6k px 70.8k px
concentric diameter 26.3k 30.9k
breadthfirst width 76.3k 93.7k
force field 11.6k × 12.5k 3.0k × 3.4k (906 body overlaps) 2.4k × 1.8k

So on that graph v4-with-labels was already tighter than v3-with-labels; what the maintainer was seeing was the labels-on default against v3's labels-off default, and, on force, a genuine defect: the separation pass alone grew a 2.4k px field to 11.6k — five times linear, twenty-five times the area. The 1:1 screenshot showed 40 px nodes hundreds of pixels apart.

Two causes, both in 114's rules:

  1. Every discrete layout spaced every pair by the single largest footprint in scope — circle and breadthfirst by max(w, h) over every node (v3's rule), concentric and radial by the longest diagonal (114.8's corner-on fix, applied globally). One long label therefore set the chord of every pair on a ring and the step of every rank, whichever way its neighbours actually lay.
  2. Force's dense-pile phase scaled the whole component by its worst pair's factor (capped at 8). Two nodes the sim left a pixel apart asked for the cap, and the cap was applied to 569 nodes.

What landed

115.1 — src/layout/separation.mts, exact per pair. Two axis-aligned boxes are clear once separated on either axis, so the distance a pair needs along a direction is the cheaper axis's: separationAlong(dims, i, j, ux, uy), exact, asymmetric boxes (a label below the body) honoured. On top of it, the ring solvers: ringTangentialRadius (the smallest radius at which no two nodes of a ring overlap, each pair along its own chord, visited in angle order with the scan past each member stopping once the chord at the running radius exceeds the diagonals), ringBandRadius (the inner radius plus both rings' radial half extents, each node measured along its own angle — what keeps a ring a ring), ringClearanceRadius (each outer node's ray intersected with the Minkowski box of every inner node it can reach; the first radius past the floor inside no interval) and ringRadius, the one call per ring. Circle, concentric and radial route their radii through it; breadthfirst's floors became per rank and per axis (consecutive nodes need their half widths, ranks their heights, every pair a uniform spacing could bring together checked, the sideways directions' rotate-and-skew divided out).

A geometric fact worth recording: on a ring of identical boxes the exact rule still returns the diagonal chord, because some pair's chord runs along the boxes' diagonal — 114.8's corner-on finding was right. The gain is on rings of different boxes (the widest label against its actual neighbours rather than every pair) and on the wide-but-short shape, where the binding chord is the one nearest atan(h / w), under the diagonal.

115.2 — force: proximity stress in place of the component scale. PRISM's proximity stress (Gansner & Hu 2009): over the pairs sharing a grid neighbourhood — thinned to each node's nearest six clear neighbours plus every overlapping pair, the Delaunay neighbourhood approximately — an overlapping pair's target distance is its current distance times the factor that separates it along its own direction (capped at 1.5 per round), a clear pair's is its current distance at a quarter of the weight, and twenty stress-majorization iterations per round move every node to the weighted average its neighbours ask for; up to forty rounds, then the local sweeps for the residue. Measured on the way: with every clear pair in the 3 × 3 block held at full weight the pile jammed (a 200-clique of wide labels plateaued at ~270 overlaps — the holds out-voted the pushes); thinning and the quarter weight made it converge, and the sweep of hold weight × growth cap picked 0.25 × 1.5 as the densest that clears. Results, labelled cliques: 30, 120 and 200 nodes overlap-free at fills of 0.41, 0.40 and 0.46 (fill = box area over field area). em-web: 11.6k × 12.5k → 2.8k × 2.4k with bodies, 3.2k × 2.5k with labels — against the unseparated 2.4k × 1.8k.

115.3 — nodeDimensionsIncludeLabels defaults to false again. The fence: v3's default was the labels-off spacing, and a graph of long labels spaced by them is several times the size of the same graph spaced by its bodies. 114.1's flip is reverted; every layout reads === true; MIGRATING.md's row says so. avoidOverlap keeps its per-layout default (true where v3 had it, and on the three that gained it in 114). Circle and breadthfirst gain avoidOverlapPadding (10 and 0 — breadthfirst keeps v3's numbers, its spacingFactor: 1.75 being the air).

115.4 — the debug page. Two checkboxes in the layout section, Avoid overlap and … including labels, both off by default as asked, spelled out on every run of a layout that has the option (never on preset or random), linkable as ?avoidOverlap= / ?overlapLabels=.

115.5 — tests. test/modules/layout-separation.mjs (14 specs: the directional rule exact against a touching probe, the asymmetric case, the compass-point squares, a crowded ring of 120 × 20 boxes binding at the 7.5° chord, the two-places-apart wide pair, the centre-node clearance, the band, the composed call); and a not-over-separated block in the quality suite — a crammed run of grid, circle, concentric and radial on three fixtures must leave some pair at exactly the padding (a layout at its minimum spacing has a binding pair), breadthfirst packs a rank to the padding and spaces rows by heights, force's separation keeps the boxes over a fifth of the field on the fan fixtures and a third on a 60-clique of labels, each force row with its raw-settle control. Overlap-free was only half the property; the first version of every 114 rule passed the half it had.

After, on em-web (overlap on)

bodies labels
circle diameter 15.9k (v3's default look: 17k) 23.2k (was 57.6k)
concentric 11.6k 16.6k (was 26.3k)
radial 14.2k 20.5k (was 27.0k)
force 2.8k × 2.4k 3.2k × 2.5k (was 11.6k × 12.5k)

Looked at, at 1:1: force's big cluster is a compact packing with labels adjacent; the rings read like v3's. The residue the browser metric still counts on force — four label pairs by ≤ 3.9 px, two body pairs by ≤ 0.3 px — is em-web's 2 px text outline (excluded from the layout box, as v3 excluded it) and a Float32 hair.

115.6 — the review after the round

Three things the maintainer found on the page, and one ask:

  • The hover dim left labels bright. Not the page: the node label shader never multiplied the element's opacity — bodies, images and charts bind the node.opacity column and multiply it in the fragment stage, and the label pass alone did not (only text-opacity, folded into the stored colour at style-write). Node labels now bind the column as a third storage buffer and fold it into the LOD fade in the vertex stage, exactly the body rule (ancestor-folded under compounds, so a dimmed parent dims its children's labels). Edge labels are at the eight-buffer vertex budget, so their element opacity folds at style-write like edge lines and arrows do, and every label reader divides it back out (unfoldLabelAlpha), so color, text-opacity and the outline / background / border channels read back declared. v3's effective label alpha is opacity × text-opacity; it now is here too.
  • White halos on em-web after grid or circle. The sheet spells EnrichmentMap's selection ring as a 12 px border at zero opacity, which is invisible under haystack edges (they run to the centre) — but the page's layout-appropriate edge types switch em-web to bezier for the ring layouts, and an edge that ends at the node boundary stops at the invisible border's outer edge, 12 px short of the body. The affordance is what the overlay is for: overlay-padding: 12, overlay-opacity on selection. Edges reach the body; layout boxes never included the ring either way.
  • The spiral example was still spread. It walked every node along one spiral and packed components afterwards, so each of em-web's hundred-odd components spanned the whole curve and the pack laid out a hundred whole-spiral boxes (10.8k px across). One spiral per component now (ctx.components()), each node advanced along the curve by the exact chord separation from its predecessor and nudged on until it clears every box within reach on the turn before — 2.3k px across, no push-apart pass, still the contract template.
  • A spacing slider — v3's spacingFactor, on the page as a multiple of each layout's own default (so 1× on breadthfirst is its 1.75), sent to every layout but preset, linkable as ?spacing=.

Follow-ups logged, not taken

  • Size-aware repulsion inside the sim (CPU and WGSL), still the way to an overlap-free live run; the post-pass now makes the settle cheap to look at, which lowers the pressure.
  • A ring at its exact minimum still leaves most neighbours with air — the binding pair sets the radius. Non-uniform angular spacing would close it and change what a circle layout is; not taken.
  • Flow's width on non-DAGs (em-web: 149k px) is dummy nodes on the 6,899 edges, not spacing; flow's own fixtures are the DAG sheets.

Item 55: size-aware repulsion, the force box and the locked child

Raised by the maintainer on 2026-09-03: open call 55 carries the three follow-ups round 114 declined for the round rather than the library, and all three are taken here. Two calls were put to the maintainer before a line was written and answered:

  • (b) boundingBox on force takes flow's rule — scale the settle down, never up, so the bodies fit; centre it in the box; uniform, so the sim's structure is kept. v3 cose stretched centres to fill the box, up or down and size-blind; that is not carried.
  • (a) size-aware repulsion is on by default, driven by avoidOverlap (default true): the sim reads the same padded boxes the settle separates. avoidOverlap: false restores the point sim. No new public option.

What the survey found

Verified against HEAD 4d9bebde before planning:

# Fact Where
1 The repulsion law is repulsion · cutoff² / max(1, d²) / d per pair on both executors, centre distance only; the sim has no per-node size input force-sim.mts:505-517, gpu-force.mts:268-282
2 Near field = exact pairs over a 3×3 cell block with cell = cutoff on both executors; the far field is a size-blind monopole pyramid force-sim.mts:300-303, gpu-force.mts:479
3 The force kernel sits at its 8-storage-binding budget; the gravity anchors already ride the CSR buffer's tail (params.anchorBase) to avoid a ninth gpu-force.mts:30-37, 552-577
4 Force already reads padded boxes for every sim node but hands them only to the settle's separateBodies force.mts:639-644, 934-936
5 ForceLayoutOptions extends LayoutBaseOptions, so boundingBox is accepted by the type and silently ignored; the quality suite lists force as bbox: false public-types.mts:567, test/layout-quality.mjs:262
6 Flow's applyBoundingBox is private: body extents, scale down only, centre flow.mts:862-935
7 GraphStore.shiftSubtree moves every descendant raw, no lock check; setPosition then writes the parent's position exactly ("uniform translation, so only its ancestors re-derive") graph-store.mts:1716-1748, 2128-2150
8 v3's beforePositionSet shifts children() through the locked-aware shift, so a locked child and its subtree stay and the parent re-derives from all children; v3's drag list excludes locked descendants v3/src/collection/dimensions/position.mts:47-66, v3/src/extensions/renderer/base/load-listeners.mts:176-205
9 The drag gesture writes a grabbed parent through position() / shift() — the store path in #7 pointer.mts:662-667
10 Executors agree on invariants, not trajectories (the README's force section), so the GPU twin needs no bitwise parity src/README.md

The passes

116.1 — size-aware repulsion in the sim. The inverse-square law and its scale stay; it is measured from the gap rather than the centre distance. For a pair at centre distance d along the unit direction u, s = separationAlong(dims, i, j, u) is the exact centre distance at which two axis-aligned boxes stop overlapping (round 115's rule, already in separation.mts), g = max(1, d − s) is the gap softened at one pixel, and the force is repulsion · cutoff² / g² / d. With no extents s = 0 and the arithmetic is byte-identical to today, so every extent-less run and the determinism specs are untouched. At contact the pair feels repulsion · cutoff² — orders above any spring pull — so the steady state is overlap-free with a few px of gap beyond the padded box; the settle's separation stays as the exact guarantee and becomes a near no-op. A hard per-tick guarantee is not promised: the seed can overlap, and it clears within the first frames under the displacement cap.

The near field must see every overlapping pair, and two boxes overlap only if |dx| < maxW and |dy| < maxH, so the grid cell becomes max(cutoff, maxW, maxH) on both executors — cutoff, the law's scale, is unchanged; only the exact-pair radius grows. Data path: the CPU sim takes optional extents (sim-indexed, node-local, padded); the GPU runtime packs the four floats per node onto the CSR buffer's tail after the anchors (params.extBase), the kernel ports separationAlong into its near-field loop, and a run without extents keeps today's arithmetic by a flag. No new binding; the far field stays monopole.

116.2 — boundingBox on force. Flow's applyBoundingBox lifts into a shared fitBodiesToBox in pack.mts; flow delegates to it, and force calls it in the settle after separation, projection and the re-pack, before ctx.finish. The rules: scale down, never up; uniform; centre the body extents in the box; skipped exactly when the re-pack is (a pinned node or constraints — a scale would move a locked node or break a relative gap); under animateLive the stream shows the sim's own frame and the box lands with the end-of-run adjustment.

116.3 — a locked child stays when its parent moves. shiftSubtree skips a locked descendant and its whole subtree (v3: the child's shift is refused, so its own children never move), and when it skipped anything setPosition marks the parent's own geometry stale instead of trusting the exact write, so the parent re-derives about the stayers and the movers. Per pointer move the drag writes p + delta from the flushed derived value, so unlocked children keep moving by the full delta while the parent's box stretches — the v3 result. _emitSubtreePositions skips locked descendants (nothing moved there). The store does not know autolock and need not: under autolock every API-tier write to the parent is refused before the store is reached, and the animation channel moves nothing.

116.4 — the record. This file, item 55 in PLAN.md, the README's force and locked-nodes paragraphs, the MIGRATING.md cose row, the changelog, and the summary rewritten at close.

The specs that discriminate

  • test/force-sim.mjs: with extents, no two padded boxes overlap at convergence (a 30-clique of 40 px bodies; a labelled clique); control: the same run without extents overlaps; a point run is bit-identical with the extents field absent; a wide pair two cutoffs apart is gathered exactly because the cell grew to the largest box.
  • test/force-layout.mjs: the settle's separation has nothing to move under the default; control: avoidOverlap: false. A boundingBox holds the bodies, scaling down only; a box larger than the drawing leaves the pairwise distances alone; a pinned node skips the box.
  • test/layout-quality.mjs: force's row becomes bbox: true.
  • playwright-tests/renderer.spec.js: the executor-invariants test counts overlapping pairs on the live GPU run at readback, before the settle — zero; control: avoidOverlap: false overlaps.
  • test/grab-lock.mjs: a locked child stays when its parent is positioned, shifted or dragged, and the parent re-derives about it; the locked child's own subtree stays; no position event on the locked child, one on its moved sibling; control: an unlocked child travels.

What landed (2026-09-03)

All three, plus a defect none of them was looking for.

116.3 — the locked child (fix(116.3)). shiftSubtree skips a locked descendant and its whole subtree and marks the chain above it stale, so every ancestor re-derives about the stayers and the movers rather than trusting the uniform-translation write; the subtree position events skip the stayers. The drag gesture needed no change. Six specs in test/grab-lock.mjs (position, shift twice — the per-step re-derive a drag relies on — the locked child's own subtree, a locked grandchild under an unlocked child, the events, the unlocked control) and a pointer-drag browser spec over a real compound.

116.2 — the box (feat(116.2)). Flow's applyBoundingBox became fitBodiesToBox in pack.mts — exact for asymmetric extents; flow passes its symmetric halves and its specs guard the lift — and force calls it in the settle after the separation, projection and re-pack, held back by the re-pack's rule. The quality suite's force row is bbox: true, so the far-away-box loop covers it; MIGRATING.md carries the cose row.

116.1 — size-aware repulsion (feat(116.1)), and what it found. The plan's law — the distance replaced by the gap everywhere — was built first and measured: on the compound-nesting fixture (two 4-cliques in two compounds, one cross edge) the intra edges at ideal length 60 settled at 86 px against the point sim's 62, a 38% inflation of pairs that were already clear, because a clique's diagonal pairs sit inside their boxes' diagonal separation. What landed instead is a contact term: the point law stays, and a pair whose gap along its direction is under cutoff / 16 feels an extra inverse-square push measured from that gap, vanishing at the range. The range was swept:

contact range nesting intra (point: 62) 30-clique min gap 200-clique min gap 12-clique iterations (point: 120)
cutoff / 4 77 7.2 3.0 173
cutoff / 8 69 4.4 3.7 260
cutoff / 16 62 2.4 2.5 316

Every range cleared every pile (the point sim left 94 overlaps on the 30-clique and 1,712 on the 200-clique); 1/16 is the one that leaves a clear pair where the point sim put it. The price is convergence on dense piles: the term is stiff, so a clique that settled by displacement in ~80 iterations anneals to alpha's floor (~460). Sparse graphs, where the term rarely fires, are unchanged (a 12-ring settles at the same link length within 5%). The grid cell grows to max(cutoff, maxW, maxH); the far field stays monopole; on the GPU the boxes ride the CSR tail behind the anchors, so the kernel keeps its budget. Six sim specs, one layout spec (the sim's own margin beyond the padding, which the settle's tightness rule can never produce) and a browser spec.

The readback that never landed (fix(116.1)). The browser spec was the first that needed a converged GPU run, and it got nine iterations. The renderer polls convergence at frame start, before the frame's encode; the poll mapped the staging buffer, the encode skipped the copy because the buffer was mapped, and so on every frame — the staging buffer kept its initial zero, three zero readbacks counted as settled, and every GPU force run with the default threshold had stopped at nine iterations since round 18.3. The spectral seed made the result plausible, the settle's separation hid the pile, 18.3's lease spec ran with threshold: 0, and 18.4's invariants are loose enough to pass a nine-iteration run. Confirmed on the pre-round build with the same trace (lastMaxDisp 0 at every poll). The runtime now maps only once a copy has been encoded since the last map, so a poll reads a batch one frame late, latest-wins: the 30-clique's point run takes 44 frames instead of 4, its boxed run 141, and the spec asserts the frame count. Every browser force number recorded before this — the render bench's --layout rows included — was a nine-iteration figure; docs/agents/rendering.md carries the lesson.

The price, measured. The render bench's live-layout row on the generated 25k × 50k scene, both sides on the readback fix so the comparison is between converged runs (the numbers this file's plan would have compared against were nine-iteration figures):

25k × 50k, amd gcn-4 point sim with boxes
GPU live layout to converge 15.4 s, 7 fps 25.5 s, 4 fps
GPU silent settle 14.8 s 21.9 s
sync CPU settle (headless, compounds, constraints) 44 s 130 s

The whole difference is iterations: on a sparse graph the term rarely fires, but one touching pair anywhere keeps the max displacement above the threshold, and a stiff term cannot be settled by explicit Euler (stability needs alpha · f'(g*) < 2, which 1/g² meets only near alpha's floor), so a run with boxes anneals to the end. Three ways out were built and measured before keeping the term as it is: a linear ramp to repulsion · cutoff at contact (settles no sooner and leaves a 200-clique with 213–345 overlaps), a per-tick Jacobi overlap projection above alpha (pairs sit at −0.1 px under spring pressure and jitter to the floor; averaged over neighbours it leaves 618 overlaps), and a softened singularity (1/(g+σ)², σ = range/2 at range 1/8: piles clear with 0.5–3 px to spare but the 200-clique keeps 41 overlaps, and iterations are 209–458 either way). Whether the tripled CPU settle is the right default is item 56.

The record. Item 55's three calls leave PLAN.md; the item keeps only its round-102 measurement note. The README's force and locked-nodes paragraphs, MIGRATING.md (two rows), the changelog and the summary were rewritten from this file.

Item 56: the sim half of avoidOverlap becomes opt-in

Raised by the maintainer on 2026-09-04, the morning after round 116 closed with item 56 open: "let's consider the followups", and then "go ahead with all of what you proposed". What was proposed, in order: take the first measurement item 56 named before making its call; change the convergence test only if the measurement said the field was done early; make the sim half opt-in only if the field was still moving late; re-run the render bench's --layout rows on the fixed readback and mark the nine-iteration figures in the record; and drive the debug page by hand, which round 116 had not done.

The measurement item 56 asked for

The item's question: at what iteration does a boxed run's field stop changing (every position within 1 px of its final one), against the iteration the run stops at? If the field were done by alpha ≈ 0.05, an alpha floor there would cost nothing visible and recover over half the price. Measured on the CPU sim directly (ForceSim, the defaults, padded boxes as the layout hands them), snapshotting every iteration:

fixture stop alpha at stop field within 1 px from alpha there
12-clique, 40 px boxes 350 0.005 337 0.006
30-clique, 40 px boxes 409 0.002 395 0.003
200-clique, 40 px boxes 458 0.001 450 0.001
nesting fixture (2 × 4-clique + cross), 30 px 149 0.105 137 0.126
12-ring, 30 px boxes 248 0.024 234 0.029
2k × 4k uniform, 12 px boxes 458 0.001 457 0.001
25k × 50k uniform, 12 px boxes 458 0.001 455 0.001
point sim, same fixtures 78–458 12 before the stop

The field is never done early: on every fixture it is still moving until about a dozen iterations before the stop, boxed or not. The reason is in the per-tick maximum displacement along the way — with boxes it sits at the step cap (cutoff · 0.15 = 12.7 px) until alpha ≈ 0.005 on every pile and on both random graphs, where the point sim's is under 2 px by alpha 0.05. The contact term at the 1 px gap clamp is repulsion · cutoff² / d, and alpha · f'(g*) < 2 fails until alpha is under about 0.007 for a pair squeezed to the clamp, so the pairs bounce; a raised floor freezes the bounce rather than skipping a finished tail. What a floor leaves behind, before the settle:

fixture floor iterations overlapping pairs deepest distance from the annealed state (max / mean)
30-clique 0.001 (now) 409 0
30-clique 0.02 259 0 21.6 / 9.6 px
30-clique 0.01 305 3 5.0 px 14.0 / 8.1 px
200-clique 0.001 (now) 458 0
200-clique 0.05 199 109 10.7 px 51.3 / 12.3 px
200-clique 0.02 259 78 10.0 px 63.7 / 11.2 px
200-clique 0.005 351 16 5.1 px 30.4 / 9.0 px
2k × 4k, 12 px 0.001 (now) 458 395 14.6 px
2k × 4k, 12 px 0.02 259 1,693 18.2 px 120.6 / 23.1 px
25k × 50k, 12 px 0.001 (now) 458 36,042 21.9 px
25k × 50k, 12 px 0.02 259 37,923 21.8 px 406.1 / 69.0 px

Two rows the round was not looking for are in bold. The boxed sim is not overlap-free on a dense graph at all, however long it anneals: the random graphs the render bench is made of end with 395 and 36,042 overlapping pairs, some at the full box depth. Round 116 measured the term on cliques, where the spring pull on an interior node is balanced and the contact wins; on a mean-degree-4 random graph at the default ideal length the spring pressure on a node exceeds the contact's bounded push (the max(1, d − s) clamp bounds it, and the clamp is what keeps the singularity from throwing nodes a box width per tick), so the pairs sit in equilibrium overlapped, and the settle's exact separation (115) clears them exactly as it clears the point sim's. The diagnosis is the pairs themselves: ordinary degree-3-to-8 nodes 17–25 px apart in 22 px boxes, in every part of the drawing, not the isolated nodes and not a coincident pile.

So the three ways out item 56 named resolve as: a changed convergence test recovers half the price and pays for it in frozen bounces (78 overlaps of up to 10 px on the 200-clique at alpha 0.02, which the settle then opens, and a drawing 20–60 px away from the one the run was heading for); keeping the term on pays 1.7–3× the iterations on every graph for a sim-level guarantee that holds only where the spring pressure is low; and opt-in keeps the guarantee for the graphs it holds on — a small or clique-heavy graph whose animateLive run would otherwise show piles until the settle — at no cost to everyone else. The third is what landed.

What landed (2026-09-04)

117.1 — avoidOverlapInSim (feat(117.1)). A new force option, default false, read only under avoidOverlap: true hands the sim the same padded boxes the settle separates (116.1's contact term, both executors, unchanged); false is the point sim, and the settle's separation — on by default as before — does the clearing. The 116.1 layout spec opts in, and a control beside it pins the default: the 30-clique's tightest pair lands at exactly the padding (the settle's signature, 115.5) rather than beyond it (the sim's). The browser spec opts its live run in and asserts the same on a default run of the kernel. Control run: with the boxes forced on regardless, the new spec fails and the old one passes; with the option gating them, both pass. The render bench gained --layout-sim-boxes, which passes the option to both force rows and suffixes their labels, so the two never read as one series. test/force-sim.mjs's six 116.1 specs are untouched — they hand extents to the sim directly.

The bench, re-run on the fixed readback — the 25k × 50k scene, --layout --layout-uncapped, amd gcn-4:

25k × 50k, amd gcn-4, iterations: 300 point sim (the default) avoidOverlapInSim: true
GPU live layout to converge 15.4 s, 7 fps 22.2 s, 5 fps
GPU silent settle 14.7 s 21.5 s
sync CPU settle (headless path, compounds, constraints) 43.8 s 117 s
constrained settle (CPU-demoted) 33.1 s 35.9 s

The point column is round 116's within noise (15.4 / 14.8 / 44 s) and is what a default run now costs; the boxed column is the bench's iterations: 300 cap doing the stopping (the run would anneal to 458), which is why it reads a little under 116's 25.5 / 21.9 / 130 s. The boxed sim's result on this scene holds ~36k overlapping pairs before the settle either way (the measurement above), so the two columns produce the same drawing after the settle's separation.

The point rows are the figures for every scene in the record from here on; the pre-116 --layout rows in the round-18, 59, 87 and 113 records were nine-iteration runs (the readback defect 116.1 found), and the 113 record and item 54 now say so where they cite them.

The page, by hand — the Chrome extension this session could have driven by hand was not connected, so the page was driven through scripted Chromium on the real adapter (amd gcn-4, the harness flags) with screenshots read back, which is the fallback AGENTS.md allows and the same thing the Playwright specs do — a person has still not sat in front of it. What it showed:

  • The locked child under a compound drag (116.3), on the v3 compound fixture with n8 locked and n4 dragged by its padding: n8 stayed at (155, 200), n9 moved with the pointer, and n4 read back the centre of both — the parent's box grew around the stayer, exactly as the spec says.
  • The dense pile (a 40-node, 780-edge random graph): the default live run streams the pile and the settle opens it (0 overlaps, the tightest pair at 10.1 px — the padding); with avoidOverlapInSim the 700 ms frame already shows separated bodies and the run lands at 12.6 px. 1.5 s against 2.1 s. Both look right.
  • The 25k × 50k scene does not (the finding below): at zoom 4 the default run's field is bodies half-covering each other, and the probe counts 13,450 overlapping pairs after the settle — 29,727 after an avoidOverlapInSim run.

What the page found: the separation gives out at 25k (item 57)

Headless, the same graph, 12 px bodies, iterations: 300:

scene no separation (avoidOverlap: false) with the settle's separation
5k × 10k 159 pairs, 2.3 px deep 2 pairs, 0.5 px
25k × 50k 12,352 pairs, 5.6 px deep 13,406 pairs, 11.8 px

At 5k the dense pass does what 115 built it to do. At 25k it hands back more overlap, and deeper, than it was given — the forty-round proximity-stress budget gives out on a field where a third of the nodes touch a neighbour, and the sweeps that follow push into a pile that is still closed. Both executors, since the settle is one code path. Every separates row in the quality suite is under a thousand nodes, which is how a result this visible went unmeasured through 114, 115 and 116. It is logged as item 57 with its first measurement (the count after each of the three stages, and the scale at which the 5k behaviour stops) rather than fixed here: it is the 115 machinery's own question, and this round's change does not touch it — the same field went into the settle before 117.1 as after.

The record

Item 56 leaves PLAN.md — taken, with the measurement recorded on the round — and item 57 joins it. The README's force section, MIGRATING.md's cose row, the changelog and the summary say the term is opt-in and why; the option's JSDoc carries the short form.

The force rounds: the settle at scale, the infinite run, and the page

Raised by the maintainer on 2026-09-07, reading round 117's report. The first question was whether the in-sim overlap term was simply expensive against the post-sim separation; the answer was that it is additive (the settle's separation stays on in both bench columns, so the 22 s and 117 s rows are its pure price), that it does not clear a dense graph, and that its stiffness forbids an early stop. The second was whether there is a clear way to make the in-sim approach faster than the post-sim one, or whether to cut losses; the answer was to stop investing in the force-based term, take item 57 first because the settle's separation is the mechanism every default drawing depends on, and revisit in-sim overlap only as projection — one separation sweep after each tick, the settle's own primitive, which cannot be out-pushed by the springs and does not bounce. The maintainer then raised the live / infinite force-directed run "lots of people like to do", observing it is rarely good UX and might be done better by only running iterations while the network's energy is past a generous epsilon, and concluded that both post-sim and in-sim overlap correction are necessary: an infinite run has no end for a settle to land on. "OK, let's do all of the force-layout rounds we touched on", with an addition: widgets on the debug page for the live force mode, and an option to select the overlap approach, at least for the non-live mode where either could be valid — and, mid-round, "make sure to include tests wherever possible to ensure correctness and avoid regressions".

What the round takes, in order:

  • 118.1 — item 57: the settle's separation at 25k, on the per-stage measurement the item asked for.
  • 118.2 — in-sim overlap as projection: the contact force is replaced by a separation sweep per tick on both executors, and the overlap approach becomes selectable — avoidOverlap: 'settle' | 'sim' | 'both' (true is 'settle').
  • 118.3 — the infinite run: infinite: true holds the run open, ticks only while the field is moving, reheats on a drag and on request, and pins the dragged node into the sim on both executors.
  • 118.4 — the page: a Live/Infinite control and an overlap approach select in the layout section, driven.

118.1 — the settle's separation at scale (item 57)

The measurement. The item asked for the overlap count after each of the pass's three stages on the 25k scene, and the same on 10k and 15k to find where the 5k behaviour stops. Taken headless on the render bench's random scene (mulberry32 seed 42, n × 2n edges, 12 px bodies, iterations: 300, avoidOverlap: false to get the sim's own field, then the pass on that field with a stage trace — the separateBodies export now takes one). Counts are of the padded boxes, 22 px, since that is what the pass separates; "nodes" is how many sit inside some neighbour's box.

stage, 25k × 50k pairs deepest mean nodes touching bounding box
in 79,373 15.8 px 5.82 px 23,180 (93%) 6091 × 4989
local sweeps 75,668 21.9 6.58 21,982 6091 × 4989
stress round 1 81,873 21.9 5.48 22,330 6091 × 4989
stress round 20 101,150 21.9 3.81 22,685 6091 × 4989
stress round 40 100,952 21.9 3.51 22,917 6091 × 4989
closing sweeps 69,127 22.0 5.68 22,879 6091 × 4989

The same shape at 10k (21,482 in → 18,615 out at 21.7 px deepest) and, milder, at 5k (7,879 → 5,887). Three things the numbers say:

  1. The sweeps make the worst pair worse. A push along the axis of smaller overlap, by the whole amount, lands the pair on the next node in a field where nine nodes in ten already touch one — the deepest overlap goes from 15.8 px to the full box in one stage.
  2. The stress rounds never move the field. The bounding box is identical after forty rounds; the pairs go up. A stress round is a local Jacobi step — each node moves to the weighted average its near pairs ask for — and in a uniformly dense field the asks cancel: a node whose every neighbour wants it further away goes nowhere. The global mode a crammed field needs converges across a pile's few hops (the 60-clique, the 200-clique) and not across a field's hundreds.
  3. The closing sweeps deepen it again, and that is the state the item measured coming out.

Where the 5k behaviour stops, on the local passes alone (crammed random fields, the same generator): 500 nodes clear in a sweep and a few stress rounds; 1k (60% touching) clears in the budget; 2k (72%) clears with 576 pairs left for the closing sweeps; 3k (80%) leaves 1,347 pairs after the whole budget. So the boundary is a component of a couple of thousand nodes.

What landed. Stage 0, expansion: a component of at least 1,000 nodes with at least 60% of its nodes touching a neighbour is scaled about its centroid by what its median overlapping pair asks for (separationAlong along the pair's own direction, plus the half pixel, over its current distance), capped at 1.25 per round, and again while it stays crammed, up to twelve rounds. Then the three local stages as before. A pinned node holds its component back from the scale (a scale moves everything, and the re-pack has the same rule). The thresholds were measured rather than chosen: the 75th percentile over-expanded (the survivors of each round are the deeper pairs, so their percentile drives the whole component), and a size floor rather than a depth test separates a pile from a field — the median overlapping pair of the 60-clique of labels asks for under 2× just as the 25k field's does, but the clique fills 0.45 of its field under the stress rounds and 0.33 under a scale, which is 114.5's over-separation back again.

field (22 px boxes) expansion rounds after expansion after the local passes bounding box in → out time
5k × 10k 2 1,641 pairs, 44% touching 0 3484 × 2680 → 4148 × 3739 0.6 s
10k × 20k 2 5,402 pairs, 58% 0 (8 at 0.5 px into the padding) 4472 × 3564 → 5436 × 5243 1.4 s
25k × 50k 3 11,423 pairs, 53% 0 6091 × 4989 → 8735 × 8798 3.3 s (7.6 s before)

And a best-state guard: the summed overlap depth over the near pairs is measured at entry and after every stage (in the grid pass each stage already makes), the shallowest state is kept, and it is restored at the end if the closing sweeps left the field deeper. On the real 25k field with the expansion withheld (a pinned node) it fires: the closing sweeps end at 69,357 pairs at 5.66 px mean and the guard restores the fortieth stress round's 100,417 at 3.52 px — the shallower field, and the one thing the item said must never happen again (a deeper field out than in) cannot. No synthetic field has been found on which it fires — a jittered lattice and a uniform scatter both end with the closing sweeps their shallowest — so the modules spec pins the contract and records the measurement as its control.

End to end, through the layout (12 px bodies, the sim's 300 iterations, headless CPU):

scene before 118.1 after settled field
5k × 10k 0 overlapping bodies 0 3546 → 5034 px wide
10k × 20k 1,015 pairs, 11.7 px deep 0 4541 → 6715
25k × 50k 13,498 pairs, 11.8 px deep 0 6198 → 11115

The field grows 1.4–1.8× linearly (the re-pack widens it past the component's own scale, since the small components are packed around a larger one) — what 25,000 padded 22 px boxes need in a clustered field, and under the 2× the quality suite's over-separation rule allows. The 60-clique of labels, the fan and the labelled fan are unchanged to the pixel (the expansion never fires under a thousand nodes).

The gates. test/modules/force-separation.mjs runs the pass on synthetic fields without the sim — a crammed 20k lattice comes out overlap-free through the expansion stage, grown under 2×, in under 5 s; an 800-node one is a pile and takes the stress rounds with no expansion; the guard's contract holds through the local passes on a pinned 6k lattice and an 8k scatter; a clear field runs no stage; a sparse residue clears in the sweeps alone. The quality suite gained the row at scale it never had (every separates row was under a thousand nodes): force on a crammed 3k random field is clear to the padding — the contract is avoidOverlapPadding, not mere non-overlap, since the old pass's 4.3 px residue there still kept the bodies apart — and grown under 2× against the raw run, with the raw run as the control. Both went red with the expansion stubbed (CRAMMED_FRACTION = 2): the row at 7.4 px against the 9.5 it asks, the 20k lattice at 52,816 pairs. The row costs 3.9 s (two 3k sims).

118.2 — in-sim overlap as projection, and the approach as the option's value

What changed. avoidOverlap on force takes the mechanism: true / 'settle' (the default) is the settle's exact pass, 'sim' is a separation sweep after every tick and no settle pass, 'both' is both, false neither, and any other value throws at start (the throw gate covers it). avoidOverlapInSim (117) is gone — a boolean beside a boolean could not spell "the sim alone", which is the one the page needs for a live run and the one an infinite run needs by construction — and so is 116.1's contact force, on the measurement 117 took: a force bounded by its gap clamp loses to a dense graph's spring pressure, and its stiffness anneals every run to the floor. The bench's --layout-sim-boxes now spells avoidOverlap: 'both' and labels its rows so.

The primitive is shared. The settle's near-pair grid and push moved out of separateBodies into layout/separation.mts as OverlapGrid (scratch allocated once, the grid rebuilt from the positions on every pass, hashed by the largest box so any two overlapping boxes share a 3 × 3 neighbourhood; a non-finite field builds no grid) and pushApart (along the axis of smaller overlap, a hair past touching, half each or all onto the free node, the lower index negative on a tie, returning the distance opened). The settle runs its sweeps through it unchanged — the 118.1 specs and the quality suite are the control that nothing moved — and the sim runs OverlapGrid.sweep after each step. The sim's own repulsion grid is back at the cutoff (116.1 had grown it to the largest box for the contact gather); the sweep has its own.

What the measurement decided, in order.

  1. The sweep's push counts toward convergence. The first version kept it out, as the constraint projection is kept out, and the 30-clique ended with 4 pairs overlapping and the 200-clique with 155: the run stopped by displacement while the sweep still had work. The constraint precedent does not carry — a constraint fighting a force corrects by a constant every tick, while the springs' pull into overlap shrinks with alpha, so the sweep's corrections do too.

  2. The alpha floor waits for a quiet sweep. With the fold, the 200-clique still ended with 128 pairs at the floor (alpha 0.001, iteration 458): the forces are gone there and each tick is one sweep, and a pile opens under pairwise pushes only slowly — 92 more sweeps cleared it. So with boxes the floor test also requires the largest push under threshold, on both executors (the GPU's applySep folds its pushes into the same atomic max the poll reads).

  3. Sweeps per tick. Measured on the two cliques of padded 40 px boxes (overlapping pairs at ticks 50 … 450 and the iteration the run ends at):

    sweeps / tick 30-clique: pairs at tick 50 / 200 / 350 ends 200-clique: pairs at 50 / 200 / 350 / 450 ends
    1 47 / 31 / 8 473 859 / 451 / 312 / 146 643
    2 46 / 24 / 4 458 652 / 413 / 204 / 48 636
    3 39 / 13 / 1 454 548 / 360 / 167 / 27 599
    4 35 / 8 / 0 431 484 / 329 / 111 / 24 555

    Every count ends clear. What the table says about the transient: at alpha 0.47 the step cap lets the springs move a node 28 px — more than half a box — into its neighbours every tick, and no number of sweeps clears a pile that is refilled that fast; they hold it open (45 of the 30-clique's 435 pairs against the whole pile without them) until the anneal lets them win, from about tick 400 of 458. Two per tick is the balance taken: the second sweep takes the residue the first's own pushes made (a pair pushed onto a third node) for one more grid pass, and the third and fourth buy a shorter tail at a price every tick. The count is one constant, SWEEPS_PER_TICK, shared with the GPU encode.

  4. A clear pair is untouched, exactly. 116.1's ring spec compared equilibria from the scatter seed within 5%, and the sweep fails it at 74 px against 67: it opens the transient pile, and a ring's equilibrium moves with its trajectory. The property is now stated exactly — a ring seeded clear at 100 px spacing runs byte-identically with boxes and without — with the scatter-seeded ring as the control that the sweep is not silent there.

On the GPU. Two kernels, separate and applySep, after apply: the grid is rebuilt from the stepped positions (clear, bin, scan, scatter — the pyramid is not needed), separate gathers per node the pushes of its overlapping neighbours over the 3 × 3 Jacobi-style, clamped to the largest single pair's so a node hemmed in on every side moves by what one pair asks, and applySep moves, republishes to the render column and folds the push into the displacement max. Repeated SWEEPS_PER_TICK times per iteration, each on a fresh grid. The boxes still ride the CSR tail; separate binds six buffers, applySep six (the column and the meta among them). The executors agree on the invariants — clear at the end, the padding kept — and not the trajectory, as everywhere.

The gates. test/modules/layout-separation.mjs: the grid visits an overlapping pair once with its overlaps and a clear pair on request, finds a wide pair across a cell boundary, pushApart's halves, the pinned rules and the tie, sweep's largest push and its 0 on a clear field, a NaN field. test/force-sim.mjs: the 30-clique and the 200-clique settle overlap-free at the padding (the control piles), the seeded-clear ring is byte-identical, the scatter ring is wider, extents: null is byte-identical to before the field existed, the wide pair is found. test/force-layout.mjs: 'sim' streams a held-open pile (under a third of the point sim's overlaps mid-run) and ends clear with the padding kept and no settle pass; 'both' ends at exactly the padding; the default is still the settle alone (117's control); an unknown value throws. All of them red with SWEEPS_PER_TICK = 0. The browser spec (116.1's) now runs the live GPU run under avoidOverlap: 'sim' and asserts it lands clear.

118.3 — the infinite run

The maintainer's framing: the live / infinite force-directed layout "lots of people like to do" is rarely good UX, and might be done better by only running iterations while the network's energy is past a generous epsilon. That is what landed, with the epsilon the sim already had.

The shape. infinite: true on force streams like animateLive and never converges on its own: converged() is false on both executors (the iteration cap is ignored) and a new idle() — the settle test without the cap: alpha at its floor with a quiet sweep, or the displacement under threshold for three ticks — says when ticking would move nothing. The CPU loop schedules no frame while idle; the renderer skips the encode and lets its clock stop (an idle infinite run no longer drives schedule()). So at rest the run is the cost of five listeners.

What wakes it. The impl wires the core's grab, free, position, add and remove for the run's life:

  • grab on a scoped node pins it into the sim (ForceSim.setPinned; on the device GpuForceRuntime.setPinned rewrites the one slot word whose bit 31 is the pin).
  • every position event on a scoped node — the pointer's drag writes through node.position(), and so does a program — copies the store's coordinates into the sim (setPosition; on the device an 8-byte queue.writeBuffer into simPos, ordered before the next submit's encode, which the apply kernel then publishes) and reheats: alpha rises to 0.3 (REHEAT_ALPHA, d3's drag convention), the settle counter restarts, and the loop is woken (renderer.wakeForce on the device).
  • free releases the pin and reheats, so the field relaxes around where the node was left.
  • add / remove under a whole-graph scope ask for a rebuild: the current runOnce ends (the GPU poll and the CPU loop both watch the flag), lands the positions, the context drops its cached scope (LayoutContext.refreshScope — the scope was materialized once per run, and a rebuilt run must see the graph as it now is), and runOnce goes again with the seed skipped, relaxing every node where it stands and the new one where it was added. A subset scope is the caller's collection and stays what it was.

layout.reheat(alpha?) is the public handle for a change the run cannot see — an edge length under a data mapping, a restyle that resized the boxes — added to the contract (LayoutImpl.reheat?, CustomLayout.reheat) as an optional verb, so an impl without one ignores it.

The end. stop() wakes an idle run so the stop lands, and the landing is the positions as they stand: no separation pass, no re-pack, no fit and no tween — the person is looking at them. Hence avoidOverlap under infinite is the per-tick sweep ('settle' and 'both' read as 'sim', since there is no settle), which is the reason 118.2 exists in the order it does.

The gates. test/force-sim.mjs: an infinite sim is never converged after 2,000 steps and is idle; reheat() clears idle and sets alpha 0.3; a node pinned elsewhere reflows the field (the one-shot sim as the control, converging under its cap). test/force-layout.mjs: at rest the positions stop moving and the promise stays pending, and stop() lands them with the zoom untouched; a drag holds the grabbed node exactly where it was put while both ring neighbours are pulled after it, and a release lets the ring relax to somewhere between (the finished animateLive run as the control: the same gesture moves nothing else); an added node and edge are absorbed to within 250 px of their neighbour (the control: a finished run leaves it where it was added); a subset scope ignores the add; reheat() returns the layout; a locked node stays through a drag of its neighbour; a clique of bodies rests overlap-free by the sweep. All red with the position wiring and the never-converged rule stubbed. The browser spec runs the same on the GPU: the frame count holds still for a third of a second once the field rests, a pointer drag of 200 rendered px wakes it with the grabbed node at the pointer and both neighbours following, and stop() resolves the promise with the zoom untouched.

118.4 — the page: the Infinite control and the overlap mechanism select

What the layout section gained. A force-only Infinite box (infinite: true; it implies the stream, so it wins over Live and Animate) with Stop and Reheat buttons enabled while an infinite run is under way — Apply ends a run under way before it starts anything, since two sims over one graph would fight for the positions — and a Force overlap by select spelling avoidOverlap: 'settle' | 'sim' | 'both', enabled under Avoid overlap on force and pinned to sim, disabled, under Infinite, where there is no settle. The spellings are two pure functions in debug/layout-config.js (forceAnimation gained the infinite case, forceOverlap is new) and the module suite pins them: Infinite wins, the select's three values pass through, an unknown value is settle, Infinite forces sim, and the other layouts keep their boolean.

Driven — scripted Chromium on the real adapter (amd), the Playwright harness flags, debug/index.html?network=gen&gen=…, the page's own controls clicked and its layoutstop awaited. The Chrome extension for a hand-driven session was not connected, so at the round's close a person had still not sat in front of it. The maintainer did the next day (2026-09-08): the layout quality on force read as good with avoid overlap on and off, the sweep as slow as expected, and force itself as slower than it used to be with avoid overlap off — which round 119 measured.

gen=300x600, 12 px bodies time overlapping bodies tightest gap field
Avoid overlap off (the control) 1.7 s 0 3.8 px 1238 × 907
settle 1.7 s 0 10 px 1256 × 906
sim 2.6 s 0 10 px 1207 × 901
both 2.7 s 0 10 px 1207 × 901

And the infinite run on the same scene: the field rests 3.2 s after Apply and the frame counter then holds still (0 frames over half a second — the clock stopped); the drag, aimed by hovering until the GPU pick reported a node (n143, at the viewport centre), fired grab on that node, ran 111 frames, and left the node under the pointer to within the press offset (4 px); Reheat ran 18 frames and rested again; Stop disabled itself and landed the field with no overlapping bodies and the tightest gap at 9.8 px. What the first drive taught about driving: node.position() and renderedPosition() read the CPU column, which is stale under the lease, so a pointer aimed by them pressed the background and panned — the same trap 118.3's browser spec fell into, solved there by stopping first and here by hovering. docs/agents/rendering.md says so.

What the 25k scene found: a sweep does not open a crammed field, and the floor needs a budget. gen=25000x50000, the item-57 scene, on the page:

25k × 50k, 12 px bodies time overlapping bodies tightest gap field
Avoid overlap off 11.8 s 21,324 −6.9 px 5686 × 4552
settle 15.1 s 0 7.7 px 10862 × 8425
sim, before the floor budget 76.2 s 2,565 −8.2 px 5951 × 4845
sim, with it 50.3 s 5,962 −11.6 px 5716 × 4326
both 80.2 s 0 9.5 px 8418 × 6625

The settle row is item 57 fixed where it was found: the field 117 saw as half-covered bodies at zoom 4 is clear. The sim row is the limit of a per-tick sweep, and the same diagnosis as 118.1's: a sweep is a local pass, and a field whose density exceeds what its boxes allow is not its to open — the bounding box is the point sim's, and the run ran to its iteration cap still pushing. both opens it (the settle's expansion) and is priced for it. Worse, the infinite run on this scene never rested: the sweep's largest push never fell under the threshold, so idle() never held, and the page drew frames for the whole minute the drive waited. So a boxed run now keeps sweeping past alpha's floor for at most FLOOR_SWEEP_BUDGET = 200 ticks (the 200-clique needed 92 under one sweep per tick), on both executors, after which the field is as open as a sweep can make it and the run is idle — test/force-sim.mjs pins it on a crammed 3k random field (idle between 600 and 700 ticks with overlap left; red with the budget removed), and the JSDoc and README say which graphs want 'both'. Re-driven with the budget: the sim run ends at 50 s instead of 76 (the cap no longer does the stopping) with more pairs left, 5,962, since a field a sweep cannot open is left sooner; and the infinite run rests at 50 s with the frame counter still, its hover-aimed drag lands the grabbed node under the pointer to within 2.4 px at about five frames a second on 25k nodes, Reheat draws, and Stop lands it — with the 18,261 overlapping pairs a per-tick sweep leaves on this scene, which is the documented limit rather than a defect of the run.

One flake was seen while closing: test/algorithms-clustering.mjs's fuzzy c-means random-init row failed once in the full verify and passed four reruns, on this tree and on the tree before the round — item 52's residual, not this round's.

The GPU force executor's iteration cost: the serial cell scan, and the silent run's batch

Raised by the maintainer on 2026-09-08, the first person to sit in front of round 118's page: the layout quality on force was good with avoid overlap on and off, the sweep was slow as expected — and force itself looked slower than it used to be even with avoid overlap off, "near instant" on the EnrichmentMap network once and about two seconds now. The instruction was to bring the docs up to date with what had actually been done, then find the regression.

What the maintainer remembered, measured

Scripted Chromium on the real adapter (amd), debug/index.html?network=em-web (569 nodes, 6,899 edges), the page's own Apply with the layout section's defaults, layoutstartlayoutstop:

tree Animate on (the page default) Animate off field
before round 116 (fdce7463) 614–633 ms 90 ms 8,600 × 7,200 px
after 116.1 (3e6c6d10) 2,125–2,180 ms 1,462–1,530 ms 2,225 × 1,608
round 118's close 2,006–2,048 ms 1,466–1,481 ms 2,225 × 1,607

The 90 ms run is the readback defect 116.1 fixed: every GPU run before it stopped at nine iterations, and its field is four times too wide — the unconverged nine-iteration output, which the settle's re-pack then spread. So the "near instant" the maintainer remembered was a run that never finished, and the two seconds are the run finishing: the CPU sim converges on this graph at about 300 iterations (headless, iterations capped at 30 / 100 / 200 / 300 / 1,000: 144 / 217 / 334 / 416 / 422 ms, the field settling between 200 and 300), the GPU run advanced three per rendered frame however it was shown, and 100 frames at vsync is 1.5 s. The page's Animate box adds the finisher's tween on top. Headless CPU, same graph, every commit from before round 114 to 118's close: 372–436 ms, unchanged. So nothing in rounds 114–118 slowed the sim; the run was paced.

Two costs, both found by measuring

The batch. A run nobody watches mid-run — animate: false, or animate: true's tween to the settle — has no reason to be paced by the live stream's stepsPerFrame. The renderer now asks nextBatch (render/gpu-force.mts, pure) each frame: the batch doubles while the device keeps up, halves the frame after the renderer skipped a scene pass under its frames-in-flight backpressure, and stays within [stepsPerFrame, MAX_BATCH = 64] (the alpha window the encode precomputes; the encode also clamps to the iteration cap now, where three-per-frame overshot it by two). The presenting stream (animateLive, infinite) keeps its watchable rate. Two signals were tried and dropped, each on a measurement: the displacement readback's latency (mapAsync resolves 4–100 ms after a trivial batch here, erratically, so the batch sat at 3–12 and em-web ran at half the fixed-batch speed), and the frame interval against the run's shortest (the renderer's frames are not vsync-spaced — a scheduled frame can follow another by a millisecond — so every batch read as behind). The backpressure the renderer already keeps is the device's own signal.

But the batch alone bought little — em-web 1,470 → 925–1,044 ms, and a fixed batch of 64 was no faster (1,015 ms, six frames) than 16 (986 ms) — because the run was GPU-bound at ~3.2 ms per iteration on 569 nodes, and the cost scaled with the grid, not the nodes:

scene nodes grid cells GPU ms / iteration
em-web 569 10,246 3.2
gen 1k × 2k 1,000 10,434 3.3
gen 3k × 6k 3,000 33,495 9.7
gen 10k × 20k 10,000 65,536 (the cap) 19

The scan. scanCells — the exclusive scan over the cell counts that turns the counting sort into cellStart, run once per iteration and once more per separation sweep — was @workgroup_size(1): one thread walking every cell through a dependent chain of atomic loads, ~0.3 µs a cell, 65,536 cells at the grid's cap. Round 59.3's "bounded serial scan" was a budget for the scan's length, and the budget was the executor's whole iteration cost at every size below the one where the force gather dominates. It is now one workgroup of 256 threads: each totals a contiguous chunk of at most 256 cells, the chunk totals are scanned in shared memory (Hillis–Steele, uniform control flow — the cull pass's own idiom), and each thread writes its chunk's prefixes and rewinds the counters for scatter. The dependent chain is 256 + 8 loads instead of 65,536. The separation sweep (118.2) rebuilds the grid through the same kernel, so it takes the same win.

What it measures now

The same page, scripted Chromium, amd, layoutstartlayoutstop, the default iteration cap; "before" is round 118's close:

scene, avoidOverlap before after frames worst frame
em-web, off, silent 1,470 ms 268–296 ms 11–12 27–39 ms
em-web, off, silent, fixed batch 64 1,015 147 6
em-web, off, animate: true (the page default) 2,050 782 43
em-web, 'settle' 331 11
em-web, 'sim' 3,641 449 18 22
em-web, 'both' 447 19
em-web, off, animateLive (3 per frame, by design) 1,582 1,523 91
gen 1k × 2k, off 262 12
gen 3k × 6k, off 336 13 32
gen 10k × 20k, off 793 22 85
gen 25k × 50k, off (118.4's row) 11.8 s 3.3 s 70 197
gen 25k × 50k, 'sim' (118.4's row) 50.3 s 17.7 s 124

The 25k silent run's worst frame is the batch's ramp overshooting before the backpressure answers (two frames in flight, so the signal lags two frames): the batch reaches 24 once, then rides 3–12 for the run. Recorded, not fixed — the page ran 35 ms frames throughout that run before, and a one-off 200 ms stall against a 3.5× shorter run is the trade this round takes; a growth rule that prices an iteration before doubling is the follow-up if a person finds the stall.

The tween the page's Animate box adds is now most of what the page shows on em-web (782 against 296), and that is the finisher's duration, not the layout's.

119.3 — the settle threshold, relative and for the unwatched run

The maintainer, on the numbers above: still too slow, and the layout the nine-iteration defect had produced "seemed like sufficient quality" for most networks (the 1.5 s reported turned out to be a cached page, and the instruction was to keep investigating). The observation was right and measurable. Headless CPU, seven fixtures, iterations capped, three metrics — the edge-length coefficient of variation, a sampled stress (BFS distance from 40 sources against geometric distance scaled by the mean edge length) and the count of 40 px bodies overlapping:

fixture at 9 iterations at 100 at the 0.1 px settle (~300)
em-web (569 × 6,899) CV 0.50 · stress 0.193 · 750 0.49 · 0.198 · 723 0.49 · 0.203 · 709
em-desktop 0.53 · 0.211 · 3,697 0.52 · 0.135 · 2,058 0.52 · 0.154 · 1,966
npm-deps 0.45 · 0.159 · 470 0.37 · 0.096 · 301 0.37 · 0.096 · 285
reactome 0.37 · 0.126 · 282 0.35 · 0.104 · 120 0.35 · 0.106 · 117
gen 1k × 2k 0.49 · 0.416 · 5,173 0.44 · 0.314 · 3,226 0.43 · 0.308 · 3,195
tree 500 0.42 · 0.238 · 930 0.36 · 0.145 · 282 0.36 · 0.145 · 242
grid 20 × 20 0.14 · 0.043 · 0 0.13 · 0.041 · 0 0.13 · 0.041 · 0

Nine is too few on the sparse graphs (the tree and npm-deps are still opening), but every fixture has plateaued by 100, and the anneal's remaining two hundred ticks move nothing any metric sees: the spectral seed places the graph. The knob is the settle test — 0.1 px per node per tick is a tenth of a percent of the mean edge length. Swept to convergence:

fixture 0.1 px (default) 0.5 px 1 px 2 px 4 px
em-web 425 ms 228 180 117 105
em-desktop 1,033 724 591 434 283
npm-deps 130 91 72 49 31
gen 1k × 2k 472 308 252 175 136
tree 500 160 106 80 57 35

At 1 px every metric is within 1–2% of the 0.1 px result on every fixture; at 2 px the overlap count on the sparse graphs rises 3–10%; a faster anneal (decay 0.03) buys the same time at a small stress cost on the random graph. The default is now 2% of the mean ideal edge length — 1.2 px at the default 60 — for a run nobody watches (animate either way). A presented run (animateLive, infinite) keeps 0.1 px: the first version applied the relative default to every run and the infinite drag spec went red — the ring rested before its neighbours had visibly followed — because a stream's stop is motion the eye sees, and a field creeping a pixel a tick is visible motion.

Two things had to come apart from the threshold. The separation sweep's quiet test: the 'sim' spec went red with pairs left a pixel deep, since the sweep pushes a hair past touching and its push had been read against the settle threshold — it now has its own SWEEP_QUIET (0.1 px) on both executors, a second atomic max in the GPU's meta buffer. And the GPU's settle count: traced per frame, the em-web run was going to 460 iterations at the new threshold where the CPU stops near 150, because the settle counted three quiet polls and each poll's readback lands two to three frames after its batch — three polls of 64-iteration batches. A quiet poll now credits the batch's every iteration (its max is over all of them), which is the CPU sim's three-consecutive-ticks rule read from one readback; a three-per-frame stream reads the same as before.

the page, scripted Chromium, amd 119.2 119.3 frames
em-web, off, silent 268–296 ms 145–240 ms 7–9
em-web, off, animate: true 782 745 (216 + the 500 ms tween) 41
em-web, 'settle' 331 297 9
em-web, 'sim' 449 451 18
em-web, animateLive (unchanged by design) 1,523 1,523 89
ndex-large (3,238 nodes), off 585 379 11
gen 3k × 6k, off 336 279 11
gen 25k × 50k, off 3.3 s 3.0 s 67
gen 25k × 50k, 'sim' 17.7 s 16.0 s 214

What is left on em-web is latency, not iterations: a frame to start, five frames of ramp to a 64-iteration batch, two to three for the quiet poll to land, one or two for the position readback. The CPU executor does the same graph in 180 ms headless — and blocks the thread for it. The page's readout now shows the layout and the tween separately, so what Animate adds reads as what it is.

The gates

119.3's gates are the ones that went red on the way: 'sim' ends clear at the padding (the sweep's own quiet test), the infinite drag on the GPU pulls both neighbours after the grabbed node (the watched run's threshold), and the 114.5 stream-versus-settle control now settles at the stream's threshold explicitly. test/modules/gpu-force-batch.mjs pins the batch rule: it doubles from stepsPerFrame to the cap, halves when behind and never under the floor, clamps a floor past the window — red with the rule returning its input. The browser spec (renderer.spec.js, 119) runs a 60-node graph to a 240-iteration cap with threshold: 0 silently and live: the stream takes over 60 frames (three per frame) and the silent run under 40 — 82 against 82 with the rule stubbed. The scan's correctness is gated by what already read the grid: the three executors' invariants spec, the 30-clique landing clear under 'sim' on the GPU, the infinite run's drag, and the seeded-clear ring's byte-identity — all green on the parallel scan, on the real adapter.

The docs, brought up to date on the way

The 118 record and the summary said a person had still not sat in front of the page; the maintainer did on 2026-09-08, and this round is what they found. The README's force section had carried "silent GPU ~346 ms vs the old sync CPU settle ~25.3 s (~73×)" at 25k since 87.2 — a nine-iteration figure, as every browser force number before 116.1 was — and now says so with the converged number beside it. And round 112's record was still filed as a plan while the README, the summary and three landed commits described the flow layout as built: re-filed as landed, with 112.5's levers held as its file already said.

The smallest components take shapes, and the page's EM combo

Raised by the maintainer on 2026-09-08, after round 119: the force layout's packing can be more orderly. Components of one to four nodes should take canonical shapes — a pair as a vertical barbell, three as a triangle, four as a diamond — which also orders the rows of small components by size, and helps centre-aligned labels, which read left to right, stay off each other. An option on force, with a widget on the debug page. And the layout dropdown should gain a combo entry that uses two or more layout calls for the EnrichmentMap case: force-directed overall, then packed so the red components sit on one side and the blue on the other, each side ordered by component size as the general packing already is.

What the EM preset does, measured

The em-web fixture's preset positions, by component (144 of them: 99 singletons, 20 pairs, 9 triples, 4 quads, one of 187 nodes holding 59 positive and 128 negative NES):

row what sits there x range, positive NES x range, negative
top the 187-, 78- and 32-node components 0–3,500
second 15, 14, 14, 11, 10, 8, 7, 6, 5 3,500–4,150 30–1,700
third the quads and triples 3,800–4,000 90–2,800
fourth the pairs 3,570–4,120 30–2,900
bottom the singletons 3,530–3,850 650–2,660

So: shelf rows by size, largest first, and the field split by sign with the positives to the right — the same packing force already does, run once per sign.

120.1 — canonical shapes for components of two to four nodes

tidySmallComponents in layout/pack.mts, run at the settle before the separation pass (so the pass finds the shapes clear) and before the re-pack (so their boxes are what it packs). A pair stands as a vertical barbell, three make an equilateral point-up triangle, four a diamond (top, right, bottom, left). The radius is the larger of what the edges ask — the component's edge length along every side — and what the bodies need: horizontal neighbours a body width plus the padding apart, vertical ones a body height plus the padding. Nodes go round the perimeter in a depth-first walk, a path from an end and a star from its hub, so a path's or a cycle's edges run along the sides and a star's hub takes the top. The shape is centred where the component's centroid was. Left alone: singletons, components of five or more, a component holding a locked node, and one whose edges ask for different lengths (a data-driven edgeLength is the caller's point — the per-edge-length spec caught the first version giving a 40/160 path one side). Constrained and infinite runs skip it with the re-pack. The option is tidyComponents, default true.

Why it orders the rows: the re-pack shelf-packs by box area, largest first, and every component of a size is now the same box, so the quads make a row, then the triples, then the pairs, then the singletons — which is the EM preset's shape. Before, a pair's box was its sim angle's, and a flat pair packed among the singletons.

120.2 — the page: the tidy box and the sign combo

A Tidy small components box (force only, on by default, spelled into tidyComponents every run), and a dropdown entry Force, then pack by sign (EM combo: two runs). The combo reads the network's signKey (NES on em-web and em-web-clustered, EM1_NES_Data_Set_1_ on em-desktop), splits the components by the sign of their members' mean value — whole, so the mixed 187-node component goes with its majority, and a component with no value follows the negative side — runs force on each side's nodes and edges as its own scoped run (each side's components packed largest first, as force packs), shifts the positive side to the right of the negative side's box with three component spacings between, and fits (animated under Animate). A network with no signed field is one plain run. The pure parts — splitBySign, isCombo, the tidy spelling — are in layout-config.js and pinned by the harness spec; the readout says how many force runs the combo made.

Driven in scripted Chromium on the real adapter, avoid overlap on: em-web force with tidy 215 ms, the combo 397 ms (two runs), em-desktop combo 668 ms; the screenshots show the blue components left, largest first, the red right, and the pairs, triples and quads in rows.

The gates

test/modules/pack-tidy.mjs: a pair stands at the edge length centred where it was; three make an equilateral point-up triangle and four a diamond with axis-aligned diagonals; a star's hub takes the top and a shape grows for wide bodies; singletons, a 5-cycle and a pinned pair are untouched. test/force-layout.mjs: through the layout, the three shapes hold beside a 6-ring; tidyComponents: false keeps the sim's angle (the control), and a locked pair holds while the free shapes still take theirs. The harness spec pins the page's spellings and the split. All 2,612 Node specs green; the force browser specs green.

The re-pack takes a grouping and an order, and the larger components turn

Raised by the maintainer on 2026-09-09, from the round-120 follow-up list: the page's EM combo should be the library's — one force call, not harness code — with the mixed component's placement settled; the components of five nodes and up should take a canonical orientation as the small ones take shapes; the singleton row wanted one look with labels on; the round-119 batch ramp wanted pricing before doubling; and EnrichmentMap also sorts the components by score, which the packing should allow.

What the EM preset does with score, measured

em-web's singleton rows by position: the positives read 2.47, 1.92, 1.91, 1.87 … 1.38 left to right and top to bottom; the negatives -1.30, -1.31 … -1.71 on the first row and -1.71 … -2.42 on the second. So within a size the row is NES descending — the strongest positive first, the weakest negative first — on each side. The pairs and triples rows are not score-sorted in the preset; the singletons are.

121.1 — componentGroup, componentOrder, groupSpacing

The settle's re-pack (packComponentBodies, layout/pack.mts) takes a grouping and an order: groupOf per component, each group shelf-packed on its own and the group boxes standing in a row left to right by index, top-aligned, groupSpacing apart; compare over component ids ahead of the area order, whose ties the area order breaks, so a comparator that says nothing is the default. shelfPack gained the comparator; componentBoxes is the per-component body box made shared. Force describes each component once at the settle — its nodes as a collection (ctx.nodes._spawnUnique over the sim refs), its node count, and its body box's width and height as the re-pack will see it — and hands the descriptions to componentGroup (a key per component: numbers ascending, then strings, then the unkeyed last) and componentOrder (a comparator). Both must be functions; anything else throws at start. Skipped exactly when the re-pack is (a locked node in scope, or constraints) — the group function is never called then, and the spec says so. The EM shape is componentGroup: (c) => Math.sign(mean NES) and componentOrder: (a, b) => b.size - a.size || mean(b) - mean(a).

121.2 — the larger components turn

orientComponents (layout/pack.mts), after the shapes and before the separation pass: a component of five nodes and up rotates about its centroid — with a principal axis (its position covariance's eigenvalues 1.15 apart) it lies flat, the axis horizontal, by the smaller of the two turns that get it there, the wide box the shelf rows want; an isotropic one (a ring, a star) turns its farthest member to the top, as the small shapes do with a point or a hub. A pinned node holds its component. A turn re-overlaps axis-aligned boxes, so it runs only where the settle pass follows: an avoidOverlap: 'sim' run, whose sweeps are all it has, keeps the sim's angles (the 114.5 'sim' spec caught the first version returning 27 overlapping pairs). Under tidyComponents, so one option turns the whole thing off.

121.3 — the mixed component sits between the sides

The page's group function returns -1, 0 or 1: a component whose minority sign holds a quarter or more of its signed members is mixed and packs between the negatives and the positives, where its red and blue clusters read as what they are. em-web's 187-node component (59 positive, 128 negative) sits there; the preset itself puts it top-left with the negatives, which the majority rule would reproduce and the doc records. A component with no signed member is unkeyed and packs last; a network with no signed field is one group, a plain run.

121.4 — the singleton row with labels on

Looked at on em-web with nodeDimensionsIncludeLabels: under the library's largest-box-first order a singleton with a wide label packs among the pairs and a pair with short labels among the singletons — the boxes are the labels' now, not the shapes'. The EM entry's size-first order gives clean rows regardless. The library default stays largest-box-first (the tightest packing, and every earlier measurement's), and componentOrder: (a, b) => b.size - a.size is the one-liner for rows by count; the page shows it.

121.5 — the batch is priced before doubling

nextBatch (render/gpu-force.mts) takes the device's time on the last completed batch and the iterations it carried, and grows no further than BATCH_FRAME_BUDGET_MS (100) buys at that cost per iteration; the price only holds the doubling back, and shrinking stays the backpressure's. The price is the device's own: the renderer times each non-presenting frame from the later of its submit and the previous frame's completion to its onSubmittedWorkDone — the queue runs frames back to back, so that is the frame's GPU time. The frame's wall clock cannot see it: a vsync-paced frame submits and returns while the queue absorbs the work, and the stall lands two frames later all at once.

Measured on the page, the 25k × 50k silent run: the encoded batch now rides 3–16 (an iteration prices at 6.5–9 ms there; 100 ms buys 12–15) and no run frame is over 50 ms; the run is 3.0 s against 119's 3.3 s. The one long frame in the recording — 280 ms, the sixth after the click — is the run's synchronous start on the CPU (components, the spectral seed, the buffers) and is not the batch's. em-web unchanged: 0.23 s in 16 frames.

121.6 — the run's synchronous start, the seed's BFS typed

The one long frame left on the 25k run was the click's synchronous start, and the maintainer said go ahead. Timed piece by piece on a 25k × 50k graph headless: components 5 ms, anchors 1, the scatter 5, the spectral seed 190–204 ms. Inside the seed, the BFS from each of 25 pivots per component walked incidence lists of edge ids, resolved each neighbour's local index through a Map, and queued levels in fresh arrays — 2.5 million map lookups a run. The neighbour lists are now a CSR of node ids, the local index a typed array filled once, the queue one Int32Array — bit-identical output on the 25k reference (50,000 coordinates, zero differ), and the seed is 70–75 ms: BFS 30, the embedding 10, the eigenpairs 8, the pivot scan 7, the CSR 5. A convergence exit on the power iteration was tried and reverted: the 25 × 25 matrices do not reach 1e-9 in fewer than the 300 iterations on em-web, so it bought nothing and moved the embedding by 1e-4 px; the per-iteration buffer is hoisted, which is bit-identical.

On the page, the library's run() call at 25k × 50k is now 95–120 ms synchronous (the seed and its prep 60–73 of it, the edge scan 17–20, the device start 11) and the run's first frame 83–117 ms; em-web's is 19–29 ms. The 280 ms the earlier recording showed after the click was that start plus the harness's own edge-style override, which restyles 50k edges before the run and is the page's, not the layout's.

The page

The dropdown entry is Force, grouped by sign and ordered by score (EM): force with signedPacking(key) from layout-config.js — the group by signGroup (-1, 0, 1 or null), the order by size then scoreOf descending — when the network has a signKey, a plain force run otherwise; the two-run combo driver is gone. Driven in scripted Chromium on the real adapter, avoid overlap on: em-web 302 ms (one run, against 120's two at 397), em-desktop 493 ms (against 668). The screenshots: blue left, largest first then rows by size with each row from the strongest score, the mixed component between, red right.

The gates

test/modules/pack-group.mjs: the component boxes; groups packed on their own in a row, by index, a groupSpacing apart, top-aligned; a comparator ahead of the area order and the area order breaking its ties; the largest component's centre held through a grouped re-pack. test/modules/pack-orient.mjs: a tilted path lies flat about its centroid with every distance kept, by the smaller turn; an isotropic ring puts its farthest member on top; the size floor and a pinned node hold. test/force-layout.mjs: through the layout, the groups in a row keyed ascending a groupSpacing apart with one description per component; the order by size then score in each group's reading order; an unkeyed component last, a non-function throwing at start, and a locked node never calling the group function; a 7-path lying flat at the settle with its end-to-end length kept against the tidy-off control. test/modules/gpu-force-batch.mjs: the price holds the doubling at what the budget buys, a cheap batch is never held back, a missing price is the plain doubling, and backpressure still halves. The seed's specs (test/modules/force-init.mjs) hold unchanged, as the 25k reference says they must. The harness spec pins the page's group, score and options. All 2,616 Node specs green; the force browser specs green on the real adapter.

The layout portfolio audit: the use cases, the flagships and what is missing

Item 49, raised by the maintainer on 2026-08-19: v4 should ship a better set of built-ins than v3 — one really good layout per main use case — and the audit was to enumerate the use cases, name the flagship for each, and measure that against what the ecosystem apps actually ship, since Cytoscape Web carrying three layout engines was the demand signal. Round 112 took the layered half (the flow layout, built not ported, measured against dagre and elkjs), round 114 made the portfolio that exists correct (one reading of node dimensions, overlap avoidance everywhere, locked nodes held, a quality suite over every layout), and rounds 116–121 took force to where the EnrichmentMap shape is one call. The audit itself stayed open. The maintainer picked it on 2026-09-09, and this is it: the demand read from the two flagship apps' repositories on the day, the use-case by coverage matrix, the flagships named, and the gaps ranked by who is asking.

The demand, read from the apps on 2026-09-09

Cytoscape Web (cytoscape/cytoscape-web, development at 3686e3f, 8 Sep 2026). Three engines, exactly as item 49 said, and src/models/LayoutModel/impl/ says which:

  • G6 (@antv/layout): dagre, gForce (gpuEnabled: true) and radial.
  • Cytoscape.js v3 (cytoscape ^3.34): grid, circle, cose, concentric, and cytoscape-biological-flow — a third-party "hierarchical biological pathway layout: left-to-right signal flow with crossing minimization", i.e. a layered layout the built-ins did not have.
  • Cosmos (@cosmograph/cosmos): one algorithm, displayed as "Cosmos Layout (GPU-based, nondeterministic)" — a GPU force layout added for the sizes the others cannot reach.

layoutSelection.ts is the routing, and it is the clearest statement of what the app wanted from a core library: under 1,000 elements the default is G6's gForce, or G6's dagre when the network is a hierarchy (HCX); at 1,000 elements and above the app runs v3's grid ("only run grid if the network has more than ELE_THRESHOLD elements"), and above a maximum it runs no layout at all. So the app ships three force layouts, two layered ones and a radial tree because v3's force layout (cose) could not be its default even at a thousand elements, and nothing in v3 was layered.

EnrichmentMap web (cytoscape/enrichment-map-webapp, main at a36c6c2, 2 Jun 2025). cytoscape-fcose with a per-edge idealEdgeLength computed from cluster membership and a raised nodeRepulsion, run over the connected nodes only; then the app's own _packComponents / _separateComponents — a vendored copy of layout-utilities' polyomino packing (a 400 × 300 client box, componentSpacing: 40) — over the result; then the singletons, sorted by NES descending, laid out by v3's grid in a boundingBox beneath the connected part, cols derived from its width, condense: true, nodeDimensionsIncludeLabels: true. Four mechanisms, two of them hand-written, for one picture: force with data-driven edge length, components packed, singletons in sorted rows. Round 121 measured the resulting preset and made it one force call (edgeLength mapping, componentGroup, componentOrder).

The v3 extension list (v3/documentation/md/extensions.md) is the wider signal: nineteen layout extensions, of which eleven are force-directed (cola, cose-bilkent, cosep, d3-force, euler, fcose, ngraph, spread, springy, the GPU one, cise's inner sim), three layered (dagre, elk, klay), two circular (avsdf, cise), one tree (tidytree), one domain layout (polywas) and one utility (layout-utilities: the component packer). Eleven force layouts is a community telling the core that its force layout was not good enough; the two apps above picked fcose, gForce and Cosmos out of them.

The matrix

Use case v4 flagship Also in v4 What the apps ship Where it is measured
Force / organic — the default picture force: GPU with a CPU reference, spectral seed, edgeLength mapping, fcose's constraint surface, exact overlap separation, boundingBox, infinite, live and tweened animation, component shapes, orientation, grouping and order fcose (EM); gForce, cose and Cosmos (Cytoscape Web) 121: em-web 302 ms, the 25k × 50k scene 3.0 s on the GPU; the quality suite's force rows; benchmark/layouts.mjs force-vs-cose
Layered / DAG flow: network-simplex ranking, weighted crossing minimisation, Brandes–Köpf, compound-aware, four directions, rank constraints, minLength / edgeWeight mappings breadthfirst (directed): v3's depth rows, kept dagre (Cytoscape Web's hierarchy default); biological-flow (their left-to-right layered layout — flow with direction: 'rightward') 112.1's harness against dagre and elkjs on six fixtures
Tree radial: subtree wedges by weight, roots, levelSpacing breadthfirst for a layered tree; flow for a directed one G6 radial (Cytoscape Web); tidytree in the v3 list, shipped by neither app 85.1; the quality suite
Circular circle with the sort mapping (order by a data column: the attribute-grouped ring); concentric with the concentric mapping (rings by a data column) and levelWidth circle and concentric (Cytoscape Web); avsdf and cise in the v3 list, shipped by neither app the quality suite
Grid / tabular grid: rows, cols, position, the sort mapping, condense, boundingBox grid (Cytoscape Web's fallback at ≥ 1,000 elements; EM's singleton rows) the quality suite; the layouts benchmark
Component packing force's settle re-pack (120–121: shapes, orientation, componentGroup, componentOrder); flow's own; LayoutContext.packComponents for an extension layout EM's vendored layout-utilities packer; layout-utilities itself 120 and 121, on em-web and em-desktop
Positions from data preset both apps (CX2 carries positions) the quality suite
Scatter random the quality suite

Every use case the two apps ship has a v4 flagship, and each flagship is the one layout for its use case — nothing in v4 is a second-best of another. breadthfirst stays because directed depth rows are a different picture from flow's, not a worse one, and it is the cheapest tree layout there is; circle and concentric are two spellings of one use case (by order, by level) and both stay.

What the audit finds missing, ranked by who is asking

  1. Component packing on the discrete layouts, and a standalone re-pack. Asked for by EnrichmentMap, which vendored a packer and runs grid for the singletons because fcose left the components where they fell. In v4 the whole packing machinery — shelf pack, the small-component shapes, the orientation pass, the grouping and the order — is reachable only through force's settle and through flow, which packs its own; breadthfirst, radial, circle, concentric and grid place a disconnected graph as one body, and the quality suite's component rows say so ("the ring and grid layouts place by index or by wedge about one centre, so their component boxes interleave by design"). Round 87 logged packComponents: true on the discrete layouts as a small round; nothing took it. The shape: a packComponents option on the five (run per component, shelf-pack the boxes, group and order as force does), and a pack layout that regroups existing positions with no sim — the EM "keep my sim, regroup" case. Logged as item 58; the next layout round.
  2. Scale as a reason to switch engines. Cytoscape Web falls back to grid at 1,000 elements and added Cosmos for the sizes beyond that. Both are v3-cose limitations, not use cases: v4's force lays out em-web (569 nodes, 6,899 edges — 7.5k elements, seven times the threshold that sends Cytoscape Web to grid) in 0.3 s and the 25k × 50k scene in 3.0 s on the GPU, deterministically from a seed (Cosmos is "nondeterministic" by its own label). Nothing to build; the migration guide's layout section now says which built-in answers which extension so a port does not carry the threshold over.
  3. A left-to-right layered layout. Cytoscape Web wrote one. flow takes direction: 'rightward'; covered since 112.
  4. A tidy tree (Reingold–Tilford / Buchheim: non-layered, compact, the tidytree extension's case). Shipped by neither app; radial, breadthfirst and flow between them draw a tree three ways. Declined until an app asks.
  5. A crossing-minimised circle (AVSDF) and cluster circles (CiSE). Shipped by neither app. The sort mapping on circle is the attribute-grouped ring; a crossing-minimised order is a permutation problem the same option would take if a round built it. Declined until an app asks.
  6. A second force layout. Eleven in the v3 list, three in Cytoscape Web. Declined by design: the point of item 49 was one excellent layout per use case, and force absorbed fcose's constraints (85.2), its spectral seed (59), cola's infinite (118) and cose's compound gravity (59.5) rather than shipping beside them.

Decisions

  • The portfolio is complete for the use cases the flagship apps ship, with one gap — component packing on the discrete layouts — which is item 58 and the next round. Item 49 closes here.
  • Flagships, one per use case: force, flow, radial, circle / concentric, grid, preset. A documentation site's layout page should lead with those six and list breadthfirst and random after them.
  • No second engine for scale. A v3 app's "too big for a layout" threshold does not carry to v4; force is the default at every size the apps have.

What changed on disk

Documentation only: this record; src/README.md carries the matrix as "The layout portfolio (round 122)" ahead of the per-layout sections; MIGRATING.md's layouts section gains the extension-to- built-in table; PLAN.md closes item 49 and logs item 58; the summary and the index. No source, no spec: the claims above are the record's own measurements, re-read rather than re-run — the app facts are dated and pinned to commits so the next audit can diff them.

Component packing on the discrete layouts, the pack layout, and breadthfirst's trees as blocks

Item 58, the one gap round 122's audit found: the packing machinery of rounds 120–121 — the shelf pack, the shapes, the orientation, the grouping and the order — was reachable only through force's settle and flow's own pass, while circle, concentric, grid, breadthfirst and radial drew a disconnected graph as one figure about one centre, and EnrichmentMap vendored a packer and laid its singletons out with grid for want of it. The maintainer's brief (2026-09-09): address the gap; it should be possible to choose, by option, one ring or one ring per component, and the same for breadthfirst and the rest; breadthfirst's point is to be simple and space-efficient, so for it also consider just improving the spacing and the ordering so separate trees read more easily; plan each layout first. The plan was put to the maintainer and four calls came back: the option is packComponents; breadthfirst's blocks are on by default; concentric bins per component; and locked nodes are left out of the packing, with the main drawing kept off them.

123.1 — the mechanism, and the pack layout

layout/per-component.mts: force's grouping (groupingOf, the keys sorted into group indices and the comparator wrapped over ids) and its functions-or-nothing check (validatePackOptions) moved out of force.mts and are shared; splitByComponent splits the placed nodes by the scope's components, largest first, keeping the caller's node order within each so a sorted ring stays sorted; and layoutPerComponent is the mechanism: the layout's own placement called once per component in a box centred on the origin and sized to the component's share of the scope's area (side ∝ √(size / n), never under twice the component's largest node), the body boxes shelf-packed largest first by packComponentBodies under componentSpacing / componentGroup / componentOrder / groupSpacing — force's spellings, force's LayoutComponent description — the field centred on the viewport, or scaled down and centred in an explicit boundingBox (flow's rule, fitBodiesToBox), and, when a locked node sits under it, moved off the locked nodes' union box by the smaller of a shift to its right and a shift below it. Locked nodes are left out of the placement, as they hold under every layout since 114.3. Positions land through layoutPositions, so animate tweens and fit are unchanged.

cy.layout({ name: 'pack' }) (layout/pack-layout.mts) is the re-pack on its own: the components at their current positions shelf-packed by their body boxes under the same four options, the largest component's centre held, an explicit boundingBox honoured after the pack as force honours it. Translation only — no shapes, no orientation; those need edge lengths and belong to force. The EnrichmentMap "keep my sim, regroup by sign" case is one call.

The option types: ComponentPackingOptions (packComponents plus the four) on the five discrete layouts' options, LayoutComponentInfo as what the group and order functions see, PackLayoutOptions; the shipped declaration carries them.

123.2 — circle, concentric, radial

Each layout's placement became a method over a node list and a box (ring, rings, tree), and packComponents: true routes it through the mechanism. Circle: one ring per component — a singleton a point, a pair two points, a triple a triangle — each at the smallest radius that separates its members, sort ordering each ring. Concentric: the scores resolved once over the scope (a range mapping normalizes across the graph) and the levels binned per component, levelWidth seeing that component's nodes — the maintainer's call. Radial: without the switch several components share one radial as wedges with their roots on the first ring (85.1's rule, unchanged); with it each component is its own tree, root at its own centre, the roots split per component.

123.3 — grid

One grid per component, a singleton one cell, the grids packed largest first: on em-web the quads, triples, pairs and singletons fall into rows — EnrichmentMap's picture through the same option as everywhere else. sort and position apply within each grid.

123.4 — breadthfirst: trees as blocks, on by default

With several components in a row drawing, v3 spread every rank across the box and sorted it by the parents' positions alone: the trees interleaved, and a root sat at the middle of a row that ran the whole width. Now, by default:

  • each rank is ordered component-first — the largest tree leftmost, the parent heuristic (or depthSort) within it — so a tree's nodes are contiguous in every row;
  • each component takes a column band as wide as its widest rank needs, bodies included, each rank centred in its band, so a root stands above the middle of its own subtree;
  • the singletons share one band, a block of rows as wide as a shelf, the cells the first rank's spacing apart;
  • the bands wrap into shelves up to the box's width (or the one band wider than it), each shelf centred and componentSpacing apart, the next shelf's rows starting under the deepest band of the one before, and the rows spread over every shelf's rows.

The depth rows stay shared within a shelf, so levels align across the trees on it and no height is spent. One component is v3's picture exactly (the spec pins the positions); circle: true rings are untouched. What the page found on the way: the first version put every band in one row, and em-web's 144 components made it a row 144 bands wide at zoom 0.02 — the singleton block and the shelves are the answer, and the page reads as the big tree, shelves of the medium and the small trees, rows of pairs and rows of singletons.

packComponents: true goes further: each tree laid out alone and compact — spaced by its overlap need, bodies apart, spacingFactor the air — and the trees shelf-packed under the shared options. The placement is one method (place) over a node list, its roots, a box and a sizing rule ('box' fills an explicit boundingBox, 'margin' leaves v3's margin inside the viewport, 'compact' is the packed tree's). The first version of the packed tree ran eles.components() once per component to rank the bands it would never draw — 144 scans on em-web, 1.44 s — and skips it now: 0.10 s.

The page

A Pack components box, spelled into packComponents every run on grid, circle, concentric, breadthfirst and radial (force, flow, pack, preset and random never get one), and a Pack entry in the dropdown that re-packs the components where they stand under the sheet's own edges. Driven in scripted Chromium on the real adapter (npm run -s gpu: hardware), em-web (569 nodes, 6,899 edges, 144 components), avoid overlap on, in ms:

layout one figure packComponents
breadthfirst 142 (the blocks) 109
circle 24 46
concentric 32
grid 32
radial 54

The screenshots: circle's rings by size in rows, largest first; grid's quads, triples, pairs and singletons in rows; radial's trees packed; breadthfirst's shelves.

The gates

test/layout-pack.mjs (29 specs): disjoint component boxes under the switch on every discrete layout, with circle's one-ring and grid's one-lattice controls; circle's sort per ring; concentric's levelWidth seeing each component alone against the nine-node control; radial's root at its own centre; grid's 2 × 2 path; the shared grouping and order on all five with what the description carries; the non-function throw; a locked node held and the field clear of it; an explicit boundingBox holding the field; animate ending where the sync run ends. Breadthfirst: ranks ordered tree-first with shared rows, a root over the middle of its children, bands a componentSpacing apart, one component v3's exact picture, depthSort within a tree, the shelves and the singleton block with nothing overlapping, and the compact packed tree. The pack layout: translation-only with the largest held, the grouping and the order, the throw, a locked node, the box, the animated lifecycle, and its place in the built-in list. test/layout-quality.mjs's disconnected-components rows now include every discrete layout under packComponents: true; the harness spec pins the page's box and entry; force's own grouping specs hold through the refactor. All 2,650 Node specs green; npm run build:types, the @throws gate and the JSDoc coverage audit green.

Taxi tracks: automatic turn distances per bundle, the per-edge casing, and flow's corridors

Raised by the maintainer on 2026-09-10. Flow's one open legibility problem is which edge goes to which node: with curve-style: taxi every edge out of a rank turns at the same distance, so the horizontal runs of different sources lie on one line and a reader cannot follow an edge through the gap. The reference picture is Matteo Abrate's tangled-tree visualisation (Observable, @nitaku/tangled-tree- visualization-ii), built on GeneaQuilts [Bezerianos, Dragicevic, Fekete, Bae, Watson, GeneaQuilts: A System for Exploring Large Genealogies, IEEE TVCG 16(6), 2010, doi:10.1109/TVCG.2010.159] with curved links in place of the matrices. Its two devices are colour per group — not universal, most DAGs have no natural grouping — and, the universal one, a different turn distance per group, so each group's runs sit on their own line in the gap.

What the picture does, read from the notebook's code

The Greek-gods figure (levels as columns, nodes stacked within a column, links leftward from parent to child):

  1. Bundles are families: the children of one parent set share a bundle (n.parents.map(id).sort().join('-X-') is the key). A node in several families (Zeus, Poseidon, Gaea, Aeneas) is in several bundles.
  2. Each bundle owns one trunk column in the gap between its level and the one before: b.x = max(parent.x) + node_width + (l.bundles.length − 1 − b.i) × bundle_width, with bundle_width = 14. That is the "gradual variation": the k bundles of a level take k adjacent trunk positions. A child attaches by a horizontal run from the trunk; a parent by a horizontal run to it. In Cytoscape terms every link is a taxi-direction: rightward edge whose taxi-turn (px from the parent) is b.x − parent.xthe bundle's trunk is a taxi turn distance, and the family's trunks are routing tracks.
  3. A node in several bundles is stretched so the attachment points stagger by metro_d = 4 px (the metro-map merge).
  4. Long links get a larger corner radius (c1 = min(bigc, xb−xt, yb−yt) − c when the span is > 1) so they read as a sweep rather than a step.
  5. Every bundle's path is drawn twice, casing then colour, bundle by bundle (stroke=white width 5, then color(b) width 2), so a later bundle's casing gaps the earlier line at a crossing.
  6. Colour is d3.scaleOrdinal(d3.schemeDark2) per bundle.

Device 2 is the transferable one. Its general form is not new: it is the orthogonal edge routing of layered layouts — Sander's hyperedge routing (Layout of Directed Hypergraphs with Orthogonal Hyperedges, GD 2003), which ELK's layered algorithm ships as its ORTHOGONAL edge router: the edges out of one node between two layers are one hyperedge segment; the segments of a layer gap that overlap in x conflict; conflicting segments take distinct routing slots in the gap, ordered to minimise crossings between their vertical stubs, and the gap grows by slots × edgeSpacing. dagre has nothing of the kind, which is one reason dagre-plus-taxi reads badly on a fan-heavy DAG. The picture's per-family trunk is a routing slot; the picture's "families" are a hyperedge keyed on the parent set instead of the source.

What v4 has today, verified

  • Flow emits positions only, by decided design (src/layout/ flow.mts:17-21, src/README.md:1188-1199): "style-driven edges keep routing themselves after any drag". No layout writes style, bypasses or data; two leave scratch (concentric.mts:153, breadthfirst.mts:82). The LayoutContext (src/layout/ contract.mts:52-540) has no per-edge result channel.
  • taxi-turn is a mappable edge prop (src/style.mts:3512-3522; mapped turns are px, a percent is constant-only); bypasses re-derive the curve record (src/style.mts:9392-9448src/store/curve- index.mts:1310-1332). So a per-edge turn is already expressible; what is missing is a producer.
  • Curve records are position-independent by design (src/store/ curve-blob.mts:16-19): drags and layouts cost zero blob traffic; the taxi route is evaluated from live positions in both evalTaxi (src/curve-geometry.mts:1387-1560, v3's findTaxiPoints verbatim) and evalRouteW in src/render/shaders.mts — the dual- implementation discipline. The px turn is measured from the source boundary along the taxi axis (d = turn × sgnL, then srcH/2 when edge-distances includes the body); a turn inside taxi-turn-min-distance (10) of either end silently falls to the Z-/L-shape (:1466-1470).
  • The casing existsline-outline-width / line-outline-color (round 13 B4, column edge.casing, src/style.mts:9366-9378) — but it is a global pass: all underlays, then all casings, then all lines, then arrows, then overlays (src/render/ renderer.mts:2124-2240). A casing therefore halos an edge against nodes and the background, never against another edge. v3 draws the outline and the line per edge in order (drawEdge), so a crossing is gapped there — this is a parity gap, not a feature.
  • Flow's long edges are dummy chains (src/layout/flow- order.mts:64-159); BK aligns the inner segments to one x and that corridor is discarded at flow.mts:593-598. With a turn near the source the taxi leg runs at the target's x through the intermediate ranks, which nothing keeps node-free; 112.4 measured the 50 %-turn polyline scoring more crossings than the straight drawing and withheld alignLongEdges (112.5's first lever).
  • The harness already scores the taxi polyline: benchmark/ layout-quality.mjs:387-417 (flow-taxi, 50 % turn) over countCrossings (:56-165, proper intersections only).
  • A categorical scheme is on hand for the colour recipe (dark2, src/style-schemes.mts:214).

The decisions

  1. The pass lives in the style layer, not in the layout. A taxi-turn: auto edge takes its turn from a track the curve subsystem assigns from live positions. Three reasons: the maintainer's rule that edges route themselves after any drag (a layout-written turn goes stale on the first drag); it works under every layout — flow, breadthfirst, preset, a dagre port, hand placement — and under animation; and it is how the bezier bundle already works (the renderer derives the pair's fan from topology, no layout writes control-point-distances). Flow's contribution is what only the layout can give: node-free corridors and a gap wide enough for the tracks.
  2. A bundle is the edges out of one source (ELK's hyperedge; taxi-track: source, the default), so a fan-out reads as one orthogonal bus — the reactome win 112.4 recorded (3 crossings against dagre's 73) kept, now on its own line. family groups the edges into the targets that share a parent set (the GeneaQuilts key: k sources and m children on one trunk; each source drops to the trunk's line, each child from it), which is the genealogy and ontology case and explodes the track count on a general DAG, so it is opt-in. target is the mirror for fan-in drawings (the turn near the target, v3's negative-turn sense).
  3. Tracks are assigned by conflict, ordered for crossings. A bundle's run is the interval on the cross axis from its source x to its farthest target x, on a band along the taxi axis from the source's far boundary plus taxi-turn-min-distance to the nearest target's near boundary minus it. Two bundles conflict when both intervals overlap. Conflicting bundles are coloured greedily in an order that is, in v1, the source's cross-axis position (left to right ⇒ near to far, the staircase every metro map draws); ELK's pairwise crossing rule — for a conflicting pair, count the crossings of a's verticals with b's run in either order and orient the pair the cheaper way, break cycles greedily — is the second pass, taken only if the first measurement says the staircase loses crossings on the fixtures. Slot s of k in a band lands at bandMid + (s − (k−1)/2) × spacing, spacing = min(taxi-track-spacing, band/(k+1)), so a crowded gap compresses rather than falling into the Z-/L-fallback. A lone bundle sits at the band's middle — an auto edge with nothing to disambiguate draws exactly what '50%' draws today.
  4. Delivery is one f32 column, not the blob. edge.taxiTrack (px, signed as taxi-turn is) is written by a CPU pass over the auto edges, refreshed lazily off the geo epoch, not eagerly in the position write: the store's setPositions only writes the column and bumps geoEpoch, a drag fires many pointermoves per frame and a layout writes positions in several passes, so the sweep runs once at frame start (the renderer's pre-draw) and on the CPU readers (segmentPoints(), boundingBox(), picking), each keyed on the epoch it last saw. The blob record carries a flag and both evalTaxi and evalRouteW read the column when the flag is set. The blob stays position-independent; what a drag now costs is one sweep per frame over the taxi-auto edges whose bands moved, O(E log E) with E the auto edges — a 7k-edge DAG is well under a millisecond, and the pass is a single count check when no auto edge exists. segmentPoints(), boundingBox(), picking and the parity ledger follow because they read the same route.
  5. The casing becomes per-edge — v3's order. Instead of the global casing pass, an edge with casing draws as a pair of instances in the line pass, casing then line (instance parity in the shader; both streams), so a later edge's casing gaps an earlier edge's line where they cross — the picture's device 5 — and the separate casing pass goes away. Verified in v3's drawEdge: outline, underlay, line, arrows, overlay, one edge at a time in z order. The pair lands after v4's global underlay pass — the same z-ordering deviation the README already records for underlay and overlay — and only cased edges pair (the store already counts them), so the instance count grows only where casing is on. This is a parity fix with a visible change for anyone using line-outline-* today; the round says so in the changelog and the ledger scene re-baselines, and MIGRATING names the recipe for the old global-halo look: the edge underlay, which is still a global pass under all edges.
  6. Flow keeps the corridor and sizes the gap. (a) Target- anchored corridors: a span-≥2 edge's chain aligns to its target's x (the chain's last segment joins BK's protected inner segments and the block is anchored at the target), and the chains of the long edges into one target merge into one dummy per shared rank — they are the same physical leg — which makes the sweep's crossing count taxi-aware for the legs (112.5's first lever, in the form the measurement asked for). (b) Gap growth: after assignX, flow runs the same track counter over each rank gap and sets that gap to max(rankSep, slots × edgeSep + 2 × minTurn), edgeSep a new option defaulting to the style default's 10 — positions only, ELK's edgeSpacing. Both hold when taxi-turn is a constant too; the gap is simply wider.
  7. Declined: stretching a node to stagger its attachment points (device 3 — source-endpoint/target-endpoint are constants and the merge at a shared target already reads as a metro junction); the long-edge radius (device 4 — taxi-radius is mappable, a recipe); a colour-per-bundle mechanism (device 6 — a line-color ordinal mapper over an edge data key is the recipe, documented).

The style surface

prop values default note
taxi-turn px, 'N%', auto '50%' auto: the track's turn; reads back as 'auto'
taxi-track source | target | family source which edges share a track
taxi-track-spacing px 10 the ideal distance between neighbouring tracks

taxi-track and taxi-track-spacing are mapper-capable scalars like the other taxi props; auto on a mapped taxi-turn is a fallback-able keyword, with the corner-radius parser's sentinel- and-readback pattern as the precedent. There is no none value: the off state is a non-auto turn, so a constant turn with a grouping set is a plain no-op rather than two competing switches. The grouping is spelled track, not bundle, because bundle already names the bezier parallel-edge pair throughout curve-index.mts and the README. Flow's option: edgeSep (px, default 10).

The passes

  • 124.1 — measure first. The harness grows a run-overlap column — pairs of bundles whose horizontal runs are collinear and overlap, the figure this round exists to lower — and a flow-taxi-tracks row that assigns tracks from a prototype of the module over flow's positions. Rows: flow-taxi at 50 %, at 20px, and tracked, on deps, workflow-1k, reactome and the Greek-gods genealogy (the notebook's data, 67 nodes / 92 edges, added as a fixture) — crossings and run-overlap side by side. The staircase-vs-crossing-rule order is decided here.
  • 124.2 — src/taxi-tracks.mts. A pure function: edge slots, endpoint slots, per-edge direction and bundle key, node boxes → per-edge turn px; sweep-line over the taxi axis, conflict colouring, the slot formula. Shared by the store and by flow (both under src/). test/taxi-tracks.mjs: disjoint runs untouched, a bus shares one turn, family puts two sources on one line, the min-distance clamp keeps every turn ideal, compression in a crowded gap, determinism, and the lone-bundle = 50 % identity.
  • 124.3 — the style props and the column. Parse, computed record, EDGE_DEFAULTS, MAPPABLE, readback, the blob flag, the edge.taxiTrack column and its epoch-keyed refresh at frame start and on the CPU readers, evalTaxi and evalRouteW reading it. Gates: test/curve- derivation.mjs, test/curve-routes.mjs, test/style-readback- all.mjs and test/style-camel-case.mjs (a new edge prop fails their coverage otherwise), test/curve-route-accessors.mjs (segmentPoints() reflects the track after a drag), a Playwright routing scene.
  • 124.4 — the per-edge casing. The paired-instance line pass in both streams, the casing pass removed, test/edge-casing.mjs and the routing ledger re-baselined with the crossing gap asserted in pixels.
  • 124.5 — flow. edgeSep and the gap growth; target-anchored, merged corridors in flow-order.mts/flow-position.mts; the withheld long-edge corridor spec (no node body in the leg, the merge off as the control); the crossing table against 112.4's numbers.
  • 124.6 — docs and the page. src/README.md (the taxi props, the flow section's taxi contract rewritten around tracks, the colour and radius recipes), JSDoc and the shipped d.ts, MIGRATING (casing order) and the changelog; the debug page's flow sheet switches to taxi-turn: auto with a background-coloured casing, and a Greek-gods scene with taxi-track: family, dark2 per family and direction: 'rightward' reproduces the reference picture.

The specs that discriminate

Tracks: two overlapping fan-outs from the same rank land on distinct lines (the control: taxi-turn: '50%' puts them on one); a bundle's edges share one turn to the px; a family of two parents draws one trunk line (per-source draws two); every auto turn satisfies the min-distance test so no edge falls to the Z-shape; a drag that moves a source across another's run re-assigns before the next frame. Casing: at a crossing the later edge's casing colour is what the pixel under the earlier edge's line shows. Flow: a span-3 edge's leg at the target's x meets no node body on the intermediate ranks; two long edges into one target share one dummy per rank; the gap between two ranks with k conflicting bundles is at least k × edgeSep + 2 × minTurn.

Risks named at planning

  • The position-write path is the hot path; the track sweep must be incremental in the drag case (only bands touching the moved nodes) and must never run when no auto edge exists — the render bench's drag row is the detector.
  • Track order flips as nodes move (a source crosses another's run): the staircase order is stable under small drags, the crossing rule is not; if 124.1 chooses the crossing rule, hysteresis is its price.
  • The per-edge casing changes what existing line-outline-* users see at crossings; it is v3's picture, and the ledger says by how much.
  • Chain merging changes ordering on every dummy-heavy graph, not only taxi-styled ones; the harness's straight-line rows must not move by more than noise, or the merge becomes conditional on a flow option.

Decided (the maintainer, 2026-09-10): (1) style-side tracks, for the drag rule and layout-agnosticism, with the column refreshed lazily off the geo epoch; (2) taxi-turn: auto is the switch and the grouping is taxi-track / taxi-track-spacing, no none value; (3) the default grouping is source — bounded by the nodes in a rank and what the reactome win was measured on; family is the genealogy recipe; (4) the per-edge casing lands as parity without a switch — the global halo was never v3's, and the edge underlay already gives it to anyone who wants it.


What landed (2026-09-10)

Item 59, the day it was planned, on the four calls above. What follows is the round pass by pass, and where the work departed from the plan and why.

124.2 — the pass, as a pure function

src/taxi-tracks.mts: edges (source/target slots, direction, min distance, body mode, grouping, spacing) plus node centres and outer halves in, one px turn per edge out. Per edge the axis, sign and ideal band are derived exactly as evalTaxi derives them (body-subtracted deltas, the auto-axis choice, the min distance at both ends); an edge whose band is empty keeps the 50 % turn. Bundles key on the grouping per axis and direction, with an interval-clique split when members' bands do not intersect (a family whose parents sit at different heights). Added over the plan: the band is cut by the node bodies the run would cross and keeps the stretch nearest the source (nearest the target for target bundles) — without it a lone long edge's run landed in an intermediate rank row, and with it a long edge's run takes the first gap and its leg the target's x, which is exactly the corridor 124.5 keeps free. Conflicts are pairs whose runs and bands both overlap, found by a sweep along the axis with an active band list; components by union-find; slot s of k at mid + sgn × (s − (k−1)/2) × min(spacing, band/(k+1)). runDepth (the interval colouring) is what flow sizes a gap with. Twelve specs, including the lone-bundle = 50 % identity, the crowded gap compressing to 150/21 px with every turn ideal, and determinism under edge permutation.

124.1 — the measurement, and the order it decided

The harness grew a runOverlap column — pairs of bundles (the edges out of one source) whose horizontal runs are collinear and overlap, the figure this round exists to lower — with self-tests, four taxi rows (flow-taxi at 50 %, flow-taxi-20, flow-taxi-tracks in the staircase order, flow-taxi-tracks-x in ELK's pairwise crossing order) and the Greek-gods genealogy as a fixture (Abrate's data, 67 nodes over 7 generations, 92 edges, 20 two-parent nodes; the derivation script is beside it).

fixture flow-taxi flow-taxi-20 tracks (staircase) tracks (crossings)
deps 5168 / 43 4344 / 24 4515 / 0 4259 / 0
workflow-1k 26851 / 1141 26273 / 492 29266 / 3 23176 / 3
reactome 3 / 37 3 / 37 62 / 0 59 / 0
greek-gods 16 / 10 16 / 10 144 / 0 134 / 0

(crossings / run overlaps, before 124.5's positions.) The run-overlap count falls to 0 (3 on workflow-1k) under either order. The 50 % rows' crossing counts are not comparable: collinear overlapping runs are not proper intersections, so reactome's 37 overlaps hid the leg crossings that separating the lines exposes. Between the two orders the crossing rule wins on all four fixtures and is the default, the staircase kept as the control. Its cost counts per edge run with the shared-endpoint exclusion — the way the harness counts — after a first bundle-level version chose the dearer order in 5 of 7 pairs on the gods; a per-bundle count is the perceptual truth (a bus is one line), but the instrument is per edge, and the rule optimises what is measured.

124.3 — the props, the lane, the store

taxi-turn takes auto (stored as the 50 % default with an auto flag, reads back 'auto'; a mapper's fallback: 'auto' compiles to a sentinel the setter reads back as the flag), taxi-track: source | target | family and taxi-track-spacing (px, default 10), all mapper-capable edge props with readers, 155 readable edge props now. Departure from the plan: delivery is not a new edge.taxiTrack column. The taxi blob record's third float became a turn mode (0 px, 1 percent, 2 auto) and an auto edge's px turn is the params header's n lane, which taxi never used — both evalTaxi (now taking the lane) and the WGSL evalRouteW (header.z) read it. The reason is the binding budget: the curved vertex stage binds 7 storage buffers plus the visible list, WebGPU's base limit, so a new column had nowhere to bind; the lane rides a column the stage already reads, the blob stays position-independent, and the plan's intent holds. An auto turn routes toward the target like a percent turn (the forced-direction rule never applies). CurveIndex keeps the exact set of auto slots (set on style, cleared on release, moved on compaction), and GraphStore.refreshTaxiTracks runs from flushDerived once per geo epoch — at frame start (takeDelta) and on the CPU readers — building the track edges from the extras, the shown leaf bodies as obstacles, writing the changed lanes as one dirty span and never bumping the epoch it is keyed on; a single size check when nothing is auto. Two limits, recorded: under a GPU tween or force lease the CPU positions are stale, so tracks refresh when they land; and the sweep is whole, not incremental — see 124.7 for what it costs. test/taxi-auto.mjs (13 specs) covers the props, the pass through segmentPoints() — two overlapping fan-outs 10 px apart with the '50%' control on one line, the identity, family vs source, a drag off and back onto the other run — the bounding box and the lane written with the blob untouched; a renderer spec reads each colour on its own row in the browser.

124.4 — the casing, per edge

With any casing on, each stream's scene draw spends two instances per visible edge off a third indirect args block (the scan kernel doubles the count): the even instance the casing at the layer record's width, the odd the line, so within one draw each edge's casing lands over every earlier edge's line — v3's drawEdge order, verified (outline, underlay, line, arrows, overlay, per edge in z order). The straight shader factors vsEdge's body into edgeVertexAt, shared with vsEdgeCased; fsEdge shades a casing instance solid. The curved shader factors the layer stage's fused-geometry body into curvedVertexFused (with the dash/gradient length walk the line instance needs); the cased pipeline takes a third bind layout — the fused vertex set plus the paint set, the casing record visible to both stages — and fsCurvedCased shades the casing branch before the shared shadeCurved. The separate casing layer pass is gone; the pair lands after the global underlay pass, the deviation the README already records for the layers. Measured: parity-casing 72 px (0.060 %) → 10 px (0.008 %) against v3; parity-closeup-layers stays at 0. A renderer spec draws an X and reads the later edge's casing colour on the earlier edge's line 5 px from the crossing, with the z-order swapped as the control. The changelog and MIGRATING say what a line-outline-* user sees now, and name the underlay as the global-halo recipe.

124.5 — flow's corridors and gaps

The chains of the long edges into one target merge into one dummy per shared rank, the merged unit edges carry the summed weight, and a chain's last segment is protected like an inner segment; BK's marking reads a protected up-edge on real targets too. Found on the way: the first version left the up-sweeps anchoring the chain elsewhere (a real parent competing for the target won it in sweep order) and the four-way median split the block, so vertical alignment gained a pre-pass — protected edges align first under their own monotonic guard, and the ordinary pass stays between the protected alignments ahead of and behind it. A dummy aligns only along its protected edge, so the topmost dummy of a merged chain, joined only by sources' first segments, anchors the block at the target. Compound mode keeps plain chains. A corridor probe over 400 seeded random DAGs (does the vertical line at a long edge's target x meet a node body on the intermediate ranks?) read 187 violations before, 7 after, the residue being chains the ordering left crossing (a type-2 conflict), where the guard can anchor only one; the public spec is the probe's seed-1 fixture, which fails on the pre-124.5 flow, and the module spec pins the shared dummy, the summed weight and the protected tail with plain chains as the control. edgeSep (px, default 10; negative throws): after assignX the gap below each rank becomes max(rankSep, tracks × edgeSep + 2 × 10), tracks the overlap depth of that rank's fan-out runs (long edges included, since the pass puts their runs in the first gap); assignY takes the per-gap array. The straight flow row moved within noise (deps 4407 → 4189, workflow-1k 19482 → 19589, reactome 64 → 67, gods 117 → 117), so the merge stays unconditional; the tracked rows after 124.5: deps 4142 / 0, workflow-1k 22929 / 1, reactome 59 / 0, gods 134 / 0.

124.6 — the page and the record

The debug page's flow and breadthfirst sheets take taxi-turn: auto with a 2 px background-coloured casing, and ?network=greek-gods reproduces the reference picture: flow rightward, taxi-track: family, dark2 cycled over the 25 families, a 3 px white casing — opened and looked at: one trunk per parent pair, Zeus's families on parallel lines, the crossings gapped. The README carries the taxi props, the flow contract rewritten around tracks, the per-edge casing and the two recipes (colour per family as an ordinal mapper, the long-edge radius as a taxi-radius mapper); the shipped declaration carries edgeSep and the extras' fields; the changelog and MIGRATING carry the user-visible changes. Gates: the Node tier, the throw gate (two exemptions re-keyed for moved lines), the harness spec (105), the two new renderer specs and the casing parity scenes.

124.7 — the sweep's cost, measured and cut

The plan's risk note named the position-write path as the hot path and the plan's own estimate ("well under a millisecond at 7k edges") was wrong: the first sweep on a 3k-node, 8.7k-edge layered DAG took 1.1 s. Three causes, three fixes. The crossing rule's cycle check was a DFS over the whole preference DAG per pair (1.3 s alone at 139k conflicting pairs): it is now a bitset transitive closure per conflict component, walking set bits only, with the component capped at 128 bundles (past which that component keeps the staircase; 64 cost crossings on deps and workflow-1k, whose components sit between). The obstacle cut scanned every node body per bundle: the bodies are sorted along each axis once and a bundle walks the ones whose lower edge lies in its band. Bundle keys were strings: they are numbers. Two alternatives were measured and declined — Pearce–Kelly incremental cycle detection (196 ms: the bounded searches still walk dense successor lists) and a Copeland-seeded adjacent-swap refinement (5 % more crossings on workflow-1k than the margin-greedy, which the bitset closure reproduces exactly). Measured after: 8 ms on workflow-1k (1.9k edges, 6.6k pairs), 26 ms on a dense 2.7k-edge bench where every source fans across its row, 160 ms on the pathological 8.7k-edge one (29 gaps, each a 100-clique of bundles); a graph with no auto edge pays a size check (5 µs). The realistic figure is the workflow one: a drag on a 2k-edge auto graph spends 8 ms a frame in the sweep. Incremental sweeps stay the follow-up if a real graph ever needs them.

What the plan said that the code corrected

  • A separate f32 column could not bind in the curved vertex stage; the params header's unused n lane carries the track instead.
  • The staircase was the plan's first order; the measurement chose the crossing rule.
  • The plan had no obstacle cut; without it a lone long edge's run sat in a rank row, so the pass cuts the band by node bodies.
  • The plan's alignment change ("the chain's last segment joins the protected inner segments") was not enough on its own; the pre-pass with a look-ahead bound is what anchors the block in all four alignments.
  • The harness's crossing counts under the 50 % turn were never comparable to separated lines, which is the sentence 112.4's withholding was missing.

The layout quality audit: one sub-round per layout, each signed off by the maintainer

Raised by the maintainer on 2026-09-14 (docs/features.csv, "Layout quality audit & iteration", Partial / WIP) and planned on 2026-09-15. Round 122 closed the portfolio audit — which use cases exist, which built-in is the flagship for each — and round 123 filled the one gap it found. What that did not do is look at the pictures. Round 114.8's quality suite asserts properties a spec can assert (placement, fit, boundingBox, body and label overlap, locked nodes, component separation, the lifecycle), and rounds 116–124 improved force and flow where a measurement pointed; nothing has yet sat a person in front of each layout on the application graphs and asked whether the result is good. The maintainer's clarification is that built-in coverage does not establish finished quality, and that the quality review is a review of representative app graphs for overlaps, crossings, spacing, hierarchy readability, component packing and stability, iterated on algorithms and defaults with runtime measured alongside.

The shape of the round

One sub-round per layout, one for packing, and one for the page the sittings run on, in the order below. Each sub-round has the same four parts, and each ends with a maintainer review sitting — the sub-round is not landed until the sitting is recorded on this file with the maintainer's verdict and the findings it raised. Agents prepare; the maintainer judges.

  1. The fixture set for the layout. Three to five graphs drawn from the application graphs already in the repo — em-web and the ndex-x-large scene on the debug page, the npm-deps and reactome networks, the DAG fixtures under benchmark/fixtures/dag/ (deps, workflow-1k, workflow-10k, reactome, greek-gods, compound, deep-skips) — plus one small synthetic graph that exercises the layout's known weak case. Each fixture is named, sized and pinned so the pictures are reproducible from a seed.
  2. The measured baseline. The quality suite's probes run on each fixture and the numbers written down before anything changes: body and label overlap pairs, edge crossings, edge-length mean and variance, bounding area against the sum of node areas, and the run time at that N from the render-bench --layout rows. For the layouts with an order (flow, breadthfirst, circle, concentric) the crossing count is the headline; for force it is the settle's overlap and the edge-length variance; for the packers it is area and the largest component's centre.
  3. The pictures, and the iteration. Every fixture rendered on the debug page at fit and at one working zoom, saved to the round's directory with the commit and seed in the filename. The agent reads the pictures against the six criteria — overlaps, crossings, spacing, hierarchy readability, component packing, stability across re-runs and across small edits — and lists what it sees. Fixes to algorithms and defaults that the pictures justify are made in the sub-round, each with its baseline re-measured and its runtime re-measured, so a quality gain that costs the performance priority is visible as such. A default changed is a changelog and MIGRATING entry.
  4. The maintainer review sitting. The maintainer opens the page, runs the fixtures, and records on this file: accepted as is, or the findings that stay open and whether they block. Findings that block become items in PLAN.md; findings that do not are logged under the sub-round and dated. A sub-round with no recorded sitting is not landed, whatever its specs say.

The shared lever across all of them is the quality suite: every sub-round that finds a defect the suite could have caught adds the row, with its control (docs/agents/testing.md), so the next audit starts from a stronger floor. A property that only a person can judge — readability — stays with the sitting and is not faked as a spec.

The maintainer's first sitting (2026-09-14 and 15), before any sub-round

The maintainer sat in front of the debug page ahead of the round and reported what follows. These are the round's opening findings — each is assigned to its sub-round below, and each sub-round's sitting starts by checking that it is gone.

  • avoidOverlap with labels is broken, at least on force. The label-inclusive overlap path does not hold the labels apart. → 125.1.
  • avoidOverlap fights the tidy small components on force: the N = 4 diamond (120) and the overlap pass pull the four nodes in different directions. → 125.1.
  • The shelf packer wastes the space EnrichmentMap's packer wastes. shelfPack (src/layout/pack.mts) places boxes in rows, one component per column, and every row is as tall as its tallest member — so a row holding one tall component and several short ones leaves the short ones' columns mostly empty, and the effect compounds when component sizes are far apart. This is the same limitation the maintainer sees in EM web's vendored packer. Reproduction: the EM sample network on the page, radial, avoidOverlap on, packComponents on, tidy on. → 125.9.
  • flow is too spread out on reactome, even with avoidOverlap off; with it on, and especially with labels, the picture becomes far too spaced. The Greek-gods network shows the same, but only in the label-inclusive avoidOverlap case. With avoidOverlap off, reactome under flow is mostly fine. → 125.2.
  • flow has degenerate cases on reactome: "IRAK1 recruits IKK complex …" sits far from its only neighbour with no crossing to justify it (at least with taxi edges), which reads as wrong. → 125.2.
  • breadthfirst on reactome is too airy even without avoidOverlap. → 125.4.
  • The rendered reference picture is now on disk: benchmark/fixtures/dag/greek-gods-reference.svg, Abrate's tangled tree as the notebook draws it, the bar for the flow and taxi pictures. → 125.2.
  • Edges coloured as their source node would make the reactome and workflow scenes more legible. A style-side change on the page's scenes, not a layout change; it is also what the reference picture does. → 125.10.
  • The page needs a second spacing slider that acts live. The existing one sets spacingFactor for the next run. The new one should rescale the current positions about their centre as it is dragged — a preset with the scaled positions, or the equivalent — so a sitting can find the right spacing by eye and read the factor off. → 125.10, and it goes first, since every later sitting uses it.

Two general levers fall out of the findings, and both are shared across the sub-rounds:

  • A measured "too airy". The quality suite has an overlap probe and no probe for the opposite failure, and the findings above are mostly the opposite failure. The probe: the distance from each node's bounding box to its nearest neighbour's, and for each edge the gap between its endpoints' boxes, summarised as a distribution per fixture (median, p90, max against the median node size). A quadtree over the boxes, used only in the tests, keeps it linear-ish at the app graphs' sizes. For flow the DAG form of the same probe: each node's distance from its parent in the DAG, against the rank gap — the IRAK1 case is a large outlier on that column with nothing on the crossing column to explain it. Every sub-round's baseline carries these columns from 125.1 on, with the control the testing note requires (a deliberately spread fixture must read airy).
  • Explicit node separation on the geometric layouts. circle, radial, grid and concentric compute their spacing from a factor over their own default; the maintainer's suggestion is an option that names the margin between nodes directly (a nodeSeparation / margin in model pixels), so a picture can be fixed by saying what is wanted rather than by guessing a factor. Whether one spelling fits all four, and how it composes with spacingFactor and avoidOverlap, is a call for the sittings of 125.3, 125.5, 125.6 and 125.7; the option is designed once, in whichever of those runs first.

The sub-rounds

125.1 — force. The flagship for organic graphs and the layout with the longest history of measured fixes (59, 85.2, 116, 117, 118, 119). Fixtures: em-web (569 × 6,899), ndex-x-large, npm-deps, the 25k scene, and a small compound graph with locked children. Criteria in focus: edge-length variance and the crammed-settle overlap that 118.1 fixed; whether the spectral seed's determinism holds across animate / live / infinite; compound gravity's reading of parents at fit; the avoidOverlap sim half now that it is opt-in (117) — is the default picture the right default? Runtime: the CPU and GPU executors at each N against 119's rows. Maintainer review required. Opening findings: the label-inclusive avoidOverlap does not hold labels apart, and avoidOverlap fights the N = 4 tidy diamond; both are reproduced on the page first, and the label case gets a quality-suite row with its control before the fix.

125.2 — flow. The Sugiyama-class built-in (112) with 124's tracks. Fixtures: the seven DAG fixtures, all four directions on deps and greek-gods. Criteria in focus: crossings against the 112.1 harness's dagre and elkjs rows — "comparable to dagre" is still the bar — rank compactness, the long-edge and skip-edge cases (deep-skips), compound ranking (compound.json), and the taxi corridors' run-overlap count (124: 0 / 1 / 0 / 0). The one on workflow-1k is the case to look at. Runtime: workflow-10k end to end, and 124.7's sweep row. Maintainer review required. Opening findings: the picture is too spread on reactome with avoidOverlap off and far worse with it on and labels included; the Greek-gods picture shows the label case only; IRAK1's degenerate placement. The airiness columns above are this sub-round's first measurement — rank gap and within-rank spacing as they are computed against what the picture wants, and the label-inclusive dimension read (114's one reading) checked for double-counting the label into the spacing. The picture is judged against greek-gods-reference.svg.

125.3 — radial. The tree flagship (85.1). Fixtures: reactome as a tree from its roots, a deep unbalanced synthetic tree, a wide shallow one, and a forest (three roots). Criteria in focus: sibling spacing at the outer rings against label width, the root choice when none is given, non-tree edges' crossings, and a forest's packing now that 123 exists. Maintainer review required.

125.4 — breadthfirst. The v3 layout kept for parity, now with 123's trees as blocks. Fixtures: deps as an undirected traversal, reactome directed, and the forest. Criteria in focus: whether the circle: true form still earns its place beside radial and concentric, the block bands' spacing, the spacingFactor default, and the picture against flow on the same DAG — if breadthfirst's directed picture is a worse flow, the record should say so and the documentation should point at flow. Maintainer review required. Opening finding: too airy on reactome without avoidOverlap, so the spacing defaults are the first thing measured.

125.5 — circle. Fixtures: a 40-node clustered graph with a sort mapping by cluster, em-web's largest component, and a ring of labelled nodes at three label widths. Criteria in focus: crossings under the attribute-grouped order (round 122 declined an AVSDF order "until an app asks" — the sitting decides whether the picture asks), radius against label width, startAngle / sweep defaults, and clockwise. Maintainer review required.

125.6 — concentric. Fixtures: em-web with concentric mapped to degree, ndex-x-large with the same, and a synthetic graph whose mapping has one heavy level. Criteria in focus: level spacing when one ring is crowded (minNodeSpacing and the ring's radius growth), the equidistant picture, the per-component binning 123 added, and label overlap on the outer rings. Maintainer review required.

125.7 — grid. Fixtures: em-web's singletons (EnrichmentMap's actual use), ndex-x-large as a table, and a labelled set at three label widths. Criteria in focus: rows / cols inference against the viewport's aspect, avoidOverlapPadding, label-aware cell size, the sort and position mappings' picture, and whether condense should be the default for the singleton case. Maintainer review required.

125.8 — preset and random. The two without an algorithm, audited for their contract rather than their picture: preset's handling of missing and partial positions, fit / pan / zoom semantics on the CX2 round trip both apps ship, and random's bounding-box and seed determinism. One fixture each. Short, and the sitting may take minutes. Maintainer review required.

125.9 — packing. Rounds 120, 121 and 123's machinery, audited as one system: packComponents on the five discrete layouts, the pack layout's regroup, force's settle re-pack, flow's own pass, the small component shapes, componentGroup and componentOrder. Fixtures: em-web (the EM case: keep the sim, regroup), ndex-x-large, reactome's forest, and a synthetic mix of sizes from singleton to hundreds. Criteria in focus: packed area against the sum of component areas, the largest component's centre held, the shelf's aspect against the viewport, the orientation pass's turns, stability of the arrangement under a one-node edit, and the cost of the re-pack at 121's batch sizes. Maintainer review required. Opening finding: the shelf's rows waste space when component sizes are far apart, one component per column and the row as tall as its tallest — the EM web limitation reproduced on the EM sample network under radial with avoidOverlap, packComponents and tidy on. The candidates are a guillotine or skyline packer, or a shelf that fills a tall column's slack with short components; the area column decides, with the re-pack cost row beside it.

125.10 — the page, as the audit's instrument. Not a layout, but the sittings run on it, so it comes first. Two changes from the first sitting: a live spacing slider that rescales the current positions about their centre as it is dragged (a preset with the scaled positions, or the equivalent), beside the existing spacingFactor slider that only applies on the next run; and edges coloured as their source node on the reactome and workflow scenes. The airiness probe gets a readout on the page too — the median and p90 nearest-box distance for the current positions — so a sitting reads the number it is judging. Maintainer review required.

What the round does not do

  • It does not add a layout. Round 122's declined candidates — a tidy tree, a crossing-minimised or clustered circle, a second force layout — stay declined. If a sitting finds a picture that only a new algorithm fixes, that is a finding for PLAN.md, not a sub-round.
  • It does not change a default without the sitting. An agent may propose and measure a new default; the maintainer's review is where it is accepted.
  • It does not trade the performance priority for a picture. Every algorithmic change carries its runtime row, and a regression is a finding the sitting weighs, not a cost the sub-round absorbs.

Sequencing

125.10 first — the page is the instrument, and the live spacing slider is what every sitting uses. Then 125.1 and 125.2 — the flagships the apps actually run, and where the first sitting found the most — then 125.9, since packing is what the pictures of every other layout are seen through. 125.3 to 125.8 in any order after, 125.4 early because it already has a finding. The sub-rounds are independent enough to run in parallel worktrees, but the review sittings are serial on the maintainer's time, so the plan is one sitting per sub-round rather than one for the round; the round lands when the last sitting is recorded and docs/features.csv's row moves from Partial.

Controls

Each measured property is paired with the red control the testing note requires: the crossing counter run on a fixture whose known crossing count is nonzero; the overlap probe on the crammed fixture with avoidOverlap off; the area probe on an unpacked forest. The pictures themselves have no control — that is what the sitting is.

Open (maintainer): whether the sittings are recorded here or one file per sub-round; whether 125.8 is worth a sitting at all or folds into 125.7's; and whether a default change accepted in a sitting ships in 4.0 or waits for the round to land whole; the spelling of the explicit node-separation option on the geometric layouts, and whether it is one option across the four.

The round, as carried out (from 2026-09-15)

The sub-rounds below were carried out in the planned order — 125.10 first, then the four with findings in parallel worktrees (125.1, 125.2, 125.9, 125.4), then the geometric quartet and 125.8 — each against the maintainer's rule: defects fixed, defaults measured and recommended but not changed, and every sub-round pending its sitting. The record is one section per sub-round; the sittings are recorded under each as they happen.

125.1 — force

The two opening findings, both reproduced, both defects, both fixed. Pictures and tables in plan/pictures/rnd0125/125.1/; the numbers below are npm run benchmark:layout-audit rows, headless (the CPU executor — what headless runs; the page's GPU executor was checked separately for the label finding and reads the same), seed 1, the page's production sheet.

(a) Label-inclusive avoidOverlap — reproduced only at load, and that is the app's case. Through the page's Apply button, after a frame had been drawn, every mode the panel offers — settle with animate off, on, and live, sim, both, and an infinite run stopped after four seconds — left 0 label-box overlaps on em-web, on the GPU executor (AMD gcn-4) and on the CPU one, flat and compound (em-web-clustered, parents excluded), and on reactome, greek-gods and workflow-dag. The label boxes the layout separates are the label boxes boundingBox({ includeLabels: true }) reports, and they were held apart. What reproduced was the run before the first frame: ?layout=force&avoidOverlap=true&overlapLabels=true at load — the layout option of cytoscape(), what an app does — left 14 overlapping label pairs. Cause: the store estimated every label's block with a flat 0.54 em advance and let the renderer's glyph build overwrite it with the laid block in the first frame after the labels were set; a layout that ran before that frame separated estimates. On em-web 154 of 569 labels lay wider than their estimate once laid, the widest by 36 px, so the boxes the frame drew overlapped where the boxes the layout saw did not — and a page that had drawn a frame before Apply never showed it. Fix: src/label-measure.mts — where a 2D canvas exists (a document, or an OffscreenCanvas in a worker) the store measures the block with the same breakLines over measureText advances at the atlas's 32 px scaled to the em, and marks the dims exact; headless Node keeps the estimate (a recorded deviation, as before). After: at-create boxes against after-frame boxes on em-web, 0 of 569 differ (from 154), and the load-time run reads 0 overlaps. Spec: test/modules/label-measure.mjs — the measurer, the store's exact box, a force run holding measured boxes apart, and the control in the defect's own shape (laid out on estimates, then the frame's own setLabelDims with the real widths: overlaps). The fix is store-side, so every layout under nodeDimensionsIncludeLabels, every fit() and every boundingBox() before the first frame reads the laid box now — the flow and breadthfirst sub-rounds' label-inclusive rows inherit it.

(b) avoidOverlap broke the tidy shapes — every diamond, and with labels every triangle. Probed on em-web (20 pairs, 9 triangles, 4 diamonds among its components), shapes intact after a run:

avoidOverlap bodies: pairs / triangles / diamonds labels: pairs / triangles / diamonds
false 20/20, 9/9, 4/4 20/20, 9/9, 4/4
'settle' (default), before 20/20, 9/9, 0/4 18/20, 0/9, 1/4
'sim', before 20/20, 9/9, 4/4 20/20, 9/9, 4/4
'both', before 20/20, 9/9, 0/4 19/20, 0/9, 1/4
every mode, after 20/20, 9/9, 4/4 20/20, 9/9, 4/4

Two causes. Round 120 sized each shape so its neighbours along the shape's sides stood a body apart; the separation pass that follows clears axis-aligned boxes on either axis, which a diamond's top and right — a body width apart along the diagonal — are not, so the pass pushed them and the diamond became the plus and the kite the first sitting saw. And the pass's sweeps ran across components: at the settle, before the re-pack, a shape's new footprint could intrude on a foreign node, and the sweep pushed the shape's node rather than leave the pair to the re-pack that places components apart anyway (the stress rounds and the expansion already skipped foreign pairs; the sweeps did not). Fixes, src/layout/pack.mts and src/layout/force.mts: the radius is now the largest over every pair of the shape's points of separationAlong — the pass's own rule — plus the gap and its half-pixel, so the pass finds the shape clear (the settle's dims already carry the padding, so force passes 0 gap there; the old radius double-counted it); and separateBodies takes withinComponents, true when the re-pack follows, under which the sweeps and the best-state guard's depth leave foreign pairs alone. Round 120's own specs hold (the 30 px-body diamond on 60 px edges is unchanged; the star spec's 80 px bodies read the half-pixel). New specs: test/modules/pack-tidy.mjs — the diamond stands after separateBodies, with the control (sized for its edges alone, the pass breaks it); the within-components rule with its control — and the public case in test/force-layout.mjs, 40 px bodies on 20 px edges, the diamond and the triangle stand and nothing overlaps.

The baseline (before → after; overlaps are body or label boxes as marked, gap the median nearest-box gap in px and in node sizes, area Mpx², fill the boxes' share of it):

scene run ms overlaps area fill gap edge gap median / p90 / max
em-web (569 / 6,899) defaults 266 → 264 0 4.84 → 4.81 0.19 10 px, 0.26× 171 / 363 / 609
em-web avoidOverlap: false 192 713 3.62 0.25 0 58 / 138 / 305
em-web, labels settle + labels 358 → 339 0 7.24 → 6.67 0.22 → 0.24 10 px, 0.14× 230 / 496 / 818 → 219 / 486 / 791
em-web, labels sim + labels 1,182 0 7.37 0.22 10 px 227 / 635 / 1,167
white-matter (1,499 / 18,288) defaults 881 0 5.80 0.09 12 px, 0.64× 219 / 495 / 1,597
white-matter avoidOverlap: false 802 742 2.26 0.24 0 121 / 284 / 933
ndex-large (3,238 / 68,641) defaults 2,676 0 20.83 0.08 10 px, 0.46× 243 / 464 / 1,788
ndex-large avoidOverlap: false 2,315 2,970 8.58 0.20 0 127 / 253 / 1,147
compound, generated 200 × 400 defaults 54 0 1.55 0.02 30 px, 2.5× 76 / 199 / 325

The em-web rows barely move (the shapes are a handful of nodes); the label row's area drops 8 % because the tidy shapes no longer get pushed about. The compound generator is unseeded, so its stable column is meaningless (noted, not fixed). Seeds 1 / 2 / 3 on em-web: area 4.81 / 4.61 / 4.78, gap median 10 px throughout, the same fill to 1 % — stable in the sense the criteria ask.

What the numbers say about the defaults (measured, not changed — the sitting's calls):

  1. The crammed expansion grows the large scenes 2.4–2.6×. white-matter's area goes 2.26 → 5.80 Mpx² under the default settle (fill 0.24 → 0.09), ndex-large 8.58 → 20.83 (0.20 → 0.08): 118.1's expansion stage (a component of ≥ 1,000 nodes with ≥ 60 % of its nodes overlapping is scaled about its centroid, capped 1.25× per round, up to 12 rounds) is doing what it was built to do, and the result reads airy — the median nearest gap is only 10–12 px but the edge gaps are 220–240 px on 19–23 px nodes. 'sim' on the same scenes lands at 3.11 Mpx² (fill 0.17) and 12.05 (0.14) — half the area — at 4× and 2.7× the time (3.7 s and 7.2 s headless). Candidate: the expansion's per-round cap or its percentile (EXPAND_CAP 1.25, EXPAND_PERCENTILE 0.5) is where the growth comes from; a run of the settle with the expansion stage traced per round would show how much of the 2.5× is the expansion and how much the stress rounds after it. Recommendation: measure that trace in the sitting's presence before touching the constant; do not switch the default to 'sim' for its picture — the time is the performance priority's.
  2. avoidOverlapPadding 10 → 4 on em-web: area 4.81 → 4.19 (−13 %), fill 0.19 → 0.22, gap median 4 px; with labels 6.67 → 5.86. On white-matter 5.80 → 3.21 (−45 %, fill 0.17) — because the padding feeds the crammed test too. Recommendation: keep 10 for the small graphs (the picture at 4 px reads crowded on 40 px discs) and let item 1's measurement decide the large-scene growth rather than shrinking the padding to work around it.
  3. edgeLength 60 with 40 px em-web nodes gives an edge gap median of 168 px in the big cliques — three node widths of open edge between neighbours, which is the cliques' density more than the constant; the same constant on 19 px white-matter nodes reads airier (edge gap 219). No recommendation from this sub-round; the live spacing slider on the page is the instrument for it.

What changed on disk. src/label-measure.mts (new), src/store/graph-store.mts (measure before estimate), src/layout/pack.mts (the radius by separationAlong, imported from separation.mts), src/layout/separation.mts (sweep takes compOf; ExtentsLike), src/layout/force.mts (withinComponents; the gap force passes tidy), scripts/throw-coverage.mjs (one site re-keyed), benchmark/layout-audit.mjs (the crossing counter without its pair set — it threw on white-matter), src/README.md (the label dims deviation; the tidy paragraph), the three spec files, and the pictures. Gates: verify, test/layout-quality.mjs, test/force-layout.mjs, test:node:quiet green.

Not reproduced as stated, and worth saying so: on a page that has drawn a frame, label-inclusive avoidOverlap on force was never broken in any mode; if the maintainer saw it there rather than at load, the sitting needs the exact network, sheet and checkboxes.

Maintainer review: pending. The sitting should open em-web with labels on, and look at: (1) the small components' rows — every four-node component a diamond, every three a triangle (em-web-force-after-fit.png against em-web-force-before-fit.png, where the diamonds are pluses and kites); (2) the same with Avoid overlap + labels on (em-web-force-labels-after-fit.png / -close.png); (3) ?network=em-web&layout=force&avoidOverlap=true&overlapLabels=true&labels=true — the load-time run, which is where the label finding lived — and count nothing overlapping at zoom 2; (4) reactome under force with labels (reactome-force-labels-before-close.png — unchanged by the fixes, the picture to judge the label spacing itself on); and decide the three default questions above, item 1 first.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Deferred. Requires more maintainer review before a decision; the two fixes stay as landed code, the expansion recommendation is undecided, and the sub-round is not signed off.

125.2 — flow

The first sitting's three findings on flow — too spread on reactome even with avoidOverlap off and far worse with labels, the Greek-gods scene wrong only with labels, and a leaf far from its only neighbour — came apart into three wrong readings, one placement the block-graph compaction had given up, and one question of defaults that stays with the sitting.

What was found

  • The page's "avoid overlap off" is avoidOverlap: false, which places points. Flow then reads nodeSep centre to centre: a 50 px pitch for 18 px bodies, a 32 px gap — 1.78 node sizes. The library's own default (bodies separated) is a 50 px gap, 2.78 sizes. Neither is a defect; both are the default question below.
  • Symmetric halves. assignX separated a rank by halfW = max(-x1, x2) and assignY stacked rows by halfH likewise, so a label hung to one side was read as twice its larger side. The Greek-gods labels (hung right, 76 px box) read as 140 px; reactome's bottom-hung wrapped labels doubled the rows: 148 px apart where 116 fit.
  • The model axes whatever the direction. The within-rank axis always took the model width and the rank axis the model height. Under direction: 'rightward' a rank is a column, so its members were separated by their widths — invisible on 12 px discs, and the whole of the "only with labels" finding: with labels the scene went to aspect 0.3, a column 3.4 Mpx² tall. The compound walls and rank margins had the same swap (padX along a rightward rank).
  • The free singleton. Compaction is a longest-path pass over the block graph; a block nothing aligned — a leaf in the two sweeps that align to lower neighbours, or a node whose median parent was taken by a sibling or marked by a type-1 conflict — sat at its leftmost feasible x (rightmost, mirrored). The balance of four such candidates put reactome's "IRAK1 recruits IKK complex upon TLR7/8 or 9 stimulation" (R-HSA-975144, one parent, no children) at candidates [5238, 7346, 4694, 7346] → 6292, 1,632 px from its parent at 4660. The header of flow-position.mts had recorded giving this up ("what is given up is only the original's placement of totally unconstrained classes, which the four-way balance step reintroduces") — the balance does not reintroduce it; it averages two packings.
  • The two-parent IRAK1 (R-HSA-937039, parents at 3775 and 4014, placed at 2891) is order-bound, not a placement defect: the rank order puts it left of two six-parent nodes whose parents' median is further left, so no candidate can carry it under its parents without a crossing the ordering did not choose. Unchanged, and recorded as such.

What changed on disk

  • src/layout/flow-graph.mts: the scope and component carry left / right / top / bottom on the canonical axes (CanonicalExtents) in place of halfW / halfH.
  • src/layout/flow.mts: canonicalExtents(ext, direction) maps the four model sides by direction (rightward: canonical x = model y); the walls take the padding of the axis they are on (padAcross / padAlong); the packer and the boundingBox fit read the real model extents (state.ext) rather than symmetric halves.
  • src/layout/flow-position.mts: assignX(L, left, opts, right) separates by right(u) + left(v) + gap (mirrored runs swap the sides) and reads the min-width balance from the same sides; assignY(L, top, rankSep, margins, bottom) builds a row from its tallest top and deepest bottom; compact ends with placeFree — each real singleton block moves, within the slack its rank neighbours leave, to the median x of its neighbours on the sweep's side (the other side when it has none), right to left within a rank so every bound is a placed position. flow-compound.mts's rankPadMargins takes the pad array.
  • test/layout-flow.mjs, "extents and placement (125.2)": a right-hung label's boxes sit nodeSep apart; an under-hung label's rows sit rankSep apart; a rightward rank of 60 × 10 nodes is separated by heights (with the downward control); the leaf on a 12-node DAG a seeded search found (n5 under n0: 220 px before, one pitch after), with the separation control. Against the pre-fix code the first three read 371, 47 and 100 px where 50, 24 and 50 are asserted; the first hand-built leaf fixture did not go red — the drift needs a sibling that takes the parent in one sweep and a crossing that blocks the other — which is why the fixture is a found one. test/modules/layout-flow-internals.mjs's fixtures carry the four extents.
  • src/public-types.mts (nodeSep, rankSep, avoidOverlap docs), src/README.md's flow section.

Before and after (headless, the page's sheets; benchmark:layout-audit)

network run area Mpx² aspect gap med px (× size) edge gap med / p90 / max parent gap med / p90 / max
reactome defaults, before 5.72 9.7 50 (2.78) 110 / 1169 / 3281 106 / 909 / 3281
reactome defaults, after 5.72 9.7 50 (2.78) 84 / 1047 / 3281 60 / 798 / 3281
reactome avoidOverlap false, before 3.33 9.0 32 (1.78) 82 / 855 / 2408 76 / 664 / 2408
reactome avoidOverlap false, after 3.33 9.0 32 (1.78) 57 / 765 / 2408 42 / 582 / 2408
reactome labels, before 21.68 11.2 50 (0.53) 173 / 2444 / 6902 164 / 1896 / 6902
reactome labels, after 16.95 14.3 50 (0.53) 118 / 2160 / 6902 84 / 1643 / 6902
greek-gods rightward defaults, before 0.90 0.5 50 (4.17) 112 / 546 / 1042 60 / 419 / 918
greek-gods rightward defaults, after 0.90 0.5 50 (4.17) 112 / 546 / 825 60 / 354 / 670
greek-gods rightward labels, before 3.42 0.3 24 (0.49) 282 / 1284 / 2458 53 / 1001 / 2142
greek-gods rightward labels, after 1.62 0.9 50 (1.03) 112 / 546 / 825 87 / 354 / 670
npm-deps defaults, before 42.11 9.1 50 (2.78) 1992 / 6398 / 14863 1616 / 6667 / 10038
npm-deps defaults, after 42.11 9.1 50 (2.78) 1771 / 5613 / 10656 1324 / 5953 / 10038
npm-deps labels, before 83.72 9.8 50 (0.61) 3143 / 10038 / 22260 2588 / 10359 / 14520
npm-deps labels, after 73.51 11.1 50 (0.61) 2883 / 9016 / 15252 2108 / 9433 / 14520
workflow-dag defaults, before 2.56 3.2 50 (2.78) 256 / 905 / 2724 113 / 375 / 2045
workflow-dag defaults, after 2.59 3.2 50 (2.78) 256 / 900 / 2724 118 / 375 / 2045
workflow-dag labels, before 3.44 2.5 51 (1.54) 271 / 923 / 2769 125 / 380 / 2088
workflow-dag labels, after 3.07 2.8 50 (1.52) 271 / 916 / 2769 118 / 381 / 2089

Crossings and overlaps are unchanged on every row (reactome 67, greek-gods 117, npm-deps 6090 ± 3, workflow-dag 759–762; overlaps 0). Runtime: reactome 14–18 ms, npm-deps ~57 ms, workflow-dag ~18 ms on the headless CPU path, unchanged within noise. The parent-gap maxima that remain (reactome 3281, "Innate Immune System" under "Immune System") are a parent centred over sixteen children — the shape of the data, not a placement.

The DAG harness (benchmark:layout-quality, straight-line geometry):

fixture flow before flow after dagre elk
deps 4189 cross, len 1067, 21.0 Mpx², 26 ms 4192, 1063, 21.0, 26 ms 4279, 1934, 33.3, 384 ms 3382, 1964, 94.4, 336 ms
workflow-1k 19589, 4557, 174.3, 118 ms 19550, 4449, 173.9, 107 ms 20570, 6224, 103.4, 11.2 s 21825, 4945, 554.9, 1.7 s
reactome 67, 464, 7.79, 10 ms 67, 425, 7.79, 12 ms 73, 407, 6.75, 78 ms 54, 497, 9.44, 106 ms
greek-gods 117, 382, 1.33, 6 ms 117, 348, 1.33, 5 ms 126, 396, 1.35, 21 ms 120, 470, 1.66, 51 ms

The placement pass leaves the rank order alone, so crossings hold and the mean edge length falls on every fixture; workflow-1k's one tracked run overlap (124) is 0 after.

The pictures (plan/pictures/rnd0125/125.2/)

reactome-flow-labels-close-{before,after}.png (zoom 1, the labels readable): rows 148 → 116 px apart, the within-rank pitch 146 px in both. reactome-flow-labels-fit-*: the whole picture, 21.7 → 17.0 Mpx². reactome-flow-defaults-close-*: the placement pass at defaults (edge gap median 82 → 57 on the page's avoidOverlap: false run). greek-gods-rightward-labels-fit-{before,after}.png: the column of 0.3 aspect against the picture the reference SVG draws — after, the columns are label-width apart and the rows one label height, which is the reference's shape (greek-gods-reference.svg draws 10 px text on rows ~16 px apart with columns set by the longest name; the after picture's rows are 12 px discs at a 50 px gap, the default question again). greek-gods-rightward-defaults-fit-after.png for the sitting's comparison without labels.

Defaults: measured, not changed

nodeSep 50 / rankSep 60 against 18 px bodies is the spread the sitting saw with avoidOverlap on. A sweep over nodeSep ∈ {20, 30, 40, 50} × rankSep ∈ {30, 40, 60}, with and without labels, on reactome, npm-deps, workflow-dag and greek-gods (rightward):

  • nodeSep sets the area almost linearly: reactome without labels 2.33 / 2.94 / 3.55 / 4.16 Mpx² at rankSep 30 for 20 / 30 / 40 / 50; the median gap is exactly nodeSep in every row.
  • rankSep below the track floor does nothing: the gap below a rank is max(rankSep, tracks × 10 + 20), so 30 and 40 read the same on workflow-dag (every gap has tracks) and differ on reactome only where a gap has one fan-out. 60 costs 15–20 % of area over 40.
  • With labels the label boxes sit nodeSep apart — 50 px between 95 px label columns on reactome — and the area at nodeSep 20 is 13.4 vs 17.0 Mpx² (−21 %); a smaller gap between label boxes than between bodies is the lever if the sitting wants the label case tighter without touching the body case.

Recommendation for the sitting: nodeSep 30, rankSep 40 — reactome 3.20 Mpx² (−44 % against today's 5.72), gap 1.67 node sizes, crossings unchanged by construction; dagre's own defaults are 50/50 for 30–60 px boxes, i.e. roughly one body's width of gap, which 30 is for 18 px bodies and a label-column is not. A label-aware gap (nodeSep for bodies, a smaller labelSep or a fraction for label boxes) is a second, independent call. Neither is changed in code.

Noted, out of scope

The page's Apply runs flow downward whatever the network's load layout says (greek-gods loads rightward): layoutOptions does not read def.layout. The rightward-with-labels pictures here were taken through cy.layout() directly. elkjs is --engine elk on the DAG harness.

Maintainer review: pending. What the sitting should look at: the four reactome-flow-labels-* pictures side by side (the rows), the two greek-gods-rightward-labels-* against greek-gods-reference.svg (the shape), the defaults-close pair for the leaves now beside their siblings; then the two calls — nodeSep 30 / rankSep 40 as the defaults, and whether label boxes get a smaller gap than bodies — with the sweep's table above.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Deferred until the page sitting, which needs 125.11's option controls first. Direction given: the gaps might become automatic defaults derived from node size (a 1× separation by default), or simply smaller values for nodeSep / rankSep. The three fixes stay as landed code.

Raised by the other sub-rounds, for this one's sitting. 125.4 measured npm-deps under flow at 3–6× breadthfirst's straight-line crossings and 13× its area — the compound mode (11 scope parents) holding each scope's members contiguous per rank at the price of the crossings between scopes; whether that trade is right for a dependency DAG is a call on the compound mode's picture. And the page's Apply runs flow downward whatever the network's load-layout direction (layoutOptions ignores def.layout), so the Greek-gods scene re-applied from the panel is not the reference picture; the page should carry the direction — a 125.10 follow-up.

125.3 — radial

Fixtures. Reactome as a tree (227 nodes, 245 edges, one true root, ten multi-parent joins); the quality suite's unbalanced tree (a 2-leaf and an 8-leaf branch); em-web's components under packComponents (569 nodes, a 187-node giant and a shelf of small ones). The page's production sheet, so the label boxes are the page's (reactome's wrapped 110 px labels: a 95 px box).

Baseline (benchmark:layout-audit, headless 1400 × 1000, commit 9339dfbc):

run ms crossings area Mpx² gap median / p90 ratio edge gap median / p90 / max parent gap max (node)
reactome, defaults 9 140 4.74 20 / 29 1.10 73 / 589 / 1561 1561 (R-HSA-448424)
reactome, labels 10 139 44.14 54 / 107 0.56 195 / 1886 / 4637 4637 (R-HSA-448424)
reactome, roots: [Immune System] 7 274 4.26 20 / 39 1.09 87 / 708 / 1800 1743 (R-HSA-975138)
reactome, condense: true 8 140 4.74 20 / 29 1.10 73 / 589 / 1561 1561
em-web, packed 112 3.81 M 31.19 25 / 50 0.61 1439 / 3311 / 3746 3076
em-web, packed, labels 106 3.81 M 58.29 34 / 54 0.46 1975 / 4514 / 5110 4227

Stable across re-runs on every row.

What the pictures show (plan/pictures/rnd0125/125.3/). reactome-radial-9339dfbc.png: the centre is not the root. Radial infers its roots by maximum degree — breadthfirst's undirected rule, copied in 85.1 — so on a directed hierarchy the centre goes to Innate Immune System (degree 17) and Immune System, the one true root, sits on ring 1 to its left, its blue edges the only ones that run inward. The tree is then a different tree: BFS from the wrong node makes the root a child, and the depth-2 nodes under it fan out from the far side. reactome-radial-true-root-9339dfbc.png is the same graph with roots given: the root at the centre, the hierarchy reading outward — at the price of 274 crossings against 140, because the true tree is deeper (six rings against four) and the outer rings are set by the most crowded one's circumference. Both pictures share the second thing to see: a rim around an empty middle. Every ring layout's outer ring takes the radius its most crowded ring needs, and reactome's leaves (150 of 227 nodes) all sit on the last ring, so the ring is 1,500 px across and the inner rings — a handful of nodes each — float in the middle with their edges running the whole radius (edge gap p90 589 px, the parent gap max the same node). The label-inclusive picture (reactome-radial-labels-close-9339dfbc.png) is the same shape at three times the radius: the 95 px label boxes set the chord.

condense (125.3 / 125.5, one option designed once): grid's spelling adopted on radial — condense: true sizes every ring by its nodes and avoidOverlapPadding, the box only centres, levelSpacing stays the floor. Off by default. Measured on reactome it changes nothing (the table's row: the clearance rule already set the rings past the box's share); on the unbalanced tree in a 600 px box the first ring goes from 100 px to 40 px. The spec pins both.

What changed on disk. condense on radial (and circle), its JSDoc, the README's geometric-layouts entry, the changelog; two specs in test/layout-radial.mjs (the condensed rings against the boxed control, every pair at the padding; levelSpacing as the floor). No default changed.

For the sitting — recommendations, not changes.

  1. Root inference on a directed component. Infer roots as the nodes with no incoming edge when a component has any (v3's breadthfirst rule under directed: true), falling back to maximum degree; the picture reads as a hierarchy, at 274 crossings against 140 on reactome. A directed option (breadthfirst's) is the alternative spelling that keeps today's default.
  2. The rim. The outer ring's radius is set by the most crowded ring; a shallow wide tree draws as a rim around an empty middle. Nothing in the option surface reaches it: the fix is either to let the outer ring's leaves stagger onto two radii (a leaf ring drawn as a band, the way the reference tidy trees do), or to accept that a wide shallow tree is breadthfirst's or flow's picture, not radial's, and say so in the docs. The sitting decides whether the band is worth a sub-round.
  3. condense as the default is the third call; the app graphs are unaffected either way.
  4. Raised by 125.9 while packing em-web: radial draws the 187-node component as one 3,883 × 4,020 px ring — the rim finding (2) at its worst, since a dense component has one root and every other node on ring 1 or 2. A component that is not a tree is not radial's picture; whether the layout should say so (fall back to concentric, or warn) is the same call as 2.

Maintainer review: pending. Open the page on reactome, Radial, Avoid overlap on; then set roots in the console (cy.layout({name:'radial', roots:['R-HSA-168256']}).run()); then labels on. Pictures: the three under plan/pictures/rnd0125/125.3/. Decide 1–3 above.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Decided on the roots: radial is designed for hierarchies, so a component's default roots are its true roots — the nodes with no incoming edge — with a fallback where a component has none (a cycle): the maximum-degree rule stays as that fallback. Shipped in this round (the sitting also ruled that an accepted default ships as accepted): reactome's centre is its root, 274 crossings against 140 as measured above, area 4.26 Mpx²; em-web's packed radial goes from 31.19 to 23.98 Mpx² and 3.81 M to 3.80 M crossings (many of its components have indegree-0 nodes, which now seed the trees). reactome-radial-true-root-default-after.png is the default picture now. The rim (2), condense as the default (3) and the non-tree component (4): not ruled on, deferred to the page.

125.4 — breadthfirst

The first sitting's finding: too airy on reactome even without avoidOverlap. Measured on the page and headless at the same options, it was two pictures, not one:

reactome, avoidOverlap: false, spacingFactor 1.75 gap median in node sizes area
the page (after the fitted flow load, zoom 0.11) 269 px 14.96
headless (zoom 1) 30 px 1.68 2.86 Mpx²

Cause 1 — the drawing scaled with 1 / zoom. v3's breadthfirst spreads its rows and ranks over cy.extent(), the viewport in model coordinates, and v4 kept that line (round 42's port); grid, circle, concentric and radial read the viewport in pixels. So the box breadthfirst fills is the viewport divided by the zoom at the moment Apply is clicked — after a fitted flow run the page sits at zoom 0.11, and the same tree came out nine times airier than headless, the nodes reduced to dots (reactome-breadthfirst-page-off-fit.png). Fixed: the pixel viewport, as the other four. At zoom 1 nothing changes, which is why the headless suites never saw it. The spec lays the tree out at zoom 0.1 and at zoom 1 with fit: false and asserts the same positions; the control (the extent read back in) is a tenfold span. After: page gap median 17 px (0.97 node sizes), reactome-breadthfirst-page-off-fit-after.png.

Cause 2 — a compound parent as a root: NaN for the whole drawing. The audit script's npm-deps row read stable false and every column NaN under the undirected default (directed and packComponents were fine). v3's undirected root inference takes the maximal-degree node of every component; an edgeless compound parent (npm-deps' eleven scope parents) is its own component of degree 0, so it became a root, the walk placed it in a depth, and every node's dimensions were then read through an index the parent was never in — NaN everywhere. Bisected to one parent with two children plus a singleton (a, b and s1 NaN). Fixed: roots are inferred over the placed (childless) nodes and the walk skips a parent it reaches; the spec is that four-node graph. After: npm-deps 1055 crossings, gap 1.04 node sizes, stable.

What is v3's design, not a defect, and the sitting's call. With one component the drawing is the box-fill: every rank is spread over the full width whatever its count (reactome's four-node rank at a 463 px pitch, its 53-node rank at 43), the rows over the full height (179 px for 18 px nodes), floored by the overlap need, then spacingFactor 1.75 over all of it. The packComponents path ('compact' sizing) spaces by the need alone — 32 px pitch and row step at 1.75, bodies touching at 1 — which is the other extreme. Neither is the flow rule (need plus a gap). Measured candidates on reactome, headless:

reactome crossings area Mpx² aspect fill gap (sizes) edge gap median parent gap max
default (box-fill, sf 1.75) 71 2.86 1.8 0.026 1.68 341 1739
sf 1 71 0.95 1.8 0.077 0.53 187 986
directed 97 3.35 2.1 0.022 0.84 220 599
compact (packComponents), sf 1.75 71 0.39 6.9 0.186 0.75 234 1274
compact, sf 1, padding 8 71 0.27 6.8 0.268 0.44 190 1048
compact, sf 1, padding 12 71 0.36 6.9 0.204 0.67 222 1212
compact, sf 1, padding 12, directed 97 0.57 11.0 0.129 0.67 267 777
labels, default 71 13.23 7.5 0.065 0.95 1447 7670
labels, compact, sf 1, padding 12 71 5.51 7.3 0.156 0.23 893 4836

Recommendation for the sitting: for a single component, space by the need plus an explicit gap (the flow rule; the round file's node-separation option, designed in whichever of 125.3/5/6/7 runs first) rather than the box-fill, and keep the box-fill only under an explicit boundingBox, since "fill the viewport" is what fit does anyway. The default spacingFactor 1.75 is v3's and was kept in 115.6; under a need-plus-gap rule it would multiply a gap, not a box, and 1 with a 12 px gap reads as the tightest legible row above. Not changed in code.

The undirected root inference is v3's, and reads oddly on a tree. The maximal-degree node of a component is the root; on a 1 + 4 + 16 tree the four middle nodes have degree 5 against the root's 4, so all four are roots and the tree draws in two rows from the middle. v3 parity; the sitting may want directed: true to be the page's default for a DAG scene, or a root inference by in-degree when the edges are directed. Not changed.

Breadthfirst directed against flow, the same DAG.

fixture layout crossings (straight) area Mpx² gap (sizes) parent gap max ms
reactome breadthfirst, directed 97 3.35 0.84 599 10
reactome breadthfirst, undirected 71 2.86 1.68 1739 11
reactome flow 67 5.72 2.78 3281 15
reactome dagre (112.1 harness) 73 6.75 73
npm-deps breadthfirst, directed 2166 3.31 0.76 1754 21
npm-deps breadthfirst, undirected (after the fix) 1055 3.37 1.04 1833 30
npm-deps flow 6090 42.11 2.78 10038 59
deps (harness, 30 px boxes) flow 4189 21.04 24
deps (harness) dagre 4279 33.32 376

On reactome the two are close on crossings (67 against 71–97) and breadthfirst's picture is half the area; on npm-deps flow's straight lines cross three to six times as often as breadthfirst's rows do and the drawing is thirteen times the area — the compound scope parents put flow in its compound mode (npm-deps in the audit script is the page's fixture with parents; the harness's deps has none and reads 4189, level with dagre's 4279). Breadthfirst's directed picture is not a worse flow: it is the cheaper, squarer drawing with more long edges (parent gap max) and no crossing minimisation. The documentation should keep pointing at flow for a DAG whose edges must be followed (the corridors, the tracks, the rank compaction), and at breadthfirst for a tree or a DAG read as levels — which is what the portfolio table already says. The npm-deps flow row belongs to 125.2's sitting.

circle: true beside radial and concentric, on the quality suite's balanced tree (1 + 3 + 9 + 27, 30 px nodes) and on reactome:

fixture layout crossings area Mpx² fill gap (sizes) edge gap median
tree breadthfirst circle: true 18 3.17 0.011 4.53 444
tree radial 3 0.48 0.075 1.12 129
tree concentric 35 0.25 0.143 0.55 118
tree breadthfirst rows 6 1.21 0.030 1.64 501
reactome breadthfirst circle: true 2557 3.95 0.019 2.34 290
reactome radial 140 4.74 0.016 1.10 73
reactome concentric 5076 4.58 0.016 0.97 802

Verdict: circle: true is the worst of the three on every column that matters — six times radial's crossings on the tree, eighteen times on reactome, and the airiest drawing (4.53 node sizes) because each ring is the box's radius step, not the ring's need. It earns its place only as v3 parity; the documentation should point at radial for a tree drawn in rings and at concentric for rings by a score. Whether to keep the flag or deprecate it is the sitting's call; not changed.

The forest and 123's blocks, on a three-tree forest (21 + 7 + 7) with three pairs and twenty singletons, headless: bands A / B / C side by side, the pairs and the singleton block on a second shelf below (rows 3), singleton cell pitch 68 px at 1.75 and 39 at 1, the block 20 wide in one row; under packComponents the singletons wrap into four rows of five at a 105 px pitch. Nothing wrong found. Note for 125.9: the singleton cells take the root row's box spread as their pitch (distanceX[0]), so a forest with few roots gives the block wide cells; under the need-plus-gap rule above that goes away.

What changed on disk. src/layout/breadthfirst.mts: the pixel viewport as the default box; roots inferred over placed nodes; the walk skips parents. src/public-types.mts: the interface's doc. src/README.md: a deviations entry. test/layouts.mjs: the two specs. benchmark/layout-audit.mjs: --elements <file> for the suites' synthetic fixtures. Pictures under plan/pictures/rnd0125/125.4/: reactome before (fit, close-up) and after (fit, close-up), the compact candidate, npm-deps after, flow on the same graph. Gates: verify, test/layout-quality.mjs (210 green, the "not too airy" rows included), test/layouts.mjs (36), test:node:quiet green.

Maintainer review: pending. The sitting should open reactome on the page, run flow first (so the zoom is where it was in the first sitting), then breadthfirst with avoidOverlap off, and compare with reactome-breadthfirst-page-off-fit.png (before) and -after.png; then npm-deps under breadthfirst (finite now; the scope parents are not roots). Three calls to make: (1) whether a single component keeps v3's box-fill or takes need-plus-gap spacing (the compact rows in the first table are the candidates, at padding 8 and 12); (2) whether circle: true stays, given the ring table; (3) whether the page's DAG scenes should default breadthfirst to directed: true. The label case (labels, default row: a 7.5:1 drawing, parent gap max 7670) is the same 84-labels-in-a-rank problem as flow's and belongs with 125.2's label-gap call.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Deferred, with the root issue named: the dilemma between compacting (condense: true) and the bounding box. In v3 the box was a constraint — the result must lie inside it unless another option conflicts. For v4 the default might instead read the box as a hint of the available space, which helps a layout work better by default, with a new explicit option (constrainWithinBounds: true, or a spelling like it) saying when and how the box binds. A cross-layout design call, not breadthfirst's alone — taken into item 61. The two fixes stay as landed code.

125.5 — circle

Fixtures. A 40-node clustered graph (four clusters of ten, half the pairs inside a cluster joined, twelve cross-cluster edges, insertion order shuffled) with and without a sort mapping by cluster; a ring of twelve labelled nodes at three label widths (10, 40 and 80 characters, 30 px bodies); em-web's components under packComponents (the giant component as one ring).

Baseline (commit 9339dfbc):

run crossings gap median ratio note
clustered, no sort 1,375 36 1.18 insertion order
clustered, sort: { data: 'cluster' } 318 36 1.18 the attribute-grouped ring
12 labels × 10 chars, default 101 radius 414 px (the box's)
12 labels × 10 chars, condense 15 radius 193 px
12 labels × 40 chars, either 62 radius 448: the 346 px label boxes set the chord
12 labels × 80 chars, either 125 radius 701
em-web packed 6.06 M 21 0.53 63 ms; area 32.1 Mpx²
em-web packed, labels 6.06 M 34 0.46 64 ms; area 81.7 Mpx²

What the pictures show (plan/pictures/rnd0125/125.5/em-web-circle-pack-9339dfbc.png). The giant component is a 187-node ring of 6,899 chords — a disc of grey, which is what a circle of a dense component is and not a defect; the ring itself is even and its sort-by-NES order would put the reds together (not run here). The second row is the shelf packer's row-height waste the first sitting named (125.9): a 60-node ring beside 5-node rings, the row as tall as the big one. The singleton rows at the bottom are right.

Findings.

  • The ring fills the box by default — a six-node ring on a 600 px viewport is a 270 px ring — and only radius or avoidOverlap's growth changed that. condense: true (designed with 125.3) makes the ring the tangential radius at avoidOverlapPadding: 193 px for twelve short labels against 414. Where the labels are wide the chord rule already sets the radius and condense is the same picture.
  • The attribute-grouped order works: the sort mapping cuts crossings 4.3× on the clustered graph. A crossing-minimised order (AVSDF, declined by 122) would cut further; the sitting decides whether the picture asks.
  • startAngle (3π/2, the top), sweep (a full circle less one gap) and clockwise read as v3's; nothing to change.

What changed on disk. condense on circle (with 125.3), a spec in test/layouts.mjs (the condensed radius under 100 px against the boxed control's 270, every pair at the padding, a smaller padding a smaller ring); README and changelog entries shared with 125.3. No default changed.

For the sitting. Whether condense should be circle's default (the ring by its nodes, the viewport by fit) — the v3 picture fills the box, and every ring the apps draw is a packed component whose box is its own, so the apps would not change. Whether an AVSDF order is worth a sub-round now that the clustered fixture measures it.

Maintainer review: pending. Open the page on em-web, Circle, Pack components on; the picture above. Decide the two calls.

Maintainer sitting, 2026-09-15 (first pass, from the desk). condense as a default: deferred — the geometric layouts need one consistent rule (item 61). AVSDF, the crossing-minimised ring order round 122 declined: reconsider it — logged as item 62.

125.6 — concentric

Fixtures. em-web with concentric mapped to degree (the default) under packComponents, with and without labels; a synthetic graph with one heavy level (a hub, six mid nodes, sixty leaves — the leaf ring holds 60 of 67 nodes).

Baseline (commit 9339dfbc):

run ms gap median / p90 ratio area Mpx² note
em-web packed 68 22 / 50 0.55 13.61 crossings 5.5 M; fill 0.067
em-web packed, labels 73 35 / 60 0.48 26.39 fill 0.061
heavy leaf level 20 / 26 0.68 r(mid) 46, r(leaf) 540; no overlap
heavy leaf level, equidistant 75 / 87 2.50 r(leaf) 1,127

What the pictures show (plan/pictures/rnd0125/125.6/em-web-concentric-pack-labels-9339dfbc.png). The giant component's rings by degree, the hubs at the centre, the labels held apart at the outer rings; the same shelf waste in the second row as every packed layout shows (125.9).

Findings.

  • Concentric is already the condensed ring layout: each ring at the smallest radius that clears its own nodes and the ring inside it, the gap minNodeSpacing (10) — the box is read only with avoidOverlap off. It is the model the 125.3 / 125.5 condense option copies, and its spelling (minNodeSpacing) is v3's; the sitting's call on one spelling across the four is whether minNodeSpacing should also exist on circle and radial as the gap, or avoidOverlapPadding on concentric.
  • equidistant spreads every ring by the largest ring step (v3's rule): with one crowded outer ring the inner rings, which needed 46 px, are pushed to that ring's 587 px step, and the picture is 2.5 node sizes airy where the default is 0.68. Not a defect — equidistant means equidistant — but the default is the better picture on a heavy level, and the doc should say why.
  • The per-component binning of 123 holds: each component's levels are its own, the singletons one ring each.
  • Label overlap on the outer rings: none, on em-web with the wrapped labels (the row above).

What changed on disk. Nothing in src/; the README's geometric-layouts entry records the sizing model.

Maintainer review: pending. Open the page on em-web, Concentric, Pack components on, labels on; the picture above. Decide the spelling question (with 125.3 / 125.5) and whether equidistant wants a doc note.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Specifics deferred; the rule given is that options should be generally consistent between the layouts, spacing options included — one spelling for the gap, the box's handling and the compacting (item 61). The equidistant note was not ruled on.

125.7 — grid

Fixtures. em-web's singletons under packComponents — EnrichmentMap's actual use of grid, the singleton rows — with labels; thirty labelled singletons on three viewport aspects (1400 × 1000, 1000 × 1400, 800 × 200), with and without condense.

Baseline (commit 9339dfbc):

run ms gap median / p90 ratio area Mpx² fill note
em-web packed 62 10 / 40 0.25 2.71 0.336 crossings 4.1 M
em-web packed, labels 76 26 / 42 0.35 6.42 0.253
30 singletons 1400 × 1000 77 0.50 1323 × 850 6 cols
condense 10 0.06 985 × 290 6 cols
30 singletons 1000 × 1400 44 0.28 956 × 1217 5 cols
condense 10 0.06 819 × 350 5 cols
30 singletons 800 × 200 10 0.06 1648 × 183 10 cols — overflows the box either way

What the pictures show (plan/pictures/rnd0125/125.7/). em-web-grid-pack-labels-9339dfbc.png and its close-up: each component its own grid, the singletons as rows (123's block), labels clear; the shelf's row-height waste between the rows of 7-node components and the rows of pairs (125.9's finding, seen through grid).

Findings.

  • rows / cols inference follows the box's aspect (v3's sqrt(cells × h / w)): 6 columns on a landscape box, 5 on a portrait one, 10 on a strip — and on the strip the cells overflow the box since a 30-node grid cannot fit 800 × 200 at label size; the overflow is the documented avoidOverlap behaviour.
  • condense is what the singleton case wants: the box-filling default spreads thirty singletons over 1323 × 850 px at half a node-size apart; condensed they are a 985 × 290 block at the padding. Under packComponents the singleton block is already condensed (123's block is sized by its members), which is why em-web's row reads 0.25 — so the apps' picture is right and the call is the standalone one: whether condense should default on when grid is the whole layout. v3's default is off.
  • avoidOverlapPadding (10) is the gap in the condensed picture and reads right at label size; the label-aware cell (114) sizes rows by heights and columns by widths.
  • The sort and position mappings (85.3) were not re-audited; the quality suite covers them.

What changed on disk. Nothing in src/; the README entry.

Maintainer review: pending. Open the page on em-web, Grid, Pack components on, labels on; the two pictures. Decide whether condense defaults on for a standalone grid.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Deferred into the consistent rule (item 61).

125.8 — preset and random

Audited for their contract, not their picture, on the quality suite's fixtures and a three-node probe.

Preset, as found. The map form and the function form; a node without an entry keeps its position, a node never positioned sits at the model's (0, 0); parents derive and locked nodes hold (114.3); fit wins over zoom / pan when both are given, v3's rule, so fit: false is what enables them; spacingFactor is ignored on both paths. The apps' CX2 round trip is elements[].position at load, which preset with no positions honours (only the viewport options apply). Two silent failures, both the half-positions a data import produces: a map entry of { x: 100 } wrote y: NaN into the store, and { x: 1, y: null } became y: 0 through the Float32 column. Fixed: a supplied position must carry a finite x and y or the run throws a TypeError naming the node, on the direct path and the finisher path alike; the spec runs four bad shapes through both and checks nothing was written.

Random, as found. Uniform over the viewport box or the given boundingBox, rounded to whole pixels; no seed, so two runs never agree — the one built-in that failed the audit's stability criterion by construction. Fixed: a seed (mulberry32) makes the scatter a function of the graph; omitted, Math.random as before. The spec pins same-seed equality, different-seed difference, the unseeded control and the box; a non-finite seed throws.

What changed on disk. src/layout/preset.mts (the check), src/layout/random.mts (mulberry32, seed), the option type, the specs, the README's contract note, the changelog, the throw gate (both guards covered). Types rebuilt.

Maintainer review: pending. Nothing to look at on the page; the sitting is the two calls: whether the throw on a half position is the right severity (the alternative is to keep the missing axis), and whether random should take seed from the page's seed box.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Accepted — the throw and random's seed stand. Caveat recorded as a design note: seeds should generally be per-operation, and how an app supplies them depends on its architecture — a constant seed passed on every layout run, or a global seed on the cy instance; this needs more consideration before a wider seed surface is designed.

125.9 — packing: the shelf's rows waste the room under a short component, and now fill it

The finding, reproduced. The maintainer's steps — the EM sample network (em-web, 144 components: 187, 78, 32, 15, 14, 14, 11, 10 … and 99 singletons) under radial with avoidOverlap, packComponents and tidy on — give the picture in plan/pictures/rnd0125/125.9/em-web-radial-pack-before.png: the 187-node disc (3883 × 4020) opens the first row, the 78-node component (about 1500 tall) stands beside it, and the two thirds of the column under the 78 is empty; the mid-sized components then open a second row, the singletons a third. shelfPack (src/layout/pack.mts) is a shelf: rows wrap at about sqrt(total area) × 1.25, a row is as tall as its tallest box, and every shorter box in it leaves a column of slack beneath — the limitation EnrichmentMap's vendored packer has. The measure for it is the packing efficiency: the component body boxes' summed area over the packed bounding box (1 is a tiling of the boxes). A scratch probe over the page's own sheet and elements (headless 1400 × 1000) read, before:

network, layout (options) components efficiency field aspect
em-web radial + packComponents + avoidOverlap 144 0.656 5858 × 5324 1.10
em-web radial … with labels 144 0.661 5949 × 5324 1.12
em-web force (the settle's re-pack, seed 1) 144 0.503 2438 × 1984 1.23
em-web pack 144 0.584 3054 × 2652 1.15
em-web grid + packComponents 144 0.483 1880 × 1440 1.31
npm-deps radial + packComponents 164 0.712 3267 × 2663 1.23
em-desktop force (seed 1) 147 0.621 2833 × 2406 1.18

reactome and ndex-large are one component each (the plan called reactome a forest; it is not), so they measure nothing here and were dropped.

The candidates, measured on the same boxes. A scratch harness took each layout's component boxes and re-packed them four ways: the shelf as shipped; the shelf with column stacking — a box that fits the room under an earlier box of the same row goes there — into the last column only, or first-fit into any column; and a skyline bottom-left packer (each box at the lowest point of the skyline where it fits, leftmost on ties), the guillotine's usual stand-in.

boxes from shelf stack, last column stack, any column skyline
em-web radial 0.664 0.830 0.830 0.871
em-web force 0.543 0.543 0.543 0.671
em-web grid 0.483 0.513 0.513 0.563
em-web pack 0.584 0.650 0.671 0.779
em-desktop force 0.621 0.770 0.788 0.751
npm-deps radial 0.712 0.887 0.887 0.760
npm-deps flow (flow's own pass) 0.836 0.836 0.836 0.836

Skyline wins on em-web's four rows and loses on em-desktop and npm-deps; column stacking wins or ties everywhere but em-web force and pack, where the force blobs' bounding boxes are all of a height and the slack is inside each blob's box, not under it. What decided it: skyline dissolves the rows — the singleton block, the group rows (121.1), breadthfirst's shelves (123.4) and the comparator's reading order are all built on rows — and its gain on force is on bounding boxes of irregular blobs, which a tighter packing of boxes does not make a tighter picture. Column stacking, first fit, went in.

What changed on disk. shelfPack keeps a list of the current row's open columns (where it starts, how wide the box that opened it was, how far down it is filled); a box shorter than the room under the leftmost column whose width holds it stacks there, spacing below, and otherwise the shelf runs as before. Boxes of like size never stack — the room under one is never a box plus a spacing — so the rows of singletons and of the small shapes read as they did. Stacking is off under a comparator (a fourth parameter, stack, defaulting to "no comparator"): a caller's componentOrder is read along rows, left to right — EnrichmentMap's singleton rows by score (121.4) — and a stacked box would read down a column; with stack forced on under a comparator the order still holds, read as rows top to bottom, columns left to right within a row, top to bottom within a column. packComponentBodies, packAnchors, the discrete layouts' packComponents, the pack layout, force's settle re-pack and flow's own pass all go through it unchanged. Specs: test/modules/layout-pack.mjs pins the stacking on hand-computed boxes (the stacked positions, the spacing respected, a box wider than its column or taller than the room opening a new column, like sizes never stacking, the comparator turning it off, stack: true under a comparator column-major), with the control that the packed height is the tallest box where rows alone would have wrapped; test/layout-pack.mjs runs the pack layout over a tall chain, a wide pair and two singletons — the singletons stack in the pair's column and the field is exactly the chain's height — with the control that under a componentOrder the first singleton continues the row, the second wraps, and the field is taller. src/README.md's packing section carries the mechanism and the numbers.

After.

network, layout (options) before after field after aspect after
em-web radial + packComponents + avoidOverlap 0.656 0.826 5636 × 4390 1.28
em-web pack 0.584 0.671 3056 × 2305 1.33
em-web grid + packComponents 0.483 0.513 1880 × 1355 1.39
npm-deps radial + packComponents 0.712 0.854 2913 × 2489 1.17
em-desktop force (seed 1) 0.621 0.745 2775 × 2031 1.37
em-web force (seed 1) 0.503 0.488 2303 × 1970 1.17
npm-deps flow 0.836 0.836

em-web force moves by noise for a second reason: packAnchors seeds the sim through the same shelf, so the seed field changed and the settle with it (its own boxes shrank, 2.43 → 2.21 Mpx²); the settle's re-pack had no column with the room either way. Pictures: em-web-radial-pack-{before,after}.png, em-web-force-{before,after}.png, em-web-pack-{before,after}.png under plan/pictures/rnd0125/125.9/ — in the radial picture the 32-, 15- and 14-node components now sit under the 78 beside the disc, and the small components' row takes the singletons in columns of three; in the force picture the 7-node rings stack two deep and the barbells' row continues in singleton pairs.

The system, audited. The largest component's centre is held through the re-pack as before (holdLargest, spec'd). The field's aspect on the landscape page (1.4) reads 1.17–1.39 after against 1.10–1.31 before — stacking makes rows shorter, so the square-tending width leaves a wider field. The row-width factor's candidates on em-web (stacking on): 1.0 gives 0.905 at aspect 0.67 under radial and 0.570 at 0.72 under pack; 1.25 gives 0.830 at 1.28 and 0.671 at 1.33; 1.5 gives 0.780 at 1.62 and 0.671 at 1.88. The orientation pass (121.2) is unchanged and its specs green. Stability: a one-node edit to a 2-node component of em-web and of em-desktop followed by a re-pack moved 0 other nodes (the grown box kept its slot). Cost: the pack layout on em-web 36.8 ms with stacking against 30.4 ms with rows alone (the column scan is per box per open column), em-desktop 43.5 against 44.9 — noise at 121's batch sizes.

Default recommendations (not changed in code). (1) Keep the row-width factor at 1.25 for a landscape viewport, or better, make the target aspect the viewport's (the packer knows neither today; the pack layout and packComponents could pass cy.width() / cy.height() in), since 1.0 packs tighter but portrait and 1.5 wider than the page. (2) componentSpacing 40 is not what makes the picture airy; leave it. (3) The sitting should decide whether the singleton block stacking into columns of two or three under a taller row (the radial picture's bottom row) reads well enough, or whether like-sized singletons should be kept to their own rows even without a comparator — a one-line rule (stack only boxes shorter than half the row) if not.

Maintainer review: pending. Look at em-web-radial-pack-before.png against -after.png (the column under the 78, and the singleton block's columns), em-web-pack-* (the EM keep-the-sim case), and em-web-force-* (the settle); run the EM combo entry (force by sign) to confirm its rows read as before, since a comparator turns stacking off; then call (3) above and the aspect question.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Deferred — review on the page, with the option controls, before a decision; the stacking shelf stays as landed code.

125.10 — the page, as the audit's instrument

Done first, as sequenced: everything after it was measured through it.

The live spacing slider. A second slider beside Spacing — which stays what it was, a spacingFactor for the next run — that rescales the current positions about their bounding-box centre as it is dragged: layoutConfig.scaledPositions(base, factor) over a snapshot taken at each layoutstop (and at load), applied through positions() rather than a preset run, because a layout's stop would re-base the slider to its own output and the factor would read 1 again. Dragging back to 1 restores the layout's output exactly. One apply per animation frame, so the 19.6k-node scene follows the drag.

The airiness readout. debug/airiness.js, one implementation loaded by the page and required by the suites: for every leaf node the gap to its nearest neighbour's box (the larger axis separation, 0 when touching or overlapping), found through a quadtree keyed by box centres with the extent of the boxes under each quad as the pruning bound; for every edge the gap between its endpoint boxes; each summarised as median / p90 / max, and the headline ratio — the median nearest gap in median node sizes, 1 meaning the typical node's nearest neighbour is a node-size away. The readout refreshes at every layoutstop, every slider move and the labels box. test/modules/airiness.mjs pins the quadtree against a brute-force twin on random fields of 3 to 2,000 boxes (gaps equal everywhere; the neighbour equal where the gap is unique), the summaries against hand-placed boxes, and the control: a field scaled 3× about its centre reads more than twice the gap. The same probe is the audit script's gap columns and the quality suite's "not too airy" rows.

Edges coloured as their source node on the reactome, npm-deps and workflow scenes: a source-band derivation in debug/fixtures.js copies each edge's source band onto the edge at load (a mapper cannot reach across an edge), and the workflow sheet maps line-color and the arrow colour from it with the nodes' ordinal scale; the generated networks now run through derive as the fetched ones do. A rank's fan-out reads as one colour through the gap, which is also what the reference tangled tree does.

The baseline script. benchmark/layout-audit.mjs (npm run benchmark:layout-audit -- --network <id> --layout <name> [--opts json] [--labels] [--json file]): one layout on one of the page's networks, headless at the page's size with the page's own sheet and elements, printing overlaps, straight-line crossings, area and fill, the gap columns, the edge gap, the per-node gap to its nearest DAG parent with the worst node named (the IRAK1 column), stability across a re-run and the time. Every table in this round is its output.

The quality suite. A "not too airy" describe: every layout's default picture on the fan and tree fixtures pinned under a per-layout ceiling on the ratio (grid 2.5, breadthfirst 3.5, the rest 3.0 — the measured values at the round's start rounded up, a bound and not a target), with the control that spacingFactor: 3 reads at least twice the ratio at 1.

Maintainer review: pending. Open any network, run a layout, drag Live spacing and read Airiness; open reactome under flow for the source-coloured edges.

Maintainer sitting, 2026-09-15 (first pass, from the desk). Findings, not an accept: the page should expose more — perhaps all — of each layout's options, flow's direction first, with an option shown only when a compatible layout is selected; in general the debug UI should show more of the option surface. Taken as sub-round 125.11 below.

125.11 — every layout option on the page

Raised by the first sitting pass; done the same day, since the page sittings for 125.2 and 125.9 wait on it.

What the page has now. An Options panel under the layout boxes, generated for the selected layout from a table in debug/layout-options.js: the layout's own options first (flow's direction, nodeSep, rankSep, edgeSep, layering, thoroughness, minLength, edgeWeight, acyclic, cycleRemoval, rankConstraints; the ring layouts' angles, sweeps and radii; grid's rows and columns; radial's roots, weight and levelSpacing; force's forces, iterations, threshold, init and constraints; the packing options on the five discrete layouts), then the plumbing every layout takes (fit, padding, boundingBox, animationDuration, animationEasing, zoom, pan). A field's type is the option's — a number input, a checkbox, a select, or a JSON field for mappings and id lists ({"data":"cluster"}, ["R-HSA-168256"], {"min":["a"]}) — and an empty field sends nothing, so the run takes the library's default and a run at defaults is the library's defaults. The boxes that already spell an option (Animate, Live, Infinite, Seed, Avoid overlap and its mode, labels, Pack, Tidy, Spacing, Preset's snapshot, the EM combo's grouping) keep it; the panel leaves those keys out (OWNED). Options no field can spell — callbacks, transform, animateFilter, grid's position function, concentric's levelWidth, the counterclockwise alias — are EXCLUDED with the reason. Below the panel, a readout of the exact cy.layout({...}).run() call Apply will make, refreshed on every change, so a sitting's finding can be pasted into an issue or the console. The seed box now drives random as it drives force (125.8's sitting).

The gate. test/modules/layout-options-panel.mjs reads every layout option interface in src/public-types.mts as text and holds the table to it: every member is in the table, owned or excluded, and nothing in the table is unknown to the interface (the control strikes nodeSep and watches it reported); the table's defaults are checked against the library's resolved defaults where a layout exposes them; read() sends only what differs from the default, parsed by type, and names the option when a field does not parse.

What changed on disk. debug/layout-options.js (new), debug/index.html, debug/layout.js, debug/layout-config.js (random's seed), the module spec, the rendering note, the changelog.

Maintainer review: pending. Open any network, pick Flow, set direction to rightward and read the call; pick Radial and set roots. The page sittings for 125.2 and 125.9 can now run.

The first sitting pass (2026-09-15)

The maintainer took the ten sub-rounds' questions one at a time, from the desk — by experience and logic, on the questions that were clear at once, without opening the page; the page sittings, with the fuller option controls the pass asked for, are still to come, and every "deferred" above means exactly that. What the pass decided:

  • Round-level. Sittings are recorded here, under each sub-round (not one file per sub-round). A default change accepted in a sitting ships as accepted, with its changelog entry, rather than waiting for the round to land whole. The 125.8 question (fold into 125.7) was moot — it ran on its own and is the one sub-round signed off.
  • Signed off: 125.8. Decided in part: 125.3 (the roots; shipped). Deferred to the page: 125.1, 125.2, 125.4, 125.5, 125.6, 125.7, 125.9, and 125.10 with findings.
  • Three pieces of work raised, in the order the sittings need them:
    1. Sub-round 125.11 — the page's option surface. Expose more, perhaps all, of each layout's options on the debug page, flow's direction first, each option shown only when a compatible layout is selected. The page sittings for 125.2 and 125.9 wait on it.
    2. Item 61 — one consistent option surface across the layouts: spacing, compacting and the bounding box. The maintainer's framing: v3's box was a constraint; v4's default might read it as a hint of the available space, with an explicit constrainWithinBounds-style option saying when it binds; the gap's spelling and condense's default are decided once, for every layout that has them. Absorbs the deferred calls of 125.4, 125.5, 125.6 and 125.7 and the round file's open node-separation spelling.
    3. Item 62 — AVSDF, reconsidered. The crossing-minimised ring order round 122 declined, now that the clustered fixture measures it (the sort mapping alone cuts crossings 4.3×).
  • A design note for later: seeds should be per-operation, and how an app supplies them — a constant per layout run, or a global seed on the instance — depends on the app's architecture; more consideration before a wider seed surface is designed.

Shader minification

Status: planned; round 126. Originally drafted as provisional round 999 on feature/shader-minification. Renumbered to 126 when integrated into v4, after round 125; the filename, subsection numbers and generated index now use the assigned number.

Problem and direction

Round 52's scripts/wgsl-minify.mjs already removes comments and whitespace from wgsl-tagged templates. Interpolations remain opaque, so the transform cannot safely rename identifiers across fragments or eliminate unused declarations from a complete shader module.

Assemble complete WGSL modules at build time, then pass them through a WGSL-aware minifier before embedding the resulting strings in the JS bundles. Keep source generators readable and preserve one source of truth for constants shared with CPU code. The minifier is a build dependency; consumers should not load or run it.

126.1 — inventory and baseline

  • Enumerate all shader producers and consumers, including rendering, picking, culling, mappers, tweens, force layout and GPU algorithms.
  • Classify each interpolation: fixed numeric value, structural constant such as an array size, shared fragment, generated function/identifier, or value that actually depends on runtime configuration.
  • Record current shader bytes and final bundle bytes, both raw and gzip/Brotli-compressed, plus build time. Use the same bundle and compression settings throughout the comparison.
  • Identify host-visible entry-point and override names, binding indices, buffer layouts and any shader reflection dependencies that must survive minification.

126.2 — assemble shaders and evaluate minifiers

Evaluate these candidates against the real shader corpus; their published capabilities are leads to verify, not evidence of compatibility here:

  • miniray: first candidate for npm/WASM build integration, identifier shortening and dead-code elimination; its documented defaults preserve entry points and override names.
  • wgsl-minifier: Naga-based alternative with simple dead-code elimination, identifier shortening and text minification.
  • Nagami: compare its compiler optimizations, including folding and inlining, if worthwhile. Leave lossy float-precision reduction disabled.

Run existing generators during the build wherever their inputs are known. Templates need not be removed from authored source if they resolve before minification. Do not minify interdependent fragments independently with inconsistent name mappings.

For genuinely runtime-dependent values, use uniform buffers for values that change between draws and consider WGSL override constants for pipeline-specific scalar configuration. Uniforms cannot replace generated code or compile-time array sizes; overrides also have context restrictions and are not a universal substitute for WGSL const. Preserve existing specialization where it matters, and measure any resulting runtime cost.

Choose the tool and integration on correctness, compressed bundle savings, build cost, maintenance and licensing. Pin the chosen version. Record unsupported syntax and failures explicitly. Compare full-module expansion against the existing shared-fragment representation: duplicated shader text can offset minification gains. If no candidate improves the final bundle reliably, retain the current transform and record the evidence.

126.3 — integrate and verify

  • Integrate with the existing rolldown build, generating outputs through project scripts. Apply the chosen transform in development builds too, so browser tests exercise the shipped shaders.
  • Preserve host-visible names or generate consistent host name mappings; preserve binding indices and buffer layouts. Fail the build on shader transformation errors rather than silently shipping a partial result.
  • Add focused regression coverage for cross-fragment references, generated identifiers, entry points and override configuration. Run the controls required by docs/agents/testing.md.
  • Run npm run -s verify, npm run build, npm run build:types, npm run -s test:modules:quiet, npm run -s test:runtimes:node:quiet and npm run -s test:node:quiet. Exercise affected compute paths and run npm run -s test:playwright:quiet for shader compilation, rendering and pixel parity. Open the debug harness with npm run watch.
  • Report measured raw and compressed bundle deltas and build time; investigate shader compilation or runtime performance regressions.
  • Update the renderer/build notes and maintained scope documentation when implementation lands. Close the round with the measured record and the required executive-summary rewrite.

126.4 — future WebGL renderer fallback: glslx

Use glslx for build-time GLSL minification in the future WebGL renderer fallback. Carry this requirement into the fallback work scoped by round 73 (the WebGL2 fallback, scoped). Validate glslx against the fallback's actual GLSL ES version and shader features before integration, and preserve or map host-visible shader names. Apply equivalent compressed-size measurements and browser/pixel-parity gates. This round records that tooling direction; implementing the WebGL fallback remains separate work.

String keys become constants

The maintainer's directive, 2026-09-15: clean up hard-coded string use. Style property keys and store column keys should be reused constants instead of hard-coded and repeated strings, and probably the same for the reserved data keys (id, parent, source, target).

The census, before

Three vocabularies are spelled as string literals at every use site, and each is typed well enough that a typo already fails to compile — which is why nobody noticed the repetition. What the literal form still costs: a rename touches every site, a search for "who reads this column" has to know the spelling, and the renderer's worker protocol, the mirror and the tween paths each carry the same string with nothing tying it to the contract but the union type.

vocabulary where it is declared literal sites files
column ids ('node.position', …, 38 columns) ColumnId, a string-literal union in src/contract.mts ~700 25
style property keys (173 names) nowhere once — the applyProp switch, NODE_READ/EDGE_READ, MAPPABLE, the readers, the config sets each spell them again ~800 in src/style.mts, ~15 elsewhere 6
reserved data keys (id, parent, source, target) nowhere 44 (about half in comments) 7

Group names ('nodes'/'edges', 744 sites) are the same shape and are not in scope: they are a two-member typed discriminant that reads best as the literal, and the maintainer's examples did not name them. Logged as a call, not decided here.

127.1 — the column ids: COL

  • src/contract.mts declares COL, one as const object holding every column id with the JSDoc that used to sit on the union member, and derives ColumnId from it — so the union cannot drift from the object, and COLUMN_SPECS is written against COL rather than spelling the ids a second time.
  • Every 'node.*' / 'edge.*' literal under src/ outside the contract becomes COL.NODE_* / COL.EDGE_*. Scripted, then the diff read; the worker protocol and the mirror keep carrying the string value, which is the point of a constant.
  • Specs under test/ keep the literals: a test that spells 'node.position' pins that COL.NODE_POSITION still resolves to it, and the wire format is what the tests are for.

127.2 — the reserved data keys: DATA_*

  • DATA_ID, DATA_PARENT, DATA_SOURCE, DATA_TARGET beside GroupName in src/contract.mts (the columnar converter, the store and the style engine all already import the contract; element-defs would be a new edge for the store). EndKey names the source/target pair.
  • The comparisons in collection.mts, columnar.mts, graph-store.mts and style.mts read the constants. Typed property access (data.source) is not a string and stays.

127.3 — the style property keys: PROP

  • A new src/style-props.mts holds PROP, one as const object of every property v4 accepts — node, edge, core, compound and transition — with StyleProp derived from it. The list is taken from the engine itself (the applyProp and resolveCoreProps switches, the read sets, MAPPABLE), so the table's first version is a census, not a guess.
  • Every property-name literal in src/style.mts, animation.mts, collection.mts and render/mapper-runtime.mts becomes PROP.<NAME>: case PROP.BACKGROUND_COLOR:, [PROP.LINE_COLOR]: as an object key, PROP.OPACITY in a set. Multi-word names are scripted; the eleven single-word names (width, color, opacity, …) collide with channel kinds and layout options and are replaced by hand, in the style engine only.
  • Property values ('round-rectangle', 'match-line', 'triangle-list') are out of scope: the shape and arrow vocabularies already map through SHAPE_NAMES-style tables to their ids, and the WebGPU descriptor strings are the API's.
  • Error messages and comments keep the human spelling.

127.4 — the gate

  • test/modules/string-keys.mjs scans src/ with the comment- and string-aware walk import-graph.mjs uses and fails on a column-id literal outside the contract, a reserved-data-key literal in a comparison outside the contract, or a multi-word style property literal outside style-props.mts. Each rule runs its control on a planted line, per docs/agents/testing.md.
  • It also pins the tables to the engine: every COLUMN_SPECS id is a COL value and vice versa; PROP has no duplicate values; every PROP value normalises to itself.

127.1 — carried out (2026-09-15)

COL holds the 38 ids with the union's member docs moved onto the members; ColumnId is (typeof COL)[keyof typeof COL]. A scripted pass replaced 622 literals in 25 files — the walker is comment-, string- and regex-aware, and the first version was not regex-aware: src/style.mts came back with zero replacements because a /^url\s*\(\s*['"]?…/ on line 1579 read as an unterminated string and swallowed the remaining 8,300 lines. The count of files touched is the control on such a tool: 24 of 25 was the tell. Thirteen sites were type positions (id: 'node.overlay' | 'node.underlay') and became typeof COL.…; one new Set([...]) that had inferred Set<string> now inferred a narrower union and needed Set<ColumnId>. The three tween pseudo-columns became TWEEN_COL in animation.mts. npm run -s verify: green, 2,719 specs.

127.2 — carried out (2026-09-15)

DATA_ID, DATA_PARENT, DATA_SOURCE, DATA_TARGET and the EndKey type sit beside GroupName in the contract. 34 comparison sites in five files read them — the sidecar's reserved-key skips in the converter and the store, the collection's data() getter and setter guards, the style engine's four key === 'id' first-class-id reads, and the end-label and arrow-fold loops that iterate [source, target]. What stayed literal, on purpose: prop.startsWith('source') is a property-name prefix test, and TAXI_TRACK_NAMES holds the values of taxi-track, which happen to share the spelling.

127.3 — carried out (2026-09-15)

src/style-props.mts holds PROP, 173 names generated from the engine's own tables (the applyProp and resolveCoreProps switches, the twelve read/config sets, MAPPABLE), alphabetical within family. The 162 multi-word names went through the scripted walker (802 sites in four files), which learned one more rule on the way: a literal in object-key position — followed by : and preceded by {, , or indentation — becomes a computed key [PROP.X]:, while case 'x': keeps the bare member. The eleven single-word names (width, opacity, color, …) collide with ChannelKind, WriteKind and the layer-field switch, so they were replaced by syntactic rule in the style engine only — list members, case labels, defineReader arrays, .has()/.add()/.includes() calls, the norm === and m.prop === tests — and by hand in the other three files (69 sites). What stayed literal, on purpose: the overlay/underlay reader's switch (field) compares a stripped suffix, kind: 'color' is a channel kind, and dep(key, 'label') is a dependency class. npm run -s verify: green, 2,719 specs.

127.4 — carried out (2026-09-15)

test/modules/string-keys.mjs: three tree rules (a column id outside a COL/TWEEN_COL member line; a reserved data key in an ===/!== comparison or a ['source', 'target'] pair; a hyphenated property name outside style-props.mts), five table rules (COLCOLUMN_SPECS; every key spelled from its value; the pseudo-columns disjoint from the real ones; PROP duplicate-free and already in normalizeProp's spelling), and the scanner's own controls — a planted literal in code, one in a comment / template / double-quoted string, one after a regex holding a quote (127.1's tool bug, kept as a spec), a division that must still read as code. The tree rules' control was run by hand: one literal planted per rule, three red rows naming src/layout/dims.mts:76, src/columnar.mts and src/collection.mts, then restored.

127.5 — what the tree's other scanners said (2026-09-15)

npm run -s test:node went red in 17 module specs on the first full run, all from two tools that read src/ as text and had assumed the literal form:

  • scripts/status/feature-inventory.mjs derives v4's style surface by regex over NODE_READ/EDGE_READ and the core switch — and found nothing once every member read PROP.X, so every feature row disagreed with an empty surface and the status-site plan failed behind it (15 of the 17). It now resolves PROP.X against the table's own text, still without importing the library; the literal form is still accepted. 171 names, as before (the two -relative-to names are throw-only and were never in a read set).
  • scripts/throw-coverage.mjs keys its allowlists by file:line, by design (round 37.1: an insertion above a site re-points the entry, and the gate is what notices). Two entries had moved — the SHAPE_MASK invariant in graph-store.mts by twelve lines, the export-scale guard in renderer.mts by two — and were re-pointed.

Neither is a defect the round introduced; both are the tools doing what their headers say they do. Recorded because the second full run is the one that counts, and because round 57.2's lesson holds again: a purely mechanical change to the sources is a free control on every source-scanning tool, and this one found the inventory reader's unstated assumption.

Verification (carried out)

  • npm run -s verify after each sub-round: green, 2,719 specs; the gate adds 16. npm run -s test:node, second run: green — 2,719 unit, 745 module, 24 soak, the throws gate and lint.

  • The style micro-measure the plan asked for (headless, 2,000 nodes / 1,999 edges, a 22-prop sheet; medians of 7, three runs per tree, pre-127.3 in a worktree beside the tree):

    sheet compile + apply numericStyle() × 6k style() × 5k
    before 6.94 / 7.02 / 6.86 ms 0.57 / 0.62 / 0.59 ms 0.60 / 0.61 / 0.61 ms
    after 9.50 / 7.23 / 6.98 ms 0.94 / 0.61 / 0.58 ms 0.79 / 0.62 / 0.58 ms

    The first "after" run is the cold tsx import (it was the first process to compile the tree); runs two and three sit inside the before band. As expected: the sheet compiles once per group, and the read path plans per raw name and never touches the switch.

  • The bundle grew by 1%, measured against the pre-round commit built in a throwaway worktree with the same rolldown config:

    build/cytoscape.min.js minified gzipped
    before (988cca81) 852,759 B 233,456 B
    after 861,544 B 236,179 B
    delta +8,785 B (+1.0%) +2,723 B (+1.2%)

    The cost is the table itself (173 property strings the sources used to carry inline, now carried once and referenced) plus COL.X / PROP.X member accesses, which the minifier mangles the object name of but not the property name — PROP.BACKGROUND_COLOR ships longer than 'background-color'. Recorded as the round's price; whether to spend 2.7 KB gzipped on a spelled-once vocabulary is the maintainer's call, and the alternative (a build step that inlines as const members) is a build-tool change this round does not make. The summary's bundle row had been stale since well before this round (it read 691 / 185 KiB); it is re-measured now.

  • Style parity, re-measured by the inventory reader's own method (v4 read registries ∩ v3's declared names): 159 of 291. The summary carried 161 from round 85.4 with no method on the record; the reader's number is the one the status site publishes.

  • The worktrees were removed (git worktree list shows one tree).

npm run -s verify per commit; npm run -s test:node:quiet and npm run build before the round closes. A style-compile benchmark row before and after, since applyProp gains a property load per case — the sheet compiles once per group, not per element, so the expectation is noise, and the number is recorded either way.