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, whichAGENTS.mdrequires 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 inplan/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'splanfield tolanded. Runnpm run plan:indexafter adding or renaming a section, andtest/modules/plan-record.mjsfails 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 tonote. So the rule has no exceptions left, and three gates hold it: aplanfile may not record its own landing (a self-declaration, or a checklist with nothing left open), no round may be filed as both aplanand alandedrecord, andplan/INDEX.mdpublishes 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 intonpm packand turnedtest:modulesred, because it holds a whole second copy of the repo. Remove a landed round's worktree when the round closes.EXECUTIVE_SUMMARY.mdis 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-rotationwere 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/sortByZIndextwo 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 inEXECUTIVE_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.
arrow-scaleis quantized to 1/16, and it is not only readback (round 56, 2026-08-07).edge.arrowShapesstores 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'sgapandspacing, so anarrow-scalethat is not a multiple of 1/16 is wrong in geometry as well as in whatstyle()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 inrouting-ledger.mjswith 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-scaleis 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.v4's edge underlay/overlay band is
width + 2 × paddingwide; v3's is2 × 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 againstv3/src/extensions/renderer/canvas/drawing-edges.mts—drawEdgeOverlayUnderlaysetscontext.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 atwidth + 2 × padding, recorded insrc/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 onwidth + 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 theedge-layersgolden 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.
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.
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 differencesrc/README.mdrecords, 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 ofp-child,p-grandchildandchild-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 questionsrc/README.mdhad been describing incompletely, and it agrees with the direction already set — round 54 tightens compound bounds further, and item 16 ratified the tighter compoundfit(). Not yet isolated, and deliberately not claimed: the larger divergences in the same scene (parent-parent16.04 px,leaf-parent8.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.sourceEndpoint()/targetEndpoint()answer the node centre on a straight edge (round 55, 2026-08-06). v3 answers the node boundary (itsrs.arrowStartX/Y, the spacing-shortened arrow point).v4's
Collection._endpointPointfalls 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.straightEndpointAtresolves 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'sspacing, non-zero only forteeand 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'sarrowsscene: 14 diverged fields to 4, and the residual is exactly that spacing term —circleby 9.880383 (getArrowWidth(5, 1.5) x 0.15) andteeby its constant 1.0, with the other five heads clean. Thebase,familiesandbundlesscenes went fully green and lost theirtest.fail()markers. One prediction in that plan was wrong and is corrected here: the Playwright probe atrenderer.spec.js:4516-4546was 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. Onlytest/collection-dimensions.mjsneeded rewriting.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-outerase 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 supportarrow-fill: hollowon 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.
edgeHitsBoxkeeps 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 atgraph-store.mtsnow 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.
Split the big implementation files, v4's way (raised 2026-08-07).
style.mtsis 7.9k lines,collection.mts5.8k,store/graph-store.mts5.0k,render/shaders.mts4.3k,core.mts3.1k. v3 split its collection intov3/src/collection/*(traversing, data, dimensions, events, …); v4 has already done the same thing once, forsrc/algorithms/— one file per algorithm over a sharedalgo-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_APIis a file list, andauditFilewalks class bodies; a split done v3's way — assigning onto a prototype from several modules — would make every moved member invisible to coverage,@param,@returnsand@throwsat 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, anddocs-generatehas a spec that will say so. git blameand 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.
- The audits read files as text.
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
betweennessCentralityis contracted CPU-only (Brandes over weights needs a priority queue) and is n independent Dijkstras — embarrassingly parallel across sources. A pool of plain workers (Nodeworker_threads/ browserWorker) 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-65executorcontract ('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".
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.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 onparity.htmland 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.The benchmark coverage audit graduates (raised 2026-08-19).
bench-coverage.mjsstays 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 —AnimationandAnimationManagerinternals:gpuEligible,settleGpu,demoteGpuAll,onCompacted),style.mts(22 —StyleEngineinternals:applyBulk,refreshMapped,paintInputs,arrowBase), andevent.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: fiveCollectionmembers 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.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
throwbranches — 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 overnpm testis days; per-file scoping with narrowed test selection is the design problem, and the tool must respect the.mts-via-.mjsimport convention. First measurement: mutation-survival rate onsrc/style.mts(the largest file, 7.9k lines) undertest:jsalone — the survivor list is the finding, whatever the tooling verdict.A renderer soak tier (raised 2026-08-19).
test:soakchurns 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.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 (thedebug/generators are the seed) that bisects to first failure per subsystem, documents the ceiling insrc/README.mdas 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.The VRAM budget, and failing gracefully (raised 2026-08-19). Device loss is tested; allocation failure is not — nothing defines what a
createBufferfailure 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.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-motionhonoured 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 overdebug/index.html(predictably: silence), and an inventory of what Cytoscape Web built or skipped for a11y.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.
Lasso selection + public spatial queries (raised 2026-08-19). v3.30 added lasso; v4 has box selection only (no
lassoanywhere insrc/). 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: whatcull.mts/cpu-pick.mtsalready provide toward polygon containment, and the cost of point-in-polygon over 100k nodes at a realistic vertex count.Compound drag-and-drop reparenting (raised 2026-08-19). The
compound-drag-and-dropextension is a standing ecosystem bolt-on, and v4 owns everything it needs: the hierarchy lives instore/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).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.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.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.
A v3→v4 codemod (raised 2026-08-19).
MIGRATING.mdis 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.Typed element data (raised 2026-08-19). The shipped
d.tstypesdata()asany-shaped; the ask is generics —cytoscape<NodeData, EdgeData>( … )— flowing throughdata(), the mapper DSL's field references, and event payloads. Two risks make it measure-first: whether thebuild:typespipeline (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 throughnpm run build:types— if generics survive todist/cytoscape.d.tswith hover docs intact, the round is real; if not, the round is first a build-pipeline round.Framework bindings, React first (raised 2026-08-19).
react-cytoscapejsis 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.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.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.
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,
edgeLengthand 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 (theflowlayout — 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.*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.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
createImageBitmapdecodes 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
startForceacross 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 nostartForce, 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-facelabels 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 workerFontFaceSet.loadspike proving the face actually applies to OffscreenCanvas 2D rasterization in each engine.
- Background images in the worker. The proxy zeroes the
image count and emits one loud error event; nothing is
drawn. The build-out is
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 theinit: '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:spectralSeedis 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 afterspectralSeed, held in memory and flushed at exit — since the inputs are already known to be constant and it is the effect that goes missing.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-90and 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 listis one line), and all four round branches are fully merged intov4—round-90-api-cleanup(8b992283, 0 ahead / 30 behind),feature/round-86-worker-renderer(ac2d7d8f),worktree-plan-round-numbering(b87f9812) andworktree-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 -don those four names is the whole action, and the tips are written here so it is reversible from this file alone.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 undersrc/algorithms/— a--repeatfor 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 inmeta.adapterwould give the next such step a suspect. (e) Added 2026-09-04 (round 117): every render-bench--layoutrow 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--layoutrun 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.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
avoidOverlapthe 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)boundingBoxon 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 orhide()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.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 watchableanimateLiverun 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.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.mjsare the gates that were missing.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 throughforce's settle andflow's own pass;breadthfirst,radial,circle,concentricandgridplace 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 withgridfor want of this. The shape:packComponents: trueon the five discrete layouts (run per component, shelf-pack the boxes, group and order as force does), and apacklayout 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 frompackalone against 121's force-run rows. Taken by round 123 (2026-09-09), on the maintainer's four calls: the option ispackComponents, on all five discrete layouts, with the four shared spellings; thepacklayout; 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 andtest/layout-pack.mjsare the gates; the em-web pictures are on the round. Closed.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: taxievery 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: autotakes 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 trackedflow-taxirow 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, thetaxi-trackspelling,sourceas 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 fromflushDerived; 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) andedgeSep; 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.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.1force, 125.2flow, 125.3radial, 125.4breadthfirst, 125.5circle, 125.6concentric, 125.7grid, 125.8presetandrandom, 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-inclusiveavoidOverlapbroken 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;condenseon 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 thecy.layout()call as a readout. Open on the page sittings.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'sminNodeSpacingrings, circle's and radial's box-filling rings grown byavoidOverlap), breadthfirst stretching every rank to the box, flow spacing bynodeSep/rankSep, and the gap spelledavoidOverlapPadding,minNodeSpacing,nodeSepandcomponentSpacingby 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 explicitconstrainWithinBounds-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 throughbenchmark:layout-audit, area and gap columns before and after, so the sitting decides on pictures and numbers.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
sortmapping 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, thesortorder and an AVSDF order computed offline, before any spelling is designed (the natural one is a value ofsort).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
PROPandCOLbut 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 ofas constmembers (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.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: thelabelstyle prop accepts the single mapperdata(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
highWaterinstances, 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 +resizedflag;takeDelta()returns-and-clears;onInvalidate(cb)fires ≤ once per microtask. Extended withtouch()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; cachedid()/group()stay readable after removal (needed forremoveevents). - Events: single core emitter (v3's
src/emitter.mtsunmodified 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.
- No event namespaces: v4 drops namespaced events (
- 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 butgrid. - 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;.readyrejects on null adapter; premultiplied canvas; dead instance +errorevent 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 onresizedwithdestroy()deferred behindonSubmittedWorkDone(), 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()reportscpuFrameMs(encode/submit cost, ~0.1 ms by design) separately fromgpuFrameMs(real frame GPU time via the optionaltimestamp-queryfeature — 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/renderScaleMaxband (defaults 0.5/1), quarter steps driven by mediangpuFrameMsover ~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; pureScaleController, 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;labelMinPxoption hard-culls unreadably small labels in the glyph cull predicate.Whole-graph fit fast path (added): no-arg
fit()/center()compute bounds viaGraphStore.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 throughcy.elements(). ndex-x-large: 235 → 15 ms, identical zoom/pan. Future edge geometry (bezier, arrows) extends the edge term in the store scan andCollection.boundingBoxtogether. (Since superseded: round 12a extended the store scan's edge term with the conservative curve-hull bound and gaveCollection.boundingBoxthe exact lazy curve tier.)
Integration
- devDep
@webgpu/types; tsconfig"types": ["@webgpu/types"]. - rolldown:
build/cytoscape.umd.js(globalcytoscape) +build/cytoscape.esm.mjs; theFILE=umdwatch filter picks the gpu UMD up automatically (verified). - package.json:
exports["./gpu"], gpu bundles indist:copy,debuginwatch:sync. debug/: network/bg/LOD/labels URL params,?gen=NxMrandom-graph generator, best-effort constant-prop conversion of the v3 fixture styles, FPS/counts/upload-bytes/glyphs/pick-latency overlay.- playwright: the
rendererproject (namedwebgpuuntil round 42) —channel: 'chromium'new headless +--enable-unsafe-webgpu --enable-unsafe-swiftshader, loading viahttp://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 withnavigator.gpuremoved; 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
Compute-shader culling +— done (see the culling/LOD section above).drawIndirectBatch the one-time glyph build— dead 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 (runtimestyle()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.Bulk element load— done (the actual init bottleneck). Profiling the ndex-x-large load (28.6 MB JSON, 19.6k nodes / 465k edges, ~960 ms end to end) showedcytoscapeinit at 662 ms — dominated not by the columnar model but by eager per-element handle materialization (Collectioninterning for 484k elements the loader never touches), a per-elementaddemit 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 convertercytoscape.toColumnarElements(json)— init 236 → 80 ms, and ~76 ms with a prebuilt payload (what fetching a binary format would enable;JSON.parseitself 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 byoptions.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.— done, columnar like everything else: per-(group, key) adaptive columns (f64 + presence for numbers, dictionary-encoded strings, plain-array fallback),data()sidecarele.data()with v3 semantics (immutable id/source/target,dataevents), ingest from defs, columnardata:columns and the wire (v2 data blocks — f64 and dictionary indices deserialize zero-copy). Labels now take anydata(key)mapper and refresh on data writes.Perf round 2 (post-load-path)— done: (a) grid layout got a slot path (no handles; bulksetPositions; 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).Cheap wins remaining: arrows, pinch zoom— done. 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)/zoomRangesetters,getFitViewport/getCenterPan(compute without committing),renderedExtent,size,centre. (getZoomedViewportskipped — internal in v3.) - Introspection/aliases:
instanceString,isReady(via a_readyResolvedflag),headless,styleEnabled,hasCompoundNodes,hasElementWithId,$id,mutableElements,window,options. - Events:
once,listen/bind,unlisten/unbind,pon;onRender/offRender. renderer(),forceRender()(renderer got a publicrequestRender),resize()/invalidateSize,makeLayout/createLayout.- Graph-level
data/removeData/scratch/removeScratch(+attr/removeAttr), plain objects on the core. - Interaction gating:
autolock/autoungrabify/autounselectify(+*Nodesaliases) andpanningEnabled/userPanningEnabled/zoomingEnabled/userZoomingEnabled/boxSelectionEnabled; all ctor options too.pan/panBy/zoomgate on the programmatic flags; the pointer gates drag-pan/wheel/pinch on theuser*flags and drag on grabbable+unlocked;autounselectifysuppresses 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/relativeComplementaliases,isLoop/isSimple,equal/equals,min/maxDegree/min/max{In,Out}degree/totalDegree.degree/indegree/outdegreeare singular first-element accessors (undefined when the first element isn't a live node), as in v3 — the whole-collection sum istotalDegree. - Dimensions:
renderedBoundingBox,renderedWidth/renderedHeight(+outer),renderedPositionsetter,shift/silentShift,silentPosition(s),midpoint/renderedMidpoint,source/targetEndpoint(+rendered; node-center approx),relativePosition,point/modelPositionaliases. - Data/scratch/json:
removeData(+attr/removeAttr), per-elementscratch/removeScratch(plain JS on the interned handle),json/jsons;once/pon/listen/bind/unlisten/unbind. - Flags:
selectify/unselectify,grabbable/grabify/ungrabify,locked/lock/unlock,grabbedgetter,show/hide/visible/hidden.FLAG_GRABBABLE/FLAG_LOCKEDadded; grabbable defaults on; def/ctor-levelgrabbable/locked.show/hideturned out to be pure LHF — the cull kernels and CPU pick already mask onSHOWN = ALIVE|VISIBLE, so togglingFLAG_VISIBLEneeded no shader change. move()for edges (re-endpoint in place viastore.moveEdge).
Not yet ported from the small list: — landed in round 6 (below).active/activate,
pannable/panify, inactive
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 rebuildingconnectedEdges);add+remove~32×;components~30×;intersection/difference~24×;collection()~14×; mutations (data/positionset) ~10–12×;map~2.6×; traversal 1.5–4×.Optimizations applied (each its own commit, all revealed by the benchmark): pure
#idselectors 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 agroup:slot:genstring; each collection lazily caches its membershipSet(sound —_refsis immutable), sosame/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/filterpreallocate and hoist thethisArgbranch;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, socompileFlagPlancompiles any flag-only selector to per-group(mask, want)tests andGraphStore.scanRefsIntoanswers 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), collectionfilter(selector)tests refs against the plan directly, the interned-handle pool wentMap→ 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 nothisArgis given, matching v3's semantics (thisis undefined, not the element) — rebinding the receiver per element viafn.call()cost ~2× at 20k.Verified at N = 2k/20k/200k (the focused
benchmark/materializers.mjssweep 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.mjssweep (whole-graph mutation round-trips vs v3 at 2k/20k/200k;BENCH_OPruns one group per process at 200k, where eight v3 instances exceed the heap). The sweep exposedeles.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, arequireBitfilter (selectable-only for selection), changed-index collection and one coalesced dirty span per group — now backs select/unselect and all_setBitmutators (show/hide,lock,grabify,selectify); (b) select/unselect skips restyle outright unless somecasecondition 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/partialpositions()write the position column directly (GraphStore.shiftPositions/setPositionsConst— no per-element handles, callbacks or Position allocations) and thepositions(fn)path reads previous coords off the column instead of allocating viaposition(). 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 setis 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, andsuccessors/predecessorsspawned 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_eleFromRefre-validation.neighborhoodpre-seeds the seen-set with its own elements instead of adifference()post-pass, andsuccessors/predecessorsis a raw slot BFS (no per-hop collections at all): 2k-node closure 92.7 ms → 352 µs (2.9× → ~725× vs v3). Verified bybenchmark/traversal.mjsat 2k/20k: the two residual v3 wins flipped (100-node-bandconnectedEdges1.2–1.5× loss → 1.3–1.5× win, bandsources1.1–1.3× loss → 1.3× win), and the rest widened —neighborhood2× → ~4×,outgoers/incomers~3.4× → ~4.5×, bandneighborhood2.5× → ~5.4×, bandroots~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 whyshiftcan be ~106× whileoutgoersis ~4.7×; the big traversal multipliers only appear where an algorithmic layer was removed (the per-hop collection machinery insuccessors).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.mjsreplays five composed traces with core listeners attached, at 2k/20k/200k (BENCH_OPone-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:_onDataChangedran 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_setDatacall), andrefreshLabels(slots)recomputes only the label sidecar, resolving the stylesheet once per selectedness likeapplyBulk(per-element fallback only under#idblocks); 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/pannablestates:FLAG_ACTIVE/FLAG_PANNABLEbits;activate/unactivate/active/inactive,panify/unpanify/pannablethrough the bulk flag path. v3 defaults (edges pannable, nodes not; per-def override); pannable overridesgrabbable()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 outermostendBatch, filtered to live refs; events keep firing; a sheet set mid-batch flushes as oneapplyAll. Renderer scheduling needed no deferral (the dirty tracker already coalesces per microtask), sonotify/noNotificationshave 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 viajson(true)), sheet, graph data, viewport, gating flags; element json gainedlocked/rawgrabbable/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.refsInBoxanswers 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 ascy.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/boxselectper 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;pstylestays internal-only in v3 and has no v4 counterpart).Batching— landed in round 6 with the v3 policy (defer style apply, keep events);notify/noNotificationsdeliberately have no v4 counterpart (the renderer is dirty-driven).Core— call made (round 10 planning, 2026-07-27): not in v4. Removed elements are terminally dead (see the design decision injson()import and elementclone/copy/restoresrc/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 ofcy.json()are permanently closed; re-adding from kept definitions is the app's job (exported element json round-trips throughcy.add()).Image export (— landed in round 9.6 (below) as the offscreen render + buffer readback path;png/jpg/jpeg/renderTo)renderToremains 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]/forEachfrom 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 (
— landed in round 6):selectionType+ box selection,active/activate,pannable/panifymultiClickDebounceTime(multi-click),eles.layout()/layoutPositions/layoutDimensions,boundingBoxAt(bbox at a hypothetical position),(closed 2026-08-01: z-index is dropped by decided design — see the design-sitting section below),sortByZIndex/zDepthpadding/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 columnardata()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.mtswas deleted and replaced bymatcher.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 inoff()), and ids go through$id/getElementById.cy.$()and string arguments to set ops/edgesWith/components/remove/fitwere 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#idblocks are gone; state is acasecondition (round 57.1:{ when: { selected: true } }and the rest of v3's state selectors), and v3's own:selected/:parent:selected/:activeblocks are entries in v4's default stylesheet that a user block replaces. The(ele) => propsfunction form was removed in round 8 (below): all per-element styling is declarative (caseconditionals,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-outinterpolate: 'srgb') with named schemes (viridis family, ColorBrewer ramps, category10/dark2) and multi-stop ranges. Missing/unmappable data →fallbackelse the channel default.domainomitted/'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;labeltakes 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). caseconditional mapper.{ case: [{ when: { data, gt/lt/eq/ne/in/... }, then }], else }— ordered clauses, conditions AND-ed within a clause, first match wins;whenreads any data key or the first-classid. The declarative replacement for(ele) => cond ? a : band 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 toCompiledMapper.keys.- The
(ele) => propsfn form removed.GpuStyleFnis gone; the sheet is props-only. The engine collapsed to one path (nodef.fnbranches in applyBulk/refreshMapped/labelChannels/setSheet, no fn-return throw,eleFordropped). Selection-dependent recolouring is intentionally gone (the accent ring is shader-drawn); id-based styling migrates tocaseondata: '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 deterministictick(now)for tests). Standard easings.Animatable:
position, nodeopacity,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.canDragconsultsisAnimating), 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, anowuniform 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.positionis 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.mtsruntime 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
fromTofor color (twovec4fper slot; sRGB per-channel to match the current CPU tween unless we deliberately unify on OKLab), foldedge.opacityinto 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()thenwidth()/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 explicitawaiton 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 aref(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-sizeand 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 — afull/high-scaleexport 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-relativerelativePosition/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/isBundledBezierand 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;segmentPointsand 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 geometry — boundingBox() 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 atier: paint (opacityboth 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-channelChannelWrites (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 WGSLarrayLength(&slots), not a uniform —queue.writeBufferis 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 animatingedge.opacityalso emits a color tween per arrow end tobase × toOpacity— identical math on both executors. The base comes fromStyleEngine.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.
targetis 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 mappedopacity, 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.
linearplus 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 andcubic-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'sspring(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 exactly1 − 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 whatlinear()costs.durationis 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.
compileEasingreturns{kind, bezier, points, durationScale, fn}; the CPU callsfn, 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()andlinear()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'stype.min/type.max; color bytes clamp on pack, with alpha clamped explicitly (aUint8Arraywrite 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-expohas 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, orstore.boundingBox()withfull) into a transient texture + depth target, culled by a dedicated export Frame uniform and export CulledGroups through the samedrawScenesequence as the screen;copyTextureToBufferreadback (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/maxHeightoverride scale,quality,output); every form resolves through one promise (sync readback is impossible on WebGPU); jpg defaultsbgwhite; 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
needsRedrawset), 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 CPUposition()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), andcompareToGoldenwith anUPDATE_GOLDENS=1regen 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
visualPlaywright project, pinned to SwiftShader via--use-webgpu-adapter=swiftshaderso 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.htmlloads 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/6visualPlaywright 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-familyas a constant, effectively global node style prop (defaultsans-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 routesstore.labelFont→ atlas reset (cache/pen/full + re-measured ascent, same texture object so bind groups survive) → all labelled slots marked label-dirty → oneLabelLayer.process()pass rebuilds every glyph run against the new metrics.- A vendored OFL web font for the specs (
@fontsource/open-sansas a devDependency;@font-facein the test pages; specsawaitdocument.fonts.loadbefore 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. Adocument.fonts.readyre-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/7visualspecs (incl. thelabels-open-sansgolden), 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 cachedid()/group()survive removal. This permanently closesrestore()/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:moduleswhere 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,kruskalinsrc/algorithms/(a sharedSubgraphView— dense node index + edge membership over the calling collection — plus an indexed binary min-heap inalgo-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 intest/algorithms.mjsported 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 sharedclustering-distancesmetric 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, andanimate: truevia the existing animation system — handle-memoized,animateFilterhonored; the fit applies at layoutstop until A7's animated fit), andeles.layout()/makeLayout/createLayout(grid and preset honorelesscoping 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
sortdoes nothing there), and breadthfirst compacts the nulls left by maximal shifts before sorting a depth (v3 passes them into its comparator). 28 specs intest/layouts.mjs(1584 Node tests green).A7 Viewport animation targets — landed 2026-07-27.
cy.animate/cy.animation(the handle form is new, mirroringeles.animation) takefit: { eles | boundingBox, padding }andcenter: { 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, likefit()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:layoutPositionswithanimate: truenow 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 animatedfit()/center()options don't exist in v3 either — the target form is the parity surface. 9 specs intest/viewport-animation.mjs(1593 Node tests green).A8 Data query predicates — landed 2026-07-27.
Querygainsdata: { key: value | { eq/ne/lt/lte/gt/gte/in } }(bare value =eq; keys AND together), compiled toCompiledCondition[]on the plan and evaluated with the sametestConditionthecasemapper uses (missing value fails every op,neincluded; exactly one op per condition;innon-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 andplanMatchesRefpaths apply them too. 10 specs intest/query-data.mjs(1603 Node tests green).A9 Small items — landed 2026-07-27. (
boundingBoxAtlanded with A7.)padding()/paddedWidth/paddedHeight: accessor-only — v4 has nopaddingstyle 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 viaDataStore.exportColumns— numbers as f64+NaN holes, strings as dictionary columns, mixed as arrays), round-tripping throughoptions.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'sloadingdoneand 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, +squarealias) 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 apolygon-shapesgolden (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.lineStylecolumn (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-styleskipped per the plan's stretch clause (dashing an SDF boundary needs perimeter parameterization) — README records it.line-stylesgolden (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;applyMappedwrites 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:
textureSampleandfwidthboth hoisted above the solid-quad branch (caught by the validation-error guard).label-visualsgolden (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(+ thearrowalias), with WGSL SDFs generated from v3's arrow point tables (sharedARROW_POINTSin 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 newedge.arrowShapescolumn 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-shapesgolden (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/takeLabelDirtyare group-keyed (trailing group param defaulting to 'nodes', so node call sites read unchanged); StyleEngine's label channels — the passthroughlabel,font-size,colorand all the B3 text visuals — now compile for edges too (the edge write path calls the sharedwriteLabel, edges centering on the midpoint by font size).Renderer: a second GlyphBuffer in the LabelLayer, an
edgeGlyphcull 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 bindsedge.endpointsand 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 anedge-labelsgolden (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;tapholdafter a 500 ms unmoved press;dbltapon a same-target second tap withincy.multiClickDebounceTime()(default 250 ms; new ctor option + validated getter/setter) plus the debouncedonetap; and drag-all-selected — grabbing a selected node collects every draggable selected node into a drag set moved by one bulkshiftper 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
_attachFnon the core (with the WebGPU-availability check at attach time);unmount()destroys pointer + renderer and the instance reads headless with a resolvedready;mount(container)re-attaches a fresh renderer — the new ColumnMirror's from-zero realloc re-uploads every column, andmarkAllLabelsDirty()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
devicelostand 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 emitsdevicerestored; a loss during recovery or a failed re-acquisition goes headless-dead +error(the previous behavior).Plumbing:
gpu-contextnow surfaces every loss and the renderer distinguishes its own teardown by itsdestroyedflag (sorenderer.destroy()stays silent); a_debugLoseDevice()test hook destroys the device externally. Playwright spec: lose the device →devicelost→devicerestored→ 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— 12c landed 2026-07-30/31, completing the family.curve-style:haystackandstraight-triangleplus manual endpoints (the 12c pass).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 accessorscontrolPoints/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 addedcurve-style: haystack(+haystack-radius) andstraight-triangleto 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 props— landed 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 theming— landed 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 theactive-bg-*andselection-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 labels— landed 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 (— landed 2026-07-29 (see the autorotate entry below); the flip rule call was taken as v3's verbatim undirected-slope angle.text-rotation: autorotate)Per-element numeric— landed as round 27.7 (2026-08-02), on any label.text-rotationstays in the label-parity batch.Force-directed layout— landed 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-index— dropped outright (decided 2026-08-01, no round at all): draw order is structural and stays that way, soz-index,z-compound-depthandz-index-compareare not coming to v4 and neither iszDepth/sortByZIndex. See the decided-design bullet in the README. The mechanism this entry named (more z-ranks or au32index-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.)
Compound nodes— landed 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 +:parentvisuals +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-relativerelativePosition,effectiveOpacitysemantics, event bubbling through parents, cose nesting. Needs its own design round: hierarchy in the columnar store, parent auto-bounds vs cull/bb, render order.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.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
chartfamily designed for future chart kinds — scoped as round 23 (plan at the end of this file).Node visual parity batch — gradients (
background-fill/line-filllinear/radial + stop props),corner-radiuscontrol,border-style/-cap/-join/-dash-pattern/-dash-offset/-position, the nodeoutline-*group (5), custompolygonviashape-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-blackenandbounds-expansionwere 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/barrelas parameterized fields, and the sevenround-*keywords assdPolygon( 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.)Arrow parity —
mid-source/mid-targetpositions,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 setarrow-shape: noneon the reasoning that arrows were where the two renderers differed. v4 implemented no arrowgapat all: v3 shortens the drawn line byarrowShapes[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'sgapandspacingboth 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 insrc/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: thearrow-scalequantization (item- and the two stages the trim cannot reach (item 24 — since closed by round 58, 2026-08-09).
Label parity — placement (
text-valign/text-haligngrid vs v4's fixed below-node), per-element numerictext-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-elementmin-zoomed-font-sizevs v4's globallabelFadePx/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. Numerictext-rotationlanded as round 27.7 (2026-08-02), on any label, alongside theautorotatekeyword (edge-only — it resolves from an edge's slope). Also: labels are excluded fromboundingBox()in v4 — v3'sincludeLabels(and the bb options object generally) affectsfit()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.Event vocabulary — v4 lacks the element state events (
grab/grabon/drag/free/freeon/dragfree/dragfreeon), the normalized device events (tapstart/tapdrag/tapend+vmouse*aliases, rawmousedown/mousemove/mouseup/click),tapdragover/tapdragouthover-during-drag,cxtdragover/cxtdragout,tapselect/tapunselect, and the viewport-gesture variants (dragpan/scrollzoom/pinchzoom). Event objects also lackpreventDefault/stopPropagationand 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.Interaction options + touch parity —
wheelSensitivity,touchTapThreshold/desktopTapThreshold, configurable taphold duration,pixelRatio, per-elementevents/text-events(pointer-transparency),box-selection: overlapmode (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;pixelRatiofound 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 optionboxSelectionMode: 'contain' | 'overlap'. Two things that entry did not say: v3 spells the same choice as a per-element style prop (box-selection), andcy.elementsInBox()deliberately stays pure containment, so the mode is read by the gesture alone.Animation surface —
stepcallback,queue: false,renderedPositiontargets, Animation object controls (pause/progress/reverse/apply/applying/completed— v4's handle hasplay/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
stepstays 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, andpause/resume/reverseland (progressstays a getter,apply/applyingstay 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.**
Extension system —
cytoscape.use()andcytoscape(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 )asany.LayoutContext,LayoutImplandCustomLayoutexport now — see open call 14.displayvsvisibility— v3 distinguishesdisplay: none(no space) fromvisibility: hidden(occupies space) from zero opacity; v4 has oneshow/hideflag. Call: is one flag enough, and what dovisible()/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),visibilitylands as a mapper-capable style prop keeping space and bundle ranks — scoped as round 22 (plan at the end of this file).Odds and ends — trued up 2026-08-03 (round 28.3), because three of the six entries had stopped being true:
— exists (cy.window()core.mts, with a "v3 parity" doc comment). It was listed as a gap it had already closed.— landed as round 28.2 (2026-08-03).panByanimation targetlayout instances as event emitters— not 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:— v3's manual garbage-collect hook. Round 19 gave v4cy.gc()cy.compact()plus an automatic trigger, so the question was whethergcsurvives 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 deferredcompact(), a full glyph atlas), so there is something to silence; whether a global mutable switch is the v4 spelling is the call.graph-level— narrower than it read:datain the wire formatcy.json()already exports it (core.mts), and the gap was the binary format (serializeElements), which carried elements only. Sincecy.serialize()output feedscy.add(), including graph data raised whether adding elements should overwrite the target'sdata()— a semantics call, not an omission to patch. Landed as round 39.2 (2026-08-04): format version 4, flagF_GRAPH_DATA, one JSON string written last so v2/v3 buffers keep loading;options.elementsapplies graph data andcy.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 hacks —
hideEdgesOnViewport,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 aliases —
content,autolockNodes/autoungrabifyNodes,padding-{left,right,top,bottom}, the no-dash shape spellings (roundrectangleetc.),attr-family duplicates beyond the ones already kept. One name per concept.
- Canvas-era performance hacks —
- Kept (moved to "gaps with direction set" above):
curve-style: haystack(+haystack-radius) andstraight-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
ends — all 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 full
— oxfmt, 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
overlayCountcounted +1 peraddEdgebut decremented per overlay-list entry (an edge holds two:out[source]+inn[target]), so it could hit zero with entries still live and letaddBulkbuild 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 inensure(). 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),GraphStorerebuilds 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
moveEdgesits 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-columnepochbumps. 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:
onDictRemap→GraphStore.markDataWriteover the whole column (watched keys re-upload their remapped index shadow), and the mapper runtime packsdictEpochsbesidedictSizes— 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=vulkanalone 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=Vulkanroutes 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 thewebgpuandvisualprojects inplaywright.config.js, gated onprocess.platform === 'linux'—--use-angle=vulkandoes 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 decodepage.screenshot()or usecy.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:
- Shrink (big removals without re-add — e.g. a filter UI cuts 200k
elements to 20k). Dead slots pile up and
highWaternever falls: every compute dispatch (cull count/scan/scatter, mapper eval) still runs overhighWaterlanes; every CPU columnar scan (scanRefsInto,boundingBox,refsInBox, CPU pick) still iterateshighWaterslots — 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. - 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.
- 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
refsInBoxis 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-styleprop (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/renderedControlPointsaccessors. - 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-segmentscorner arcs,taxi-direction/taxi-turn/taxi-turn-min-distance,round-taxiradius; 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):
- v4's default
curve-styleisstraight— the perf-first default at v4's target scales; parity scenes and apps opt intobezierexplicitly. A deliberate divergence from v3's bundled-bezier default, recorded insrc/README.md. bezierbundles multi-edges only, verbatim v3: a lone edge between two nodes stays a straight line undercurve-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-rotationis an edge style prop — keywordsnone(default, horizontal) |autorotate, constants or mappers (enum kind, socaseconditionals work, matching the other label channels). Numeric rotations throw (per-element numerictext-rotationstays 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/40webgpuPlaywright 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/13visual(newedge-label-autorotategolden: 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.mtsis the CPU half of the dual-impl discipline for curves: v3's math ported verbatim (bundle stagger(0.5 − n/2 + i)·step, loop raysloopDir − π/2 ∓ sweep/2at radius1.4·step·(j/3 + 1), theedge-distances: intersectionframe 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 =- and the conservative
curveDeviationhull bound for cull/fit. Contract:edge.curveParamscolumn (f32×4; kind packed at [3] so the curve shaders fit the vertex stage's 8-storage-buffer budget)
CURVE_*kinds + the store-managedFLAG_CURVEDbit 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).
- and the conservative
Curve style props + bundle index + param derivation. Five edge props (
curve-stylestraight|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.mtsowns the styled records and derivesedge.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, andstore.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) andsource/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 ofcurve-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 columnsthe 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
curvedEdgekind splits the edge draw on FLAG_CURVED (the straight predicate rejects the bit) — same five inputs, chord test grown byframe.curveSlack(the Frame uniform's spare pad slot), no decimation on the curved stream;CullInfogainedindexCountso 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
webgpuspecs (fan-off-the-chord with pixels at the CPU-computedrenderedMidpoint— 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/CurvedArrowPipelineride 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-arrowsgolden (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-labelsgolden (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-curvedscene generates its 50k edges as parallel pairs (a lone bezier renders straight, so a random-edge scene would measure nothing) withcurve-style: bezieropted 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-25kvsgen-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.outerHalfderived 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.curveEvalAtreads 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.borderWidthitself 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.evalRoutecomputes 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.computeCorneris v3'sgetRoundCorneras a pure function (spec-pinned directly againstv3/src/round.mtsoutput 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:
quadPiecemaps 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).routeMidpointports 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_TAXIkinds +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).
StoreDeltagains an optionalcurveBlobspan/resized entry andModelViewexposescurveBlob()/curveBlobLength();GraphStore.setCurveParamsBlobwrites record + header + FLAG_CURVED/FLAG_CURVED_BOX, feeds the monotone dev/box maxima behindcurveSlack(), and fixed-kind writes release any blob record the slot held. 10 Node specs (test/curve-blob.mjs).Style props + per-edge derivation.
curve-stylegains 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 pinshaystack).Route accessors + the exact lazy bb.
GraphStore.curveRouteAtis the route twin ofcurveEvalAt(which now correctly returns null for blob kinds instead of misreading their headers as bezier params): blob record + live positions/outerHalf/shapes → the evaluatedCurveRoute.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-familyrouteMidpointrules,source/targetEndpoint()as the route's boundary endpoints, andcurveBBAtflattening 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_WGSLmirrors the CPU route evaluator step for step — the frame, the full taxi routing,computeCornerW(getRoundCorner), the piece allocator androuteVertexW/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 bumpsmirror.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 evaluaterouteVertexWat 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 newwebgpuPlaywright 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
routeMidpointWin 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).
refsInBoxnow 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) andtaxi-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 invisual), 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_HAYSTACKandCURVE_TRIANGLEare 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), andCURVE_HAS_ENDPTflags a blob-backed kind (MULTI/SEGMENTS/TAXI) whose record is prefixed by a fixed 10-float endpoint block — [mode, a, b, pctBits, dist] × 2 — resolvingsource/target-endpointandsource/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'sshortenIntersectionclamp rule. Structural calls, recorded in the geometry module doc: a straight edge with manual endpoints derives asCURVE_MULTI n = 0(the route degenerates to the chord between the resolved endpoints —routeVertex/routeMidpointalready handle it), and a bundled bezier with manual endpoints promotes toCURVE_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 arecenter + (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-stylegainshaystack|straight-triangle; new edge propshaystack-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-labelkeywords throw — no label bb in v4), andsource/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
endptPctMaxterm incurveSlack();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), andrefsInBoxtests haystack offset points (v3's haystackPts).Readback:
curve-style/haystack-radiusoff 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), andboundingBox()with the exact offset-point span; endpoint-flagged route kinds flow throughcurveRouteAtautomatically, 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 theedge-distances: endpointsframe 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 withhaystackSlack(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 newwebgpuPlaywright 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/54webgpu, 22/22visual(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) andmanual-endpoints(a px point source end, an angle target end, a source distance and an unbundled bezier underedge-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, andparity-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/28visualPlaywright 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-pointsland on the round-11 blob pattern: a secondCurveBlobpool holds each node's flat unit pairs, slot-stable compaction rewrites the packed offset|count<<24 ref that rides theborderGeomradius 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 dropsnode.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:
refis 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), awebgpuspec (draw + pick agree on the point list at pixel level), theshape-polygongolden (concave arrow outline over bordered / anisotropic / small nodes), andparity-polygonvs 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 thefont-familyrule: 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 generalizedGLOBAL_FONT_PROPSguard (same messages asfont-family).The playwright page gained the real Open Sans 700-italic
@font-faceso 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 thelabels-bold-italicgolden (label tolerance) and awebgpuspec 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 precomputedzoomDprMin = minZoomed / fontSize— the Glyph struct grew 12→14 words (56-byte stride, one f32 + explicit pad) — so both glyph cull kinds testframe.zoomDpr < zoomDprMinbefore the globallabelFadePx/labelMinPxpredicates: v3'seleTextBiggerThanMin(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 awebgpuLOD 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;anchorYper 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-valignstays'bottom'(the round-10 below-node placement every existing golden pins); v3 defaults to'top'. The v3padding-based gap is approximated by the fixed 4 px label margin (v4 has nopaddingprop). No label pixel parity vs v3 by recorded design — pins are thelabel-aligngolden (all nine (halign, valign) pairs with background boxes) and awebgpuspec 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-labelfamilies (2026-07-31). All ten props land:source/target-label(constants or thedata(key)passthrough, refreshing on data writes),-text-offset(non-negative, mapper-capable),-text-margin-x/yand-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/edgeTargetin the widenedLabelStreamtype) feed two moreGlyphBuffers 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'scalculateEndProjectionon-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 moreCulledGroups of the same kind and two more draws through the sameLabelPipeline(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 theend-labelsgolden (straight + bezier pair with autorotate + taxi + loop, boxed labels) and awebgpuspec 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-radiusis read by the CPU pick inside-test; nodeoutline-width/-offset, overlay/underlay padding and ghost offsets grow the store bb scan the wayborder-widthalready 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-weightfollow thefont-familyrule: global constants (one font per atlas); per-element forms stay out.- Dashed
border-style/outline-style/text-border-stylestay out (dashing an SDF boundary needs perimeter parameterization — the recorded B2 reason); these props ship withsolidsemantics only where the rest of their group lands. text-valign/text-halignare placement only: labels stay excluded fromboundingBox()(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-fillare 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 theactive-bg-*andselection-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 (elementopacityis 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/-offsetas 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 (thefont-familyrule: 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 globallabelFadePx/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.outerHalfis already a bindable column); placement only per the lean above. - D4
source-label/target-labelfamilies (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-offsetas 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 forghostas an enum).The decided simplified form, verbatim: a new
node.ghostcolumn ([offX, offY, opacity, enabled], f32×4) drives a ghost pass — the node shader gainedvsGhost/fsGhostentry 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'srefsInBoxtests the body box only). 8 Node specs (test/ghost.mjs), awebgpuspec (ghost at the offset, not pickable, follows drags on-GPU, old spot clears), aghostgolden (three shapes with borders at one offset), and aparity-ghostlive 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, 55webgpu+ 30visualspecs, 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×4columns ([rgba folded, padding×256, shape, radius×256|auto]) drive oneNODE_LAYER_SHADERinstantiated 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), awebgpuspec (overlay wash + underlay ring), anode-layersgolden, and aparity-node-layerslive v3 scene at 0 px differing. 1858 Node tests, 56webgpu+ 32visualspecs, 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 packedUint32Array×2columns — [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/vsCurvedLayerentry 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-radiusstay 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); anedge-layersgolden (straight + taxi + loop under both layers) and aparity-edge-layerslive 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
coregroup — 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-widththeme 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), andactive-bg-color/-opacity/-sizedrive 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 awebgpuspec (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) andtext-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
opacitystays its own column multiplied in the FS, so v3's effective = channel × element holds; the arrow fold gains the line-opacity factor (v3'seffectiveArrowOpacity = opacity × lineOpacity), threaded throughfoldedArrow, 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 apaintInputsexclusion (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 aparity-opacity-splitlive 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.borderGeomcolumn ([cornerRadius | −1 = auto, borderPosition]).border-position(center | inside | outside — and v4's default flips to v3'scenter: 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-geomscene (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-offsetandline-cap(butt | round | square; cap + offset mapper-capable) land in two columns (edge.dashPatternf32×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-propsscene (pattern + offset + all three caps) from 2.501% to 0 px differing. Caught en route by the Node WGSL-identifier guard's runtime sibling:metais 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-colorride the A2 layer machinery verbatim: anedge.casingcolumn 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 existingvsEdgeLayer/vsCurvedLayerentry 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
opacitymapper 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.
- bezier pair + taxi under an 8 px casing) measures 0.061% —
the recorded butt-vs-round stroke-end deviation only. 5 Node
specs (
B5 node outlines (2026-07-31).
outline-color/-opacity/-width/-offset(solid only —outline-stylestays out withborder-style, the perimeter-parameterization limit). Thenode.borderGeomcolumn widened toUint32Array×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 byparity-outlineat 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 monotoneoutlineSlack()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) andtext-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-stylestays 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-tierlabel-boxesgolden (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) andsource/target-arrow-width(px | 'match-line' | %, resolved against the edge width at style-write into a newedge.arrowWidthscolumn).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-scalarsgolden (scale 2, hollow ends, thick hollow strokes) and awebgpuhollow-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/-colorland 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 newvsMidArrowentry points on both arrow pipelines whoseEnduniform 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,routeMidpointWfor 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 checkedshape === 'triangle', so constant vee/chevron/circle/... sheets never drew arrows at all; now any non-'none' shape draws. (Follow-up, same day: the B7arrow-scalarsgolden predated this fix — its scene's constantsource-arrow-shape: circlearrows 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 themid-arrowsgolden (straight + bezier pair + taxi + haystack) and awebgpuspec asserting purple mid-arrow ink at the CPU-computedrenderedMidpoint()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) withbackground-gradient-stop-colors/-stop-positions/-direction(v3's eightto-*keywords), andline-fillwithline-gradient-stop-colors/-stop-positions. Storage: one packedUint32Array×8record 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; thegradientsgolden 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:
- Parent styling takes both decided forms. (a) The sheet gains
a
parentsgroup overlaying the nodes group for parent slots — constants or mappers, defaults = v3's:parentblock (shape: rectangle,padding: 10,background-color: #eee,border-width: 1,border-color: #ccc). (b) Thecasemapper'swhengains structural boolean conditions ({ parent: true },{ child: true }). Query objects gain the matchingparent/childboolean keys. The v3:parent:selectedtint is dropped — v4 never restyles on selection (the shader accent ring is the selection affordance); recorded deviation. - Event bubbling is ported (reversing v4's flat-emit rule for
compounds only): element events bubble child → ancestors → core
with v3 semantics —
event.targetstays the originator,stopPropagation()/return-falsehalts the walk. The flat no-compounds path stays byte-identical (zero cost). - 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-heightas 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.
- 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_VISIBLEis redefined as the effective shown bit (own state AND no hidden ancestor) — every consumer (WGSLSHOWN, 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 theFLAG_CURVEDprecedent. - Parent geometry is materialized into the real
node.size/node.positioncolumns 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()thencurves.flush(), replacing everycurves.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.dependsOnSelectionexists at HEAD (it left with the selector removal) — the parent-flip restyle hook is built fresh; and v3 edgeeffectiveOpacityis the edge's own opacity (edges have no parent), which v4 ports.
Design (per subsystem):
HierarchyIndex (
store/hierarchy.mts, new; modeled onstore/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.setParentcycle-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 throughmaterializeGeom— raw column writes + dirty marks +updateOuterHalf+geoEpoch+++ label re-anchor when size changed + incident-edge curve invalidation.materializeGeombypassessetPosition, 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 andshift/positionsgain 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 theupdateOuterHalfhooks), add/remove/reparent, compound style writes, visibility toggles. Drained fromflushDerived()attakeDelta(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_PARENTnode 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
parentNodecull 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
nodecull predicate excludesFLAG_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).drawScenedraws 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 (skipFLAG_PARENT), then parents in reverseparentOrder, with a shared order helper so pick and draw can't diverge.Dragging a parent needs no drag-set union (parent
setPositionshifts the subtree);FLAG_GRABBEDis not set on descendants (minor recorded deviation).Visibility + opacity folds:
setVisibilitysets/clearsFLAG_SELF_HIDDENand recomputes effectiveFLAG_VISIBLEover 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.opacitystores 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 onparentCount > 0so the non-compound path is unchanged.style('opacity')reads the base;effectiveOpacity()the fold. GPU-mapped nodeopacity(andwidth/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, checkingisPropagationStopped()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).callbackContextreturns the phase element (v3's currentTarget);event.targetstays the originator.Style/query:
SHEET_KEYSgains'parents'; the block takes node props pluspadding,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 byFLAG_PARENT; parentwidth/heightdivert to the fallback size (auto-bounds ownsnode.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/childboolean keys OR-composed into the flag test likeselected;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 aCURVE_MULTI-family blob record with v3'sfindCompoundLoopPointsmath verbatim (two control points off the min top-left corner,loopW = 50, per-end stretchmax(0.5, log(w·C))), box-bounded (FLAG_CURVED_BOX). Applies regardless of declared curve style (v4 has noedge:compoundselector — mirrors the forced self-loop rule; recorded). Re-derives on reparent and on endpoint resize during hierarchy flush.Model/API/format:
parentbecomes a reserved first-class key — skipped by def/columnar data ingest, immutable viadata()(reparent viamove()), synthesized on read like edgesource/target. Def ingest resolvesparentin 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-preservingmove({ parent })withmoveout/move, compound-relativerelativePosition, realpadding(), and parentwidth()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;boundingBoxAtforce-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 likeFLAG_CURVED);store/hierarchy.mts— theHierarchyIndex(host-callback object like the CurveIndex):parent: Int32Array(−1 = orphan) + link-timeparentGen(recycle guard, warn-once), sparsechildrenlists,depth, live-parent count, and the lazily-rebuiltparentOrder()(depth-asc, slot-asc) draw permutation.setParentcycle-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;removeNodenow 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. Theparentdata key is reserved first-class: def ingest skips it (14.2 resolves it as hierarchy),data('parent', v)throws (reparent ismove()), and reads synthesize from the hierarchy like edgesource/target(whole-objectdata()includesparentonly when parented). Tests-first: 12 specs intest/hierarchy.mjswritten 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 theisParent/isChildless/isChild/isOrphanpredicates (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,moveoutbefore /moveafter per changed node (listener-gated), unknown parent a silent no-op (v3), cyclic assignment warns + drops with no events; def ingest resolvesdata.parentin 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); elementjson()carriesparentvia the synthesized data object and round-trips throughadd().Tests-first: 17 specs in
test/compounds-api.mjsred 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.sizecolumns, so bb/cull/pick/mirror need zero geometry changes.HierarchyIndexgained the pending set (markGeomarks whole ancestor chains with early-exit;markAncestorsfor pure translations), per-parent compound style (setCompoundStyle: padding px/% + relative-to, min-w/h), andflush(): deepest-first over pending parents, direct children's border-inclusive extents offnode.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/paddedHeightreturn the column,outerWidth= padded + border,padding()answers the resolved pad. Writes go throughmaterializeParentGeom— dirty spans,updateOuterHalf, thenodeHalfMaxcull 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 everycurves.flush()site; drains at takeDelta/bb/refsInBox/ accessors.Triggers: the four position writers (a parent
setPositionflushes, 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 emitpositionfor shifted descendants (listener-gated, v3); compound-relativerelativePosition(get + both setter forms); parent-flip restores the stashed style size.Tests-first: 14 specs in
test/compound-bounds.mjsred 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_VISIBLEis now the effective shown bit (own state AND no hidden ancestor) recomputed byGraphStore.setVisibilityover 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).refsInBoxgained 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 storesbase × ∏ 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 whileeffectiveOpacity()/transparent()read the fold, edges keep their own opacity (v3 — verified against v3 source), and a GPU-mapped nodeopacitydemotes to CPU while compounds exist (paintInputs+ a store→engineonCompoundsToggledpaintVersion bump on the 0↔>0 transitions). Tests-first: 11 specs intest/compound-visibility.mjsred 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
_emitOnElere-emitting one shared Event with a moving_gpuPhaseRef, sostopPropagation()(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.targetstays 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.mjsred 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:parentoverlay (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 (paddingpx or 'N%',padding-relative-to,min-width/min-height,compound-sizing-wrt-labelswhere'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- Engine mechanics: a third GroupDef compiled from the merged
props (parents-block mappers evaluate for parent slots only);
applyBulk/refreshMappedpartition node slots byFLAG_PARENT; mapper escalations re-partition viaallSlotsFor; the readback paths route throughdefFor(ref);stylesDependOnDataconsults the parents deps;store.setCompoundStylelands per parent at apply. Flip restyle: a leaf↔parent flip re-applies the slot against the right group via a storeonParentFliphook (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 intest/parents-style.mjsred then green; the 14.3 bounds suite pins raw math by zeroing the new defaults in its sheet — 2016 Node tests, typecheck + lint clean.
- Engine mechanics: a third GroupDef compiled from the merged
props (parents-block mappers evaluate for parent slots only);
14.7 Structural query + case keys — landed 2026-07-31. Query objects gain
parent/childbooleans (parent: false= v3's:childless,child: false=:orphan), OR-composed into the one flag test likeselected— pure columnar scans, noscanRefsIntochanges. Structural keys are node concepts: an explicitly-edges query throws, an unrestricted one just never matches edges (v3's pseudo semantics).The
casemapper'swhengains the structural forms{ parent: bool }/{ child: bool }— a structural condition stands alone (AND it with data conditions via thewhenarray 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-keyrefreshMappedon the moved node (store.onReparented); parent flips already restyle fully via 14.6's hook. Tests-first: 8 specs intest/structural-query.mjsred 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 — withtoColumnarElementslifting 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 intest/compound-wire.mjsred 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
parentNodecull 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
nodecull (and with it the depth prepass) excludesFLAG_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 callsflushDerived()), and 14.6's specificity assumption was wrong — v3 precedence is order-based, so a user nodes block overrides the default:parentoverlay (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, awebgpubehavioral spec (child-over-parent pixels, padding band, edge-over-parent, pick in band vs child, parent follows child), thecompoundsgolden (nesting/padding/borders), and theparity-compoundslive 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-inclusiveouterHalf. Full suites: 2034 Node tests, 116/116 Playwright (54+3webgpu... 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
findCompoundLoopPointsverbatim (two controls off the endpoints' min top-left corner,(1 + 50^1.12/100)·dist·(j/3+1)offsets, stretchmax(0.5, ln(outerWidth·0.01))per end) — as a newCURVE_CMPDkind 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'sedge:compounddefault block makes related edges bezier-compound by default, so behavior matches; unbundled styles takecontrol-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, andflush()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 incurveSlack(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) — andCURVE_HAS_ENDPT = 8collided with the naïve next kind id, soCURVE_CMPD = 16sits 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), thecompound-loopsgolden, andparity-compound-loopslive 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):
layoutPositionsfilters parents (auto-bounds derive them from their placed leaves), the grid slot path filtersFLAG_PARENTslots, 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).boundingBoxAtskips 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 carryFLAG_PARENT|FLAG_CHILDis 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 justposition()(the 14.3 subtree shift), and drag-all-selected with a parent + its child rides the collectionshift()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.
debuggained a?network=compoundgenerated 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.
- flush + delta) 17.6 µs, a child-drag frame 11.8 µs —
auto-bounds are noise at frame rate. Renderer benchmark
gained
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:
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-tiertexture_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
imageMaxSizerenderer option moves the cap) — a recorded deviation for large photo sources.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
loadingdoneprecedent. 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 highscale.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) sendssdf-iconsources 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 bybackground-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.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.
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), plusbackground-image-typeandbackground-image-color(keyword sets and %-defaults are v3's, verified against v3 source at implementation).background-width/height-relative-tois 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'sinclude-paddingdefault; theinnervariant is the unported spelling, recorded). Mapper rules (the 12b list discipline): list forms are constants-only; the single-image forms ofbackground-image,background-image-opacityandbackground-image-colortake mappers (data(key)URLs resolve through the ordinal-dictionary path — the icon-per-type pattern;caseworks 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).
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).Geometry non-interaction + LOD. Images never grow
boundingBox()(unclipped overflow is not in bb — consistent with thebounds-expansiondrop) and never affect picking (the pick body stays the shape). The FS skips image sampling below theimageMinPxon-screen node size (default ~8 px; below ~3 px the plain-disc LOD already owns the pixel) — recorded.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 viatakeFreed()(the layer reclaim channel); rgba tier assignment from the decoded longest side (128/512/1024, cap tier clamps); sdf-icon entries raster at the fixedSDF_IMAGE_SIZEand carry no rgba tier; decode runs behind an injectable async rasterizer (setDecoderkicks 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 intest/image-registry.mjsred 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 thirdCurveBlobwith round-11 compaction, relocations rewriting the ref column) +delta.imageBlob+ModelView.imageBlob()/images.GraphStore.setNodeImagespacks 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;removeNodereleases 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-tothrows 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 channel —background-imagecompiles as an enum mapper whose parseEnum interns urls per compile (casethens, ordinal ranges and raw passthrough data values alike), covering both icon-per-type and photo-per-node;-image-opacityand-image-colorare plain number/color channels; every other image prop rejects mappers (the 12b list rule). Tests-first: 17 specs intest/background-image.mjsred 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-tiertexture_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: nodeby the node SDF — containmentinsideclips at the border's inner edge (border stays visible; a translucent border shows fill, not image — recorded beside the B1 band rule),overat the shape boundary — and confines repeat tiles to the node box (recorded).clip: nonerects 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 withnullnarrowed to same-origin — recorded) attaches at init and detaches on destroy. WGSL lesson re-hit and re-recorded:refis reserved (the console-error guard caught it). Verifies: 6 Node specs (test/image-arrays.mjs, tests-first), theimages-basicandimages-cover-clipgoldens, andparity-imagesvs 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-multigolden pins four overlapping images with per-image sizes/positions/opacities and a half-translucent source (blend math), andparity-images-multivs 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
computeSdfEDT runs at upload, and the field lands in a dedicated r8texture_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: theimages-sdf-iconsgolden (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, sincebackground-image-typeis 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:
exportImagepromotes at the export view's zoomDpr (no viewport test) and awaitsregistry.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 viadata.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:
Labels join
boundingBox()andfit()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-argfit/center),getFitViewport, animatedfit:/center:targets andboundingBoxAt. 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).The wrap family (v3 semantics; node labels, edge labels and the D4 end-label streams alike):
text-wrap(none | wrap | ellipsis, defaultnone),text-max-width(model px),line-height(multiplier, default 1),text-overflow-wrap(whitespace | anywhere),text-justification(auto | left | center | right,autoside-aware per v3).wraphonors embedded\nand breaks attext-max-width;ellipsistruncates with…;nonekeeps today's single line. All mapper-capable (CPU-evaluated, the label sidecar tier).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.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/haligngrid anchors the block, autorotate rotates the block as a unit, and the fade/min-zoomed-font-sizecull predicates are unchanged (the block AABB grows the cull bound).The parked props' v4 forms (from the 2026-07-29 triage):
box-select-labelsbecomes the core optionboxSelectionIncludesLabels(default false, v3's default) — one more term inrefsInBoxoff the same laid dims;text-metrics's v4 form is the public exact measureeles.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'stext-wrapsemantics —nonecollapses newlines,wraphonors\n+ greedy word wrap withwhitespaceoverflow vsanywheremid-word splits,ellipsistruncates one line with '…'),layoutLabelBlock(lines stacked by lineHeight × em, justified inside the block, block centered about x = 0), andestimateBlock— 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-wrapnone | wrap | ellipsis,text-max-width9999,line-height1,text-overflow-wrapwhitespace | anywhere,text-justificationauto | left | center | right); all five are mapper-capable (the sidecar tier), both label groups. The sidecar entry stores the resolved justification (auto folds againsttext-halignat write — v3's hanging-label rule; edges center) whilestyle('text-justification')reads back the declared value incl. 'auto', as v3.Label dims live in the store (
labelDimsAt/setLabelDims, per stream):setLabelestimates immediately viaestimateBlock— 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.mtsmoved to the gpu root (a dual-consumer module, the curve-geometry precedent). One historical pin updated: gpu-style's unsupported-prop example wastext-wrap, which now exists — it pinsbackground-blacken(dropped by decided design) instead.Tests-first: 10 specs in
test/text-wrap-props.mjsred then green — 2106 Node tests, typecheck + lint clean.16.3 Renderer (2026-08-01) — LabelLayer lays every stream through
layoutLabelBlockbehind 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-wrapgolden (three-line wrap under left/center/right justification via mappers, ellipsis truncation, unwrapped control) andlabels-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,renderedBoundingBoxand the store's whole-graph scan (no-arg fit/center/getFitViewport read it implicitly), andboundingBoxAtcarries the node-relative label box to hypothetical positions (animated-layout fit targets cover labels).Terms: node labels are exact —
store.nodeLabelBoxplaces 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 conservative —edgeLabelSlackis 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 intest/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):refsInBoxadditionally 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
webgpuspec: 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:
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-onvariants fire only on the directly grabbed element; the plain forms fire on every node the gesture moves (drag companions included);dragfree/dragfreeononly 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 andoriginalEvent.
Dropped, recorded: the
vmouse*aliases (thetap*names are the normalized vocabulary) and the raw mouse/touch re-emits (mousedown/mousemove/mouseup/click,touchstart/...) —pointer*is their one modern spelling; the existingmouseover/mouseoutemissions stay.event.preventDefault()stays unported (gesture defaults are gated by options/flags, not handlers;originalEventkeeps the DOM method) — recorded. All new element events bubble through the round-14.5 phase machinery.- Drag-state (elements):
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 })(andeles.layout({ impl, ... })) construct and run it through the existing lifecycle —layoutstart/layoutready/layoutstopon 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 thelayoutPositionsfinisher (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).
- Shape: a layout impl implements
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, andpointerover/pointeroutriding the hover transitions beside mouseover/mouseout; plus the device-normalizedtapstart(primary press),tapdrag(moves while a press is active — the raw pointermove covers unpressed motion) andtapend(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
webgpumouse-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/dragfreeonwith v3's firing rules: the-onvariants fire only on the directly grabbed element; the plain forms fire on it and every selected companion in the drag set;dragfires 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
webgpumouse-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/tapunselectfire on the tapped element beside its gesture-driven select/toggle-off (background clears and box selection keep their own events, as v3);tapdragover/tapdragoutandcxtdragover/cxtdragoutride 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) andpinchzoom(each two-finger zoom step), all core-level with the gesture's model position. Pinned in awebgpuspec (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 })(andeles.layout) runs a user class (constructed argless) or object implementing{ run(ctx), stop?() }— no registry, no cytoscape.use, no global state.runmay return a promise (the GPU-layout shape); the wrapper exposespromise()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.setPositionson the round-5 slot path).The LayoutContext is columnar-first:
nodeSlots()(scope order, pre-filtered to unlocked leaves — the 14.11 rule), livepositions()/endpoints()views, O(1)degreeOfoff CSR,edgeSlots(), scope bb + viewport dims,ctx.optionscarrying custom knobs, with handles reachable atctx.eles/ctx.nodes. Layout instances stay non-emitters (round-10 rule; events fire on the core with the wrapper asevent.layout).Tests-first: 10 specs in
test/layout-contract.mjsred 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) —
debuggained the worked example:SpiralLayout, a plain class run viacy.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:
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 towardedgeLength, 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).
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 andlayoutstopfires.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.
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.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(truelive |falsesettle-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 · alphaper 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' })wrapsForceLayoutImplin 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.mjsred 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 withapplypublishing 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:
metais 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.jsuses. The trap that produced the earlier conclusion:requestAdapter()returns null onabout: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
--layoutmode 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 thenumIter: 300baseline hung the suite for hours. Fixed inb7ea7068with 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-uncappedremoves both).Two starvation findings recorded in that commit:
setTimeoutruns minutes late under cose's synchronous iteration blocks, and even a rAF watchdog only runs at paint time (first paint 70 s afterrun()at 25k withrefresh: 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): v4forceconverges 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
encodeCopythrew the copy away. Now the frame checkshasFreeSlot()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 existinghasPending()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
nullscount unambiguous (background only).Saturation is observable:
renderer().stats().pickDeferralscounts 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.mjsunit-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. AwebgpuPlaywright 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):
- 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.)
- 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.11settleGpuAllprecedent, 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 callcompact()— auto is the user-serving default. - 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 (compactOrderalready drops tombstones), id-index fusion,edge.endpointsrewrite on node moves, CSR rebuild, dirty/resizedsignaling, 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/_eleFromRefrepair paths that rewrite theRefin 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
resizedhandshake, 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, abenchmark/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,highWaterand 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 takesoldGenAt(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;resizedmarks 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
markAllLabelsDirtyas 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._refsbecame 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-finishingsettleGpuAll). 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-onlyrunner 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:
The option quartet is core-level, with getter/setters. v3 buries
wheelSensitivity,desktopTapThresholdandtouchTapThresholdin renderer options and hardcodestapholdDuration = 500; v4 has no renderer-option surface for interaction (therendererblock is GPU tuning), so all four are constructor options withmultiClickDebounceTime-style getter/setters — readable and settable at runtime, validated (throw on non-finite/negative;wheelSensitivitymust 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: 500ms (v4 makes v3's constant configurable — the one deliberate surface addition, logged in the gap list). v3's console warning on a customwheelSensitivityis kept verbatim (the hardware-variance advice is as true under WebGPU; emitted once per instance, from the setter or ctor).eventsis a style prop compiled to a flag bit. v3'sevents: 'yes' | 'no'ports to both element groups (default'yes'), constants orcasemappers (CPU-evaluated — a flag write, like every non-paint channel).The engine maintains a new store-managed
FLAG_NO_EVENTSbit; 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 apickModeFrame field and drop flagged edges in pick mode only — scene draws are untouched:events: noelements still render), and the box-selection gesture (v3'sgetAllInBoxruns over theinteractiveset, soevents: noelements are not box-selectable; the gesture filters, whilecy.elementsInBox()stays a pure geometric query — a recorded scope note).interactive()becomesvisible() && events !== 'no'.An
eventsflag change invalidates the pick-tile cache (it changes pick answers, not pixels).text-eventsis 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 orcasemappers,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.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 —
cxttapstarton 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 (cxttapendfires, 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)/3corners),boxstarton 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'sdidSelectlatch). Both gestures ride the existing pointer-event handlers (v4 has no touch-event path by design).Closed or deferred without building:
pixelRatioturned 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 aboxSelectionMode: '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 awebgpuPlaywright 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,tapfired) at threshold 10; a 350 ms hold fires notapholdat 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 orcasemappers) whose write() maintainsFLAG_NO_EVENTS; the CPU node pick scans past flagged slots (grab/hover/tap fall through to what's beneath), the Frame uniform grew apickModefield (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 tointeractive()(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 awebgpuPlaywright spec (a blueevents: nonode still wins the pixel but hover and a drag pass through to the node beneath; acy.pickon anevents: noedge 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 orcasemappers) maintainingFLAG_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 awebgpuPlaywright spec (a click on the label below the node background-taps under the default and selects the node undertext-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 (
cxttapstarton the node under finger 1, else finger 2, else the core — the sync CPU pick), the pair moving emitscxtdrag+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+cxttapwhen 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:
cxtdragthresholds on finger-1 movement pasttouchTapThreshold(v4's mouse cxt rule) where v3's touch cxt fires on any move event.Pinned in a
webgpuPlaywright 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,boxstarton the first move, the themed DOM box drawn live (the overlay/styling shared with the mouse box via a newshowBoxRecthelper), applied on any box finger's lift — boxend/box/boxselect throughelementsInBox(so the 16.5 label option applies) filtered tointeractive()(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 (
cxttapendfirst) — 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
webgpuPlaywright 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
webgpuspec confirmed the pre-existing option end to end (pixelRatio: 1→ backing store = css size,2→ doubled, andcy.pickat 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:
- 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 noqueue: falseoption because there is no queue), and the v3stepcallback 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. displayandvisibilityboth 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.- Pie/stripe backgrounds: yes — designed as a charts surface.
Ported not as v3's 101 numbered props but as a lean list-valued
chartfamily 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(). delaystays (it is part of one animation's timeline, not queueing).play/stop/promise/playing/animatedkeep 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: falseoption 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()replacesenqueue()), eviction comparestouchedColumns()(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;tickadvances every running animation (dedup across refs) andstop()stops them all — itsclearQueueargument is gone fromeles.stop/cy.stop(no queue to clear).queue/stepoption 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:
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 hiddenbezier-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.visibilityis a style prop ('visible' | 'hidden', both groups, default visible, constants orcasemappers — 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()ridesvisible()). 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).- Mechanism: one derived bit, one WGSL constant. The style
engine maintains
FLAG_SELF_INVISIBLE; the store derivesFLAG_DRAWN(= effective shown AND no invisibility on self or, for nodes, any ancestor) in the same subtree walk that maintains effectiveFLAG_VISIBLE. The WGSLSHOWNconstant redefines fromALIVE|VISIBLEtoALIVE|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). - 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 fromvisible());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),
setInvisibilityas the style write's entry,isDrawn(ref)folding edge endpoints, thevisibilityprop (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 collectionboundingBox()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
SHOWNconstant 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) whilefit()still frames it; a tap where it sits background-taps; a restyle to visible returns the pixels; and a display-tierhide()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),onEdgeShownChangedmarking the pair/loop on hide/show (wired fromsetVisibility; 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:
The
chartfamily (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'spie-holeanalogue),chart-start-angle(pie; v3'spie-start-angle, default 12 o'clock),chart-direction(stripes:horizontal | vertical) andchart-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).
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.chartRefcolumn — colors resolve at style-write (constants-only props bake per record).chart-valuesvia{ data }refreshes on writes of the mapped key like any mapped channel; every other chart prop is constants-only exceptchartitself andchart-opacity(mapper-capable enums/numbers).Rendering: in the node FS, SDF-native. A
chartRef == 0early-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 thechart-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.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
chartpies onto v3'spie-i-*props (and stripes ontostripe-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.chartRefcolumn — 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
chartitself 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 thecharts-pie-stripesgolden: 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 againststripe-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'sstripe-direction: horizontalis inert — the canvas draw switch tests a typo'd 'righward' keyword its own style type rejects — anddrawStripeswaps W/H in its centering offsets, visible on non-square nodes.The planned
debugtoggle 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:
Transitions are in — the
transition-property/-duration/-delay/-timing-functionfamily 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 (caseclause flips, scale output moves, auto-domain extent shifts) and structural restyles (leaf↔parent flips, structuralcaseconditions). Instant on add (v3's rule — a new element's first style application never tweens from channel defaults).Non-triggers, recorded:
visibility/show/hideflips (flags, not tweenable channels — fade is spelled with anopacitytransition) and descendant effective-opacity folds (they follow an ancestor's tween per tick; no per-descendant transitions). Batched writes capture at the outermostendBatch— one transition per net change.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.
transition-propertyaccepts 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-widthon 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, edgewidth) 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.
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).
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, pindomainwhen a stream grows its own extent. No warning machinery.Controls:
pause/resume/reverseland;progressstays a getter (no scrubbing), and v3'sapply/applyingstay 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/reversecontrol 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-propertyaccepts 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::parentrefreshes, 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-
opacitytransition 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/gpuEligiblefrom 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
gpuEligibleand 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) —paintInputsdemotes 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
rendererproject): a sheet-swap transition tweens pixels through OKLab whilestyle()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-onlyprogress()andpaused()introspection (progressis a getter only — no scrubbing;apply/applyingstay 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 alastNowclock 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 (sidecaranchorX/anchorY+ the glyph run's baked offsets — today only the style engine's same-passwriteLabelkeeps them in sync, and the store'sreanchorLabelcovers only the parent auto-bounds path) and the compound-loop excursion boundp2(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.arrowWidthsundermatch-line/percent,edge.casingstroke (width + outline width),edge.overlay/edge.underlaystrokes (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.setwould tombstone-and-append per tick until compaction forces whole-stream re-uploads, and every label write bumps the globalgeoEpoch, nuking the per-edge exact-bb memo. Edge-labelanchorYis fontSize-dependent (-fontSize/2 + marginY); node label anchors are not.Padding already cascades:
setCompoundStylemarks the hierarchy geo-stale and the lazy flush re-derives auto-bounds.
Design calls (round 25):
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).gpuEligiblestays false via the existing tier mechanism; the GPU tween kernels never see the new write kinds.The write vocabulary grows three CPU-only kinds.
ChannelWritegainslane(scalar tween of one component of a multi-lane column —node.sizelanes 0/1,edge.arrowWidthslanes, and the ×256 fixed-point stroke lanes ofedge.casing/edge.overlay/edge.underlay),padding(writessetCompoundStyle({ padding })per tick), andfontSize(patches the label sidecar per tick). Store entry points:setLane(with per-column cascade) andsetLabelFontSize; both usable by the style engine too.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')/setLanere-anchor the label (reanchorLabelhoisted out of the parent-only path; early-outs when unlabelled or center-anchored).The planned CMPD
invalidateRelationhoist is unnecessary by a containment argument: the excursion bound is a max over both ends' stretches, stretch is monotone inouterHalfW, 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 eventmaterializeParentGeomalready 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.
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),
arrowWidthsrides per mode (match-line → toWidth, percent → pct·toWidth, number → no ride), modes answered by the style engine at capture (thecaptureArrowFoldprecedent). Transitions get the same rides from stored-truth diffing (the apply pass rewrites the derived channels; the txn records them as lane rides ofwidth).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/heighton 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:widthandheightshare thenode.sizechannel, so the round-21 eviction treats them as one channel (a running width tween is evicted by a starting height tween).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'
setCompoundStyleapply (its own small capture beside thewrite()funnel, honouring the styled-generation instant-on-add rule).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-runningestimateBlock; (b) the shaping-memo key dropsmaxWidthwhen wrap isnone(kills the spurious per-tick miss + unbounded memo growth); (c)GlyphBuffer.setupdates 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 globalgeoEpoch(labels get their own epoch; the per-edge exact-bb memo keys on geometry alone).Wrapped labels (
wrap/ellipsiswith a finitemaxWidth) genuinely re-break per tick — correct, priced in the benchmark, and recorded as the expensive configuration. The tween patchesfontSize(and the fontSize-dependentanchorYon the three edge streams) acrossnodes/edges+ end-label streams; min-zoomed-font culling follows automatically (the per-glyph threshold is rebuilt with the instances).Transitions wire through the same channels.
TRANSITION_CHANNELSgains nodeswidth/height(size lanes),padding,font-sizeand edgeswidth(+ 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.Scale is measured, not assumed. A new
benchmark/geometry-tween.mjssweep 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
lanewrite kind (ChannelWrite.lane, stride 2, geometry-tier by construction —TWEEN_SHADERS/pipelines narrowed to aGpuWriteKindthat excludes it, and the runtime throws if one ever reachesregister), the store's cascadingsetLane(node.sizeroutes throughsetPair; other float columns write the lane raw + dirty), the label re-anchor hoist intosetPair('node.size')(the raw-size-write anchor staleness hole, closed for style writes and tween ticks alike), andSTYLE_CHANNELSwidth/heightasnode.sizelanes 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-stalewidth()/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.widthgains theedge.widthcolumn (plain scalar, geometry tier) and the capture carries the three style-write-baked derivatives as ride-along lane writes (captureEdgeWidthRides, thecaptureArrowFoldpattern): 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), andedge.arrowWidthsby mode via the new constants-onlyStyleEngine.arrowWidthModes()('match-line' → target width, percent → pct × target, numbers stay).setLaneencodes the layer records' ×256 fixed-point stroke lane. Tests: 4 specs (red then green) — livewidth()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_CHANNELSgains nodeswidth/height(node.sizelane channels) and edgeswidthwith its baked derivatives as lane rides (casing/overlay/underlay stroke lanes ×256 fixed-point, bothedge.arrowWidthslanes — 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 thelanekind 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 keyedcolumn:lane(arrowWidths carries two),buildChannelWritetakes the lane through to the write. Parent slots never record anode.sizetransition (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
paddingwrite kind targets thenode.paddingpseudo-column (TweenColumn=ColumnId+ the pseudo target; padding is a compound style input, not a stored column) and writes through a newupdateCompoundStyle— a partial merge over the current record, split fromsetCompoundStylebecause 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 topadding(GpuWriteKindexcludes 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 viapaddedWidth(), 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
fontSizewrite kind (pseudo-columnsnode.fontSize/edge.fontSize) andGraphStore.setLabelFontSize— the per-tick sidecar patch, no engine round trip: an edge write drives all three streams and re-derives the fontSize-baked edgeanchorY(−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
maxWidthunder wrap none,GlyphBuffer.setrewrites same-count replacements in place (pinned by a 50-tick no-growth/no-tombstone spec with a single coalesced span), and label writes stop bumpinggeoEpoch(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 atBENCH_Nscale. 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). Twowebgpu-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.launchfails 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
srcis 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.mts66/204 (32%) andcore.mts33/89 (37%).animation.mtsis 33/48 (69%),viewport.mts11/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 incollection.mts, 14 incore.mts— "graph manipulation", "viewport", "traversal", "events", ...). So section placement needs no new tag: the banners already are the grouping. - The
./gpupackage export maps"import"only — notypeskey and no.d.ts— and the seventest:types:*scripts contain zero gpu references, soimport cytoscape from 'cytoscape'resolves to untyped JS today. Pointing the existingrolldown.dts.config.mjsatsrc/index.mtsemits 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):
- 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.mdkeeps 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. - Standard tags only; banners are the sections.
@param,@returns,@throws,@example,@see,@defaultValue. No bespoke@section/@docstag: 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'sformatsarray. - 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. - Declarations ship with the docs in them.
cytoscapegains a real.d.tsbuilt 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. - 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,--verbosefor the per-member list),test/jsdoc-coverage.mjs(the completed-files ratchet + the tier floors), the conventions recorded insrc/README.md("Documenting the source"), andcore.mts33/89 → 89/89 plusviewport.mts11/18 → 18/18. Two drift fixes found by writing the comments:json()'s doc block had become stranded aboveserialize()(sojson()read as undocumented andserialize()carried the wrong prose), and the batching narrative was a bare/* */note rather than doc comments onstartBatch/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.mts66/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 whilewidth()/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 wheretotalDegree()is the collection-wide sum, andfilter()'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.mtsand all seven layouts (the six built-ins plusForceLayoutImpl) documented, taking the public API tier to 100% (408/408). A third stranded doc block surfaced —setSheet()'s prose had drifted onto thecoreStylefield below it — the same failure mode as 26.1'sjson(), 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.registercounted againstAnimation), prose inside/* */blocks could parse as a member declaration (the style-getter narrative's literalrgba(...,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-simand 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 (movingimagedNodes/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.clearNodeclearing only the near side so the caller must cascade edges first,IdMap.idAtbeing the only place a JS string is materialized, and — a real surprise worth writing down —hierarchicalClustering'saddDendrogramoption 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(), theGraphStoreclass doc,setNodeImages,boundingBox,CurveBlob.free,CurveIndex.invalidateRelation, andcollection.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-existentalphaMinparameter,curved-arrow-pipeline'sendUniformscomment 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 13padwhen it has carried the round-13 D4 end-label param since D4, asettle()reference the code had renamed toreadPositions(),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/offoverloads 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.mjsrolls the prototype's declarations up (the existing pipeline, pointed atsrc/index.mts),build-dts.mjsgainedfinalizeGpuDts(the gpu entry is ESM-only — the./gpuexport has norequirecondition — so no export-assignment reshaping is needed, only the UMD global name),build:typesbuilds both entries, and the./gpuexport gained itstypeskey.Two tests: the
test:types:surfaceshape 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) andtypescript/tests/api.test-d.ts, a compile-only consumer test in the existingtest:typesproject. The comment pass pays off here: 1089 JSDoc blocks reachdist/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/offdeclared their middle argument asElePredicate | EventHandler, and a union parameter defeats contextual typing — socy.on( 'tap', ele => …, cb )gaveelean implicitanyand did not compile undernoImplicitAny. 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'sformatsarray.Recorded, not fixed:
event.targetisunknown, 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 fulltest:types:allchain 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, sincedocumentation/is v3's until then) plus 26.5's loggedevent.targetcall; 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; andAGENTS.mdgained the convention itself under rule 8 — a contributor tosrc/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.tsregenerated 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:allchain (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.mjsalone. - The obvious suspect was ruled out.
fuzzyCMeansis the only clustering spec that passestestMode: truewithouttestCentroids, so it falls through toMath.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.arrowShapesis 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.ypacks 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 sevenround-*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.barreland thetriangle-backcurvearrow both need a quadratic-bezier SDF — one primitive, two consumers. border-styleneeds a perimeter parameter the node FS does not have. The edge shader dashes for free because it carriesu(model px along the edge) as a varying;nodeSDreturns 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-rotationis one bit, not a value. The label sidecar storesrotate: 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-glyphf32, andGLYPH_WORDSis 14 with every word used.
Design calls (round 27):
- 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.
- v3's arrow-size formula, in model space. v4 sizes arrows
widthPx * 3 + 2in device px off the LOD-floored width; v3 usesmax( pow( width * 13.37, 0.9 ), 29 ) * scalein 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'sarrowScaleMaxslack meter must grow with it or arrows clip. - 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 sevenround-*keywords plusbottom-round-rectangleat one line each. - One quadratic-bezier SDF, two consumers —
barrel's four corner regions and thetriangle-backcurvearrow. 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. - Compound arrows are SDF unions,
min( sdA, sdB ), since coverage is a smoothstep over the distance. Recorded deviation:arrow-fill: hollowon a compound shape falls back to filled — the strokeabs( sd )is wrong at the seam between the two parts, and v3 does not stroke compounds either.triangle-crossshifts 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. - Numeric
text-rotationcosts a glyph word.GLYPH_WORDSgoes 14 → 16 (15 would break the 8-byte struct alignment) and the angle rides as anf32. 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, andcpu-pick.mtsmust gain an OBB test — it currently asserts that node labels never rotate. Recorded: nodeboundingBoxlabel terms stay axis-aligned-conservative rather than exact. border-stylegets the exact perimeter parameter, gated.uis 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.doubleis not a dash at all — a second inner band, no parameterization needed.outline-stylereuses the sameuat the ring radius, whose perimeter is offset and therefore a different length.- 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.arrowShapesnow 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 incontract.mtsbehindpackArrowShapes/unpackArrowShapeand 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.packArrowShapesthrows 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), andsetBorderGeomthrows past the field width too; its hardcodedshapeId === 14becameSHAPE_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.mjsadds 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.jssetsreuseExistingServer: !CI, so with anhttp-serveralready listening on 3333 Playwright attaches to it and thetest:playwright:buildhalf oftest:playwright:setupnever 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-rhomboidandconcave-hexagonare v3 point tables, so they are entries inPOLYGON_POINTSand 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-rectangleis not a unit polygon: v3 chamfers by an absolute length (getCutRectangleCornerLength()= 8 model px, or the element'scorner-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 matchingcpu-pickbranch. Its'auto'resolves to a flat 8 px where round-rectangle's ismin(w/4, h/4, 8): one prop, two defaults, as in v3, so the shader gainedcornerLengthPxovercornerRadiusPxand 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 thatcut-rectangleis deliberately absent fromPOLYGON_POINTS, the explicit-radius path, and that an unported keyword still throws.A
shapes-27golden shows all three, withcut-rectangleat 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 + 2off the LOD-floored device width; v3 usesmax( pow( width * 13.37, 0.9 ), 29 ) * scalein model units. The formula now lives inarrowSizePxin 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-dependenttriangle-crosswill need). And v3'ssizeis the point-table scale, not the drawn length — its transform scales the ±0.15 / −0.3 table bysizedirectly, 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 formo = 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-rectanglerides the round-rectangle field with the radius selected by the sign ofp.y.cpu-pickgained the matchinginsideRoundPolygon— 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'sgetRoundPolygonRadius=min(w/10, h/10, 8): a third meaning forcorner-radius, after round-rectangle'smin(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-roundgolden 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 withtriangle-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 intoBARREL_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-rectangleit is a parameterized shape rather than a unit table, andnodeSDgained azoomDprargument to resolve them.cpu-pickgainedinsideBarrel, built from the same constants. Whether the sampling is good enough was measured, not asserted: v3 draws the realquadraticCurveTo, 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-backcurveon 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-teeis a union of two generated polygons (min( sdA, sdB )— coverage is a smoothstep over the distance, so a union needs no stitching);circle-triangleis 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-backcurveneeded 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) andcircle-triangle(0.6) drew clipped.ARROW_MAX_BACKis now computed from the tables, so adding a head cannot silently clip it again, andtriangle-cross's bar adds the edge width on top. (b) v3 pullscircle-triangleback by its circle radius (the shape'sspacing— 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: hollowon a compound head falls back to filled — the strokeabs( 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 withNaNmeaning 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 was1, and 1 radian is a perfectly ordinary rotation (pinned by a spec).GLYPH_WORDSwent 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, andcpu-pickgained an OBB test — it previously asserted in a comment that node labels never rotate, which stopped being true here.autorotatestays 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-style— stopped for a scope call (2026-08-02), not for a technical blocker. The technique is settled.doubleis not a dash at all — a second inner band, no parameterization needed, and it works on every shape. Fordashed/dottedthe existingdashInsideSdmachinery 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,
barreland the custompolygonincluded), 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: shippingdashed/dottedon 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.
- closed form, cheap: circle (exact —
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.mjsre-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 torequestAdapter()returning null onabout: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-radiusprop 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 numerictext-rotationand its glyph-memory cost; and the border-geometry note now explains whyborder-styleis 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 listborder-style/outline-styleas 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 numerictext-rotationwere 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 toshape-points.mtsandcontract.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 anunbundled-bezierwithoutcontrol-point-distancesby the unbundled pair group and that v4 does not port it, when v3'sedgeIsUnbundledbranch assigns the plain step size and its staggerednormctrlptDistis dead on that path, so the two agree; the event section saidevent.preventDefault()"stays unported" withoriginalEventkeeping the DOM method, when in fact the method is present (v4 emits the shared v3Event) and silently does nothing because no v4 code readsisDefaultPrevented, whileoriginalEventis 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, yetroundrectangleis still accepted whilecutrectangleandconcavehexagonare 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) → assertsboundingBox().w === 100'picks inside the body and outside the cut corners'(cut-rectangle) → assertsboundingBox().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 acceptspan,zoom,fitandcenter(animation.mts), but not v3'spanBy.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-leveldata, 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.mjsgets the keyword-level ones, which run the whole public path — the sheet compiles, the style engine writesborderGeom,pickNodeAtreads the stored words — and each case is chosen to be a hit forrectangle(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 intoboundingBox()assertions.test/cpu-pick.mjsgets 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 explicitcorner-radiuspath; barrel's height offset capping at 15 px, shown by the same relative point picking differently at 100 and 600 px tall; andbottom-round-rectangle's asymmetry, whose two assertions fail forrectangleand forround-rectanglerespectively.The round family's spec is the one that needed rebuilding. Its point is that
insideRoundPolygonis 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.mtsand 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_resolveViewportTargetsbesidefit/center: the delta resolves against the pan at creation, which is v3's own rule (v3/src/define/animation.mtsnormalizespanByagainstcy.pan()when the animation is created, not per tick), so by the time the tween runs it is an ordinary absolutepantarget 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 onopts.panbefore resolving, so apanBywould have slipped past a disabledpanningEnabled. 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 —fitbeatscenterbeatspanBybeatspan— with one deliberate deviation, recorded: passingpanByandpantogether throws, where v3 silently preferredpanBy. 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 intest/viewport-animation.mjs(the delta; creation-time resolution, pinned by panning away beforeplay(); thepanningEnabledgate; the throw; andfitwinning overpanBy), 4 red before the change. 2340 Node tests, 63 module tests, typecheck, lint, JSDoc coverage 100%.AnimateOptionsis public surface, sodist/cytoscape.d.tsis regenerated andnpm run test:types:surfacere-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)panBymove 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, sincecy.json()already exports graph-leveldata. 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
panByin 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:surfacewith 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
srcmodule to test files that import it shows ~50 with no direct importer, but almost all of those (the algorithms, the layouts,core/collectionthemselves) 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 EXPECThits are helper-wrapped assertions (close()intest/curve-geometry.mjs, thethrows()helpers intest/mappers.mjs) and the rest are false matches on substrings (betweennessCentralitycontains "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:
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 separateCollection.prototype.each = Collection.prototype.forEachline. Deleting a wiring line leaves the typecheck green — thedeclarekeeps 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 siblingspoint/points/modelPosition/modelPositions,renderedCss,renderedBoundingbox,jpeg,invalidateSize, the British spellings (allAreNeighbours,degreeCentralityNormalised,closenessCentralityNormalised) and four algorithm aliases.Four public methods have zero mentions anywhere in the suite:
silentPositions,silentShift,delayAnimationandrenderedOuterHeight— the last a plain sibling gap, sincerenderedOuterWidthis tested one line away intest/collection-dimensions.mjs.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 bycy.filter/cy.nodes/eles.filter, thatcy.$is absent, that classes are gone, or thatz-indexis rejected by the sheet. A decision that is not pinned is a decision that regresses back in by accident.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, andcurve-geometry.mts+curve-index.mtsare 2.5k lines of it.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 theeachwiring 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) andrenderedOuterHeightintest/collection-dimensions.mjs,delayAnimationintest/animation.mjs. The silent specs assert the silence — apositionlistener 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, leavingrenderedOuterHeightin model units fails two, and givingdelayAnimationa 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/silentShiftmust move nodes without emitting position events (the whole point of "silent"), anddelayAnimationmust 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 threwqualifier.fn is not a functioninside the emitter, on the next tap — duringemit, so it takes the dispatch down with it. The guard now lives inpredicateQualifier, the one choke pointon/one/offshare. - 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.setSheetnow throws, naming mappers andcaseas the replacement. - The collection methods crashed on
other._refs— or, insame()'s case, quietly returnedfalse, which reads as working code. A sharedassertCollectionguard covers all twelve (same,anySame,contains,allAreNeighbors, the four set ops,diff,indexOf,edgesWith,edgesTo). Also improved: a selector string reachingcompileQueryreported "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.mjsthen pins the ledger: selector strings at every entry point, the absent class methods andcy.$, the sheet's rejection ofz-indexand the 2026-07-29 triage drops, the no-dash shape spellings (withroundrectangle'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, andqueue/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-indexin a sheet, and the per-element bypass setter. Each assertion cites the ledger entry it pins.- Event delegation with a selector string (
29.4 A curved-edge CPU benchmark (2026-08-03) — landed as
benchmark/curves.mjs, standalone and gpu-only likelabels.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 bulkpositions()write really is free (0.97×: the write defers), buthide()/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 tocy.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-graphboundingBox()1.16×,midpoint()1.15×, conservativefit()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 likelabels.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_WORDS14 → 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'sfit-allpair 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 compoundfit-alldevice 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--layoutmode 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 nowcurves) are standalone and absent fromreport.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
renderedOuterHeightshape).GraphStore.addEdgethrows on a nonexistent source and, four lines later, on a nonexistent target; only the target throw has ever fired in a test.renderedSourceEndpointis tested;renderedTargetEndpointis 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. Thebreadthfirstlayout'srootsrejection fires nowhere at all. - A README headline, unasserted. "the factory throws
synchronously when
navigator.gpuis missing" is the first thing the README says about headless mode.index.mtschecksoptions.container != nullbefore 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 callsani.stop()andele.stop()only. - Untested public options: the clustering
distancemetricssquaredEuclideanandmax(specs passeuclidean,manhattanand custom functions only). - Five
cy.png()/jpg()guards — invalidbg, afullexport of an empty graph, a zero-sized container, a destroyed renderer, an invalidscale— public contract, browser-testable. - Style validation (5 parser paths), the wire format's corrupt-buffer
guards,
mount()'s two guards, and thecontract.mts/table.mtscolumn guards.
Negative results, recorded so they are not re-run.
- All six standalone benchmark suites still run (
compaction,labels,transitions,geometry-tween,compound,curvesatBENCH_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 429set/default/parseEnumclosures report 155 as never-called while their props demonstrably round-trip in specs. Statement-level (DA:) data on multi-linethrowbodies 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.mtshas 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 insrcnow 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, thebackground-width/-heightsign 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 (columnSpecon an unknown id, a table asked for the other group's column); the wire format's two malformed-input guards; theclosenessCentrality/degreeCentralityroot preconditions; Karger-Stein's connectivity guard; the two selector-string rejections 29.3 missed; the headless/rendered boundary's four guards; andGlyphBuffer's stride check. Controls were run for all 20 — each guard neutered in place (throw→if( 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 ownnavigator.gpucheck 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 beforenew Core, element ingest and the ctorlayoutrun — 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; andbreadthfirstresolves itsrootsatrun(), not atcy.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
rendererproject 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 incomputeExportView). These are public contract —bgandscalecome straight from the caller, and the other four are states a real app reaches: an empty graph, adisplay: nonecontainer, 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: anhttp-serverwas already listening on 3333, which is the stale-bundle trapAGENTS.mddescribes, 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()andele.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 firstviewportemit 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'srenderedOuterHeightshape 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 passeseuclidean,manhattanor a custom function, sosquaredEuclideanandmax— 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-endkMeansspec for the option plumbing.Controls: 6 mutations run (stop made a no-op, then made always-jump-to-end;
renderedTargetEndpointpointed at the source end, then at model space;squaredEuclideangiven the square root,maxmade 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, mirroringscripts/jsdoc-coverage.mjs: run it bare for the tallies,--verbosefor every uncovered site,--lcov <file>to re-read a report instead of re-running the suite. It exportsaudit()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 therendererproject instead), andUNREACHABLE(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, soexportScale's guard inrenderer.mtsreads 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 inMISATTRIBUTEDwith 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 intest/modules/throw-coverage.mjs(the precedent istest/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.mdgains 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 (rawNODE_V8_COVERAGEoffsets; function-level records on one-line arrows). Verification for the round as a whole: 2482 Node tests, 68 module tests, 91/91webgpuand 75/75visualagainst a freshly built bundle, typecheck, lint,test:types:all, JSDoc coverage 100%, andgpu-throw-coverageat 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
cytoscapeand 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.tsis regenerated and committed (the comment is shipped hover text — the whole reason the defect mattered), andtest:types:surfacere-run: 37 type exports, 3 statics, 1093 doc blocks.2483 Node tests, typecheck, lint, JSDoc 100%, and — since this pass changes source — 91/91
webgpuand 75/75visualagainst a freshly built bundle. 31.2
@throwswhere 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 —
wheelSensitivitythrows on non-positive where the thresholds allow 0, andmountnames its three distinct failures — because "throws on bad input" in a comment is not worth the line.auditThrowTags()joinsscripts/jsdoc-coverage.mjsand its tally prints under the coverage report (--verboselists 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@throwscomplete maintains an existing gate rather than inventing a new kind. It is onedescribeblock 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 insrc/, so the browser suites are unaffected;dist/cytoscape.d.tsis regenerated and committed (1093 doc blocks). 2485 Node tests, 68 module tests, typecheck, lint.31.3
mouseoutandpointercancel(2026-08-03) — landed, 2 specs in therendererproject.mouseoutis the plain sibling gap: hover on, assertmouseover:a; move within the node and assert nomouseout(the half that makes it a hover-boundary test rather than a "some event fired" test); move off and assertmouseout:a.pointercancelis driven with syntheticPointerEvents rather thanpage.mouse, because the handler matches the cancel against the press'spointerIdand only a synthetic event lets the spec choose it.(
capture()already swallows thesetPointerCapturethrow 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 reportsdragfree— the drag aborted rather than completed — plus notapend, 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/freeonare emitted from two places —onPointerUpandonPointerCancel— 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:
mouseoutnever emitted → its spec fails;pointercancelnever emitted → 1 fails; the cancel path stops freeing → 1 fails; the cancel path also reportsdragfree→ 1 fails. 93/93webgpu(91 + 2).31.4 Closing docs sweep (2026-08-03) — the README header carries round 31, the JSDoc section gains the
@throwsrule 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.mdsays 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
@throwsmaintains an existing gate rather than adding a kind), and it is onedescribeblock to remove if the maintainer disagrees. Verification for the round: 2485 Node tests, 68 module tests, 93/93webgpuand 75/75visualagainst a freshly built bundle, typecheck, lint,test:types:allwith the regenerateddist/cytoscape.d.ts, JSDoc coverage 100% and@throws16/16, andgpu-throw-coveragestill 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()'sjumpToEndis 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 optionalcriterion, 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'sdimcovers read-pair / read-axis / write-pair, andstyle()'svalueis 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:nowis 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".setPositionsdocuments the packing (xy[i*2]lands onslots[i]) — the one thing a layout author must get right — andrefreshMappedsayskeysis 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()joinsauditThrowTags()inscripts/jsdoc-coverage.mjs, overload-aware through the same regexes, public tier only, printed under the coverage report and listed by--verbose. Gated intest/jsdoc-coverage.mjsunder 31.2's reasoning: documentation completeness is already a gated concern here, so this maintains an existing gate. Controls: a@paramline deleted → 1 failing; an undocumented parameterized member added toviewport.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%,
@throws16/16,@param221/221, and the regenerateddist/cytoscape.d.ts(comments only insrc/, 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.--fulladds the 20k/200k matrix, withBENCH_OPsplittingmutators/scenariosone 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). - Browser —
render-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:
- Layouts: nothing at all. Six built-ins (
grid,preset,circle,concentric,breadthfirst,random) plusforce, 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--layoutforce-vs-cose mode, run once on the RX 580. ThelayoutPositionsplumbing (spacingFactor/transform/fit, the animated path) and the round-17 contract'sctx.setPositionsare likewise unpriced, and the contract is the surface external authors build on. - The algorithm tail.
algorithms.mjsprices 18 rows covering 17 of the 21 algorithms:kMedoids,fuzzyCMeans,affinityPropagation,kargerSteinand unnormalizeddegreeCentralityhave no row, and the weighted variants ofbetweennessCentralityandclosenessCentrality(the branch that actually runs a heap) are only exercised unweighted. - 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 intransitions.mjs, but only as a transitions-off-vs-on ratio, and inscenarios.mjsas one step of the refresh trace. - 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 threeoptions.elementsforms 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. - CPU picking.
pickNodeAtis the pointer layer's hot path and is priced only insidecompaction.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 withtext-eventslabel boxes in the scan, or at different zooms — and 28.1 recordedinsideRoundPolygonas the one shape test that is not affine-invariant, so zoom is a real axis. - Box selection.
elementsInBox/refsInBoxappears once, as a curve premium row incurves.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'sgetAllInBox, are unmeasured — as are the round-16.5 label-containment term and the round-20.2 interactive filter. - Bounds and fit. The whole-graph scan is priced only in
fragments: label terms in
labels.mjs(~0.1 µs/label), the curve premium incurves.mjs(1.05–1.16×), a parity control incompaction.mjs. The pass-1 fast-path figure (ndex 235 → 15 ms) is another one-off. Nothing pricesboundingBox/fit/getFitViewport/boundingBoxAtagainst v3 across graph sizes. - The data() sidecar. One
data setrow inmutators.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-objectdata()reads, and theDataStore.readerhoisting the scan paths depend on. - Queries beyond flag scans.
materializers.mjscovers 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. - 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.
- The animation manager.
transitions.mjsandgeometry-tween.mjsprice 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. - 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-valuesvia the{ data }passthrough refreshes per data write and is unpriced. On the device side thegen-25k-imagesscene has never been measured — 15.7 recorded "software adapter on this box", which 2026-08-03 corrected as wrong for the third time. - 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, theAdjacency.rebuildtwo counting passes,CurveBlobwaste reclaim, andDirtyTrackerspan coalescing are all unpriced, and they are what every bulk path funnels through. - Renderer-bench gaps. Beyond the images scene:
--layouthas 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.2eventspick mode, or a label-heavy wrapped configuration — the one round 25.6 named as the expensive case. - 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.htmlsees 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 againstdocumentation/-era fixtures and the v3 API, and v3 is frozen until v4 ships. v3 comparisons belong inbenchmark/, wheregraph.mjsalready builds one element list for both factories.- The anti-hoisting methodology does not need revisiting.
core.mjs/collection.mjsrotate operands over a pool of K = 8 so V8 cannot hoist a pure loop-invariant call out of the measured region — the fix that stoppedsame()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)
- 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. - 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.mdor 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). - Standalone suites join the report; the report gains a profile
that runs everything.
report.mjs's job table takes all fourteen suites, with agpuOnly: truemarker for the ones that have no v3 side, soreport-html.mjsrenders them as absolute costs rather than against a 1× reference line that means nothing for them.--allruns every suite plus the renderer bench. This closes open call 7 with the answer the user's scope implies. - 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.
- 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.
- 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 viaBENCH_OP— with thereport.mjsop tables kept in step with the group names, which is the coupling that already exists formutators/scenarios. - 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.
- A benchmark-coverage audit ships, and reports only. The third
tool in the
gpu-jsdoc-coverage/gpu-throw-coveragefamily:scripts/bench-coverage.mjsmaps 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: falseand a shared explicitboundingBoxon 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 aneles.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
forceCPU executor runs 20 iterations in 4.81 ms (29.5 ms at 2k) against v3coseat 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.
presetfirst read 2388×, which is not a layout result: with nopositionsv4'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 realpositionsmap, 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 evaluatescy.elements()and.nodes()to populate the handle-tierctx.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, andelesis a declared public field of the shipped declarations.Also priced: the
layoutPositionsfinisher 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 checkpresetfailed. 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:kMedoids1.25×,fuzzyCMeans1.11×,affinityPropagation1.64×,kargerStein1.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
betweennessCentrality8.9× and weightedclosenessCentrality, 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. UnnormalizeddegreeCentralityjoins 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
- so the rows measure the algorithm and not how long each
implementation happens to wander;
kargerSteinis 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 hadkMedoidsreading 1.4× slower on v4, which did not survive mitata warming both sides — the 29.4 lesson, reproduced.
- so the rows measure the algorithm and not how long each
implementation happens to wander;
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 batchcy.style()compiles and validates and defers the apply), so the whole 14 ms is the apply. The first apply of a 256-node band onadd()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 importssrc/through tsx, and profilingreadPropfor round 34 found 23% of its samples in__name— esbuild's name-preserving wrapper, anObject.definePropertyper closure creation, injected by tsx and absent from the built bundle. Throughbuild/cytoscape.esm.mjsthe 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 insidereadProp, 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×,numericStyle13×,renderedStyle2.0×, whole-objectstyle()2.2×. Localized, not just observed: the cost is entirely insideStyleEngine.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:readPropis 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, andrenderedStyle/numericStylesit on it. Logged, not fixed: a measurement round measures, and hoisting the closures out ofreadPropis 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, whichmutators.mjshas 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:
toColumnarElements789 µs,serializeElements967 µs from definitions and 211 µs from columnar, anddeserializeElements4.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× andcy.serialize()5.5× cheaper thancy.json()on the same graph; a 256-node bandadd()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
styleEnabledto false, so the v3 side was doing less work than v4 (which always applies its sheet); and v3's default layout is grid, socytoscape( { elements } )ran a whole layout inside the measured region. The v3 side is nowstyleEnabledwith an explicit preset layout — the configurationscenarios.mjsandlayouts.mjsalready 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, becausefindNearestElementandgetAllInBoxlive on v3's canvas renderer and a headless v3 instance has neither (cy.renderer()is a bare object on which both areundefined). 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-rectangle97 µs, custompolygon212 µs,star242 µs,barrel789 µs,round-hexagon823 µ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: yescosts 2.9× on the miss walk (the laid label box joins the scan per candidate). Box selection.elementsInBoxis 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-graphboundingBox()6.2×,cy.fit()33×,getFitViewport()35×, one node'sboundingBox()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:
boundingBoxAt1.65 ms whole-graph,labelBoundingBox465 ns per element. Two rows were void before they were fixed, and one of them was already shipped elsewhere. (a)elementsInBoxtakes 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 — andbenchmark/curves.mjshas had the same bug since round 29.4, in a numbersrc/README.mdpublishes.Fixed and re-measured in its own commit (33.5b below). (b) the custom-
polygonrow 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×.removeDatais 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 writesdata()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 inmutators.mjsrests 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 (2000sets), and its single-key ops arehas55 ns /get62 ns /hashAt7.6 ns /idAt9.2 ns — the last of which is the memoized hit, sinceidAtcaches 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:
outDegree6.5 ns — the O(1) claim, measured —outEdges48 ns,connectedEdges183 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), readsoffsetOfin 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
chartsheet 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'schart-valuesis 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/renderedMidpointand 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× andviewport()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 iselements(), 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 thetakesUpSpace/interactive/transparenttrio 11× — all three read through the style engine, which is 33.3'sreadPropfinding arriving from a second direction.json()/jsons()are ~1.2× v3's way, the columnar rebuild-the-object cost 33.6 found ondata(). Two rows are named for their mechanism rather than their multiplier, because the multiplier is not about v4: coredata()andscratch()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.mjsnow 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 pluscompaction,compound,curves,labels,transitions,geometry-tweenand 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.mjsandlabels.mjstime 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), whichreport.mjscannot read (it needsjob.groups).finishManualRun( suite, groups )inbench-run.mjsturns one-shot rows into that shape viaoneShotStats, the convention the renderer bench already uses for its init/export timings.No suite's terminal behaviour changed: without
BENCH_JSONit writes nothing, exactly likefinishRun. The renderer already handled gpu-only groups (benches not namedv3/gpurender as individual labelled rows rather than as dumbbells against a 1× line that would mean nothing for them), so nogpuOnlymarker 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 labelsruns the suite and its five rows appear inreport.html.test/modules/ gpu-benchmark-report.mjsgains three specs (the manual-run shape, the no-BENCH_JSONno-op, and a single-bench section rendering) — 14 module tests, with the control run: breakingfinishManualRun'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) andgen-25k-invisible(round 22's paint-onlyvisibilityand round 20.2'seventstransparency, half the nodes each, expressed ascasemappers 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_DRAWNmask 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 onlysrc/file this round touched issrc/README.md, and the check compares mtimes without distinguishing docs from code; and--scene gen-25kis 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/collectionshareindex.mjs), the three report profiles, thefinishManualRunnote, 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.mdgained 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 theelementsInBoxsignature trap that had been live incurves.mjssince 29.4), and a v3 side needsstyleEnabledand an explicit layout, because the two defaults bias in opposite directions.scripts/picked up the third audit in the repo-structure list, andpackage.jsongainedbenchmark: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
- 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):
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,effectiveOpacityand thetakesUpSpace/interactive/transparenttrio all ride it. Biggest surface of the five.- 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.
LayoutContextmaterializes 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 evaluatescy.elements()and.nodes()for the handle tier, including for the columnar-first layouts the contract exists to encourage.mutableElements()does the same per call — 251 µs against v3's 120 ns.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),
--allis 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.mjsbuilds one degree-4 grid graph; the new suites need compound, curved, labelled and imaged fixtures. These should extendgraph.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 theBENCH_OPtables inreport.mjsare 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):
- 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. - 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.
- The order-list scan is the contract for "all elements in order".
nodeSlots()currently walks handles; the replacement walks the same insertion-order listscanRefsIntowalks, so layouts see identical order — which matters, since grid and circle assign positions by index. - No public semantics change; one public shape change. Making
LayoutContext.eles/.nodeslazy turns two readonly fields into getters, which is a.d.tsshape change (property access is unaffected).dist/cytoscape.d.tsis regenerated andtest:types:surfacere-run. Wrong, as it turned out (34.6):LayoutContextis 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.tsis the store's two new members (structureEpoch,scanSlotsInto), sincecy._storeis 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.mdnote about benchmarking transpiled sources. -
34.1
indexOfis O(1) (2026-08-03) — the lazily-built packed-key membershipSetbecame aMapfrom key to first index. Set membership only ever asks.has(), which a Map answers identically, so the ten set-op call sites are untouched;indexOfnow 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.indexOfIdis 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 intest/collection-reference.mjspinning 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) —GraphStoregainedstructureEpoch, 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 formutableElements()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 andnodes()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:
addone,removeanother 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 thefreeSlotbump 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:
_emitOnElereturns before building the event or walking ancestors when nothing listens for the type. Sound because v4's emitter never bubbles to a parent (bubbledefaults 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_emitOnEleat all.The position writers already gate on
hasListeners( 'position' )(as doadd,remove,dataandmove), 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:
_emitOnEleitself 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 ungated —mouseover/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 intest/compound-events.mjspin 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/nodesbecame lazy getters, so a columnar layout that never asks for handles never builds them; andnodeSlots()/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.scanSlotsIntois the slot-only twin ofscanRefsInto— same walk, same(mask, want)test, noRefallocated — 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.nodesstill 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 intest/layout-contract.mjspinnodeSlots()/edgeSlots()as exactlycy.nodes()/cy.edges()order, the locked/parent exclusions, subset order, and thateles/nodesstill 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
scanSlotsIntoabovescanRefsIntostranded 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 insidereadPropbecame module-level helpers taking(store, slot, id). Under tsx this is 1848 ns → 255 ns — because each closure creation also paid esbuild's__namewrapper — 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% ofreadPropinnormalizePropand another 4.5% in its([A-Z])regex: every style read was doing a regex replace and a lowercase allocation to turnbackgroundColorintobackground-color, before the 145-case switch it precedes. AMapcache (bounded at 512 entries, since an unknown name is normalized before it is rejected) takesele.style( 'background-color' )from 292 ns → 122 ns against v3's 52 ns — the gap goes 5.8× → 2.3×.numericStyle215 → 84 ns,effectiveOpacity240 → 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 displacednormalizeProp's JSDoc), caught by the gate again.34.6 Verification + closing sweep (2026-08-03). The five, before and after, through
build/cytoscape.esm.mjsat N=2000 (thestylerow 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 listening338 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
@throws16/16 and@param221/221,gpu-throw-coverageat 0 Node-reachable dead sites, the regenerateddist/cytoscape.d.ts(1093 → 1097 doc blocks — the store's two new members) withtest:types:surfaceclean, and — since this round changessrc/— 168/168 browser specs acrosswebgpuandvisualagainst a hand-rebuilt bundle (anhttp-serverwas listening on 3333, which is exactly the standing trap, sotest:playwright:buildwas run by hand first).Goldens are byte-stable and the parity scenes read their recorded values (
parity-charts-pie0.000%,parity-casing0.061%,parity-polygon0.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
Mapdispatch 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:
- The switch becomes a
Mapfrom property name to a reader function —( engine, ref, store, slot ) => value— built once at module load. Dispatch is oneMap.getplus a call, the same for every property. This is the structure the code already is; the round makes it data instead of control flow. - 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.
- Fall-through groups stay one reader with several keys, so the 19 grouped labels do not become 19 copies.
- 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 readundefinedare 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_READERSis a module-scopeMapof 111 readers over 150 labels (nine readers deliberately answer several labels), andreadPropis now 60 lines: the guards, then aMap.getand 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 narrowReadContextper instance — arrow functions capturingthis, withstore/defsas accessors because a sheet swap replacesdefswholesale 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.getcosts 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, sincestyle()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
@throws16/16 and@param221/221, throw coverage 0 dead,test:types:surfaceclean (1098 doc blocks; the.d.tsgained only the privatereadCtxline), 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 percy.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):
The
@returnstail 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@returnsis editor hover text where a missing@paramis 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@throwsand@paramare both complete.Re-measured against the value-returning public members (a member whose signature carries a return annotation that is not
voidand notthis): 206 of 264 tagged, 58 missing —collection.mts30,animation.mts11,layout/contract.mts7,core.mts5,viewport.mts3,style.mtsThe browser-only throw tier was opened and half-closed.
gpu-throw-coverageclassifies 13 sites as needing a device, a canvas or a pointer, and round 30.2 pinned six of them — thepng()/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, andimage-decoder's two. Round 30's own record says the browser tier "is pinned in thewebgpuPlaywright project instead", which is true of the export guards and of nothing else.Three promised measurements were never recorded. (a) The renderer benchmark's
--layoutmode 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-objectstyle(), butbenchmark/style.mjsandsurface.mjs— the suites whose rows exist to notice a regression on exactly that path — have not been re-run since the dispatch table landed.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:
@returnsis 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@returnsgets thegpu-throw-coveragetreatment instead —auditReturnTags()prints its tally under the coverage report and--verboselists 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.- 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.
- 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.
- 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, andreturnAnnotation()walks paren depth to the matching close, since( fn: ( a: X ) => Y ): Zhas 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 reportedAnimation.lastNowas returning the prose of the doc comment below it.CALL_MEMBER_REnarrows the class-member branch.12 fixture specs, and one of the four controls came back BAD — making
VOID_RETURN_REmatch 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
@returnstags (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 (effectiveOpacityis whattransparent()tests and is notstyle('opacity');grabbablereads false for a pannable element whilejson()reports the raw field); the predicates that are not the negations they look like (inactiveis not!active,isChildlessis not!isParent,isOrphanis not!isChild); thatremove()returns a collection which can be larger than its receiver and whose refs are dead by construction; and that the layout contract'spositions()/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 aboveStyleEngine.lineOpacityConstwith 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.tscarried both blocks stacked, so a consumer hoveringlineOpacityConstread a paragraph about arrow colours first. Round 31.1's defect class, live.(b) The
@paramgate had never walked exported functions —auditParamTagsdescended class bodies only, while this script's own header defines a public member as a class member "plus every top-level exported function". Sowire.mtsandcolumnar.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@paramat 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
allAreandis(2026-08-04) — landed insurface.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 apair()helper besidecmp()/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 assertsHTTP 404inside the warning so it pins that guard rather than "an image failed somehow".Classified UNREACHABLE with reasons:
gpu-context:38is shadowed by construction (_attachFnchecksnavigator.gpuand then synchronously constructs the Renderer, whose ctor callsinit(), whose first statement readsnavigator.gpuagain — nothing can run between the two);column-mirror:113is a column spec/group mismatch no public input chooses;gpu-tween:408says so in its own comment, barred one layer up by the round-25.1 eligibility rule. A tool bug fell out of the classification:browserandunreachablecounted the same site twice once three sites were in both, so the tallies summed past the site total (191 reported as 176 + 13 + 5).unreachablenow wins, as it already did in the--verboselabels. 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 (97webgpu+ 75visual), 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'sforceconverges 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). v3cosereports "> 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,tscand 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,--all17.4 min.The runner prints its own total, so this was always one run away.
--fullis 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.mjsis now that source and joins--all; it importsbuild/cytoscape.esm.mjsand warns when the bundle is older thansrc/.Running it under
--import tsxwas measured to be identical rather than assumed safe (the__namewrapper 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-color118 ns andborder-color116 againstborder-width64 andwidth61 — and those two colours sat at opposite ends of the old switch, so it is not residual positional cost.background-colorwas 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 aboveGpuTweenSink's; theedge.dashPatterncolumn's above the casing column's — which itself sat aboveedge.gradient, so two columns wore the wrong prose and two had none;LabelEntry's aboveLabelStream'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; andwriteImages' abovewriteChart's. All six moved back. The seventh hit iscurved-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, itssednever 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@returnsand widened@paramrules in the JSDoc section, the finished browser tier and the corrected tallies in "Measuring the error contract",style-bundle.mjsin 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.mdgains 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.tsregenerated (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:
border-style/outline-style: full coverage — every shape, the polygon perimeter tier included (round 38).- 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. - The v4 Event is built — typed
target, populatedoriginalEvent, functionalpreventDefault()(the maintainer's amendment to the proposal, which had dropped it), no namespaces (round 41). - Packaging: v4 becomes the package. v4's source promotes from
src/gpu/tosrc/and becomes the default export ofcytoscape@4; the entire v3 file set moves into a self-contained, still-buildablev3/directory (parity and comparison benchmarks keep working against it), and no v3-specific file remains outside it (rounds 42–44). - Gates: throw coverage and
@returnsboth gate (round 37); stranded blocks and bench coverage stay report-only. - Aliases split:
roundrectangledrops,autolockNodes/autoungrabifyNodesare kept as recorded exceptions (round 37). - 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. - 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. - 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 anaudit()result into the build's failures, the CLI exits nonzero on any, andnpm run test:throwsjoins thenpm testchain aftertest: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).@returnsratchets at 276/276 intest/jsdoc-coverage.mjs, beside@throwsand@param.UNREACHABLE/MISATTRIBUTEDare 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 athrow newline, 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 byfile: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
@returnsgate was run once with a tag deleted fromviewport.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.
roundrectanglethrows, joiningcutrectangle/concavehexagon, and its line intest/decided-drops.mjsflips 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 nodeshapetable,parseLayerShape(overlay-shape/underlay-shape) andTEXT_BG_SHAPES(text-background-shape). Dropping it fromshapealone 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
shapeerror 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/autoungrabifyNodesstay wired and pinned, and the alias table's comment changes from "an open call, recorded beside theroundrectangleone" 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.mtsbroke 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 theroundrectanglerow 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 theUNREACHABLEallowlist it broke then, and the gate 37.1 built to catch that — and the same fix applies: afile:linewritten 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-errordirectives intypescript/tests/api.test-d.tspin it —motionBlur,hideEdgesOnViewport, a plain typo, and one through the namedCytoscapeOptionstype — 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
cytoscapehad no@param— and yet@paramreported 229/229.src/index.mtshas been listed inPUBLIC_APIsince round 26 and contributed zero members to every audit, because the exported-function pattern round 36 added matchesexport functionandexport const f =but notexport default function. So the package's entry point — the most public member in the tree — sat outside coverage,@param,@returnsand@throwswhile 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#emitandCollection#onnow 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 fortapand not fortap.nseither", 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 semantics —on('tap.ns')listens fortapqualified by.ns,emit('tap.ns')runs both it and any plaintaplistener,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
datawrite 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 namespacesparagraph 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.mdgains the two gates in the places that asserted the opposite — "it reports, it does not gate" and "@returns… reported 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 custompolygon) 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 (theu-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 atborderWidth / 3underdestination-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/-offsetport, andborder-cap/-joindrop with the deviation recorded. What remains for docs-first istext-border-styleand the write-up.outline-stylereuses 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-stylestays 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) andborder-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.
perimeterCoordper 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), andoutline-style: doubledraws 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'srefsInBoxtakes the mode, andGraphStore.edgeHitsBoxis the new exact test. The CPU twin was extracted, not invented:segmentHitsBoxincurve-geometry.mtsissegmentHitsViewportfromrender/cull.mtsline 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
elementsInBoxcall, 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
_elementsInGestureBoxso they cannot drift.boxSelectionIncludesLabelsreverses 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 tocy.json(), matchingboxSelectionIncludesLabels: 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'seventsprop 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 usedcurve-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-bezierfixes 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. Awebgpugesture spec runs the same shift-drag under both modes. The round-37.1 gate fired twice, correctly: edits tograph-store.mtsand towire.mts's header comment moved twoUNREACHABLEsites out from under theirfile:linekeys, 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.ColumnarElementsgains an optionaldata,cy.serialize()fills it (copied, not held by reference — the buffer is a snapshot), anddeserializeElementsreads 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.elementsapplies 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 toadd()— 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 nodata()serializes to exactly the byte count it did before this round.39.3
cy.gc()(2026-08-04) — landed. The explicit alias ofcompact():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 atremove(); the slot-stable structures self-compact on their own thresholds since round 11).test/decided-drops.mjshad a spec assertingcy.gc === undefinedalongsidewarnings/notify/noNotifications; it splits in three —notify/noNotificationsstay absent with their reason,gcflips to pinning the alias, andcytoscape.warningsgets 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: thedeclarewas first writtenthis[ '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
webgpugesture 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, thecy.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):
- A two-tier taxonomy — contract 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.mjsenumerates them, so the review is a pass over a list that already exists. cytoscape.warnings()'s shape — boolean toggle (warnings(false)silences the warn tier, v3's surface), or an options form (warnings({ demoteErrors: true })/ anerrorPolicyctor 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.- 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
errorevent 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.
- GPU acquisition (5):
index.mts×2 andgpu-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.) - 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 resolvingnullwith 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,
srcstill importssrc/math.mjs,src/types.mjs,src/util/colors.mjs,src/util/position.mjsandsrc/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.mjsis 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- v3 never reads
isDefaultPreventedeither, 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.
- v3 never reads
41.1 The v4 Event (2026-08-04) —
src/event.mts. Typedtarget(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 itsas),originalEvent,layout, the derivedrenderedPosition, and nonamespacefield.41.2 The v4 emitter (2026-08-04) —
src/emitter.mts, the same qualified-listener model with v3's namespace parsing,bubble/parentrecursion,manualCallbackand 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,oneremoving before the callback runs, and a handler returningfalsemeaningstopPropagation(). One recorded difference from v3, and it is a fix: v3 snapshots the listener list once peremit()call, so inemit( 'a b' )a handler forathat callsoff( 'b' )does not stopbfiring. 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 onelisten()wrapper rather than at ~25 emit sites, so the two cannot drift; cleared in afinally, 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. Awebgpuspec asserts the DOM event's own coordinates reach the handler (so it is this event, not a retained earlier one) and thatpreventDefault()setsdefaultPreventedon it; the control pinsdomEventto 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,EventTargetandEventHandlerare exported from the entry point, so a consumer can type a handler;dist/ cytoscape.d.tsregenerated (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;originalEventpopulated 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.mtsimport — 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, andoff('tap.ns')removes it. So this is a behaviour removal with existing specs to update (intest/decided-drops.mjs), not a dead-code deletion. An audit pass confirms no othersrcimport reaches outsidesrc(the restructure's precondition, asserted by a spec that walks the import graph). - Ships in
dist/cytoscape.d.ts:event.target: unknownresolved, 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'ssrc/, its tests,documentation/, the top-level v3 benchmark suites, the v3 debug pages, the v3 rolldown/dts configs, and the stale hand-written rootindex.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 fromv3/; nothing v3-specific remains outside the directory.src/gpu/*promotes tosrc/(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 thegpu-file-name prefixes stay (history) or drop (tidiness) is a docs-first call — the audits' file lists change either way.- Root
package.jsonbecomescytoscape@4.0.0-unstable(round 49 settles the prerelease spelling): v4 ismain/module/typesandexports["."]; the./gpusubpath 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-*.mjs→test/*.mjs,test/modules/gpu-*→test/modules/*,benchmark/gpu/→benchmark/,scripts/gpu-*→scripts/*,debug/webgpu/→debug/,playwright-tests/webgpu.spec.js→renderer.spec.js,webgpu-visual.spec.js→visual.spec.js, and the Playwright projects with them (renderer,renderer-webkit,visual). Insidesrc/thegpu-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.mtsis the contrast), which is a live distinction rather than a v3-era label.(42.6 found one of those five wrong:
gpu-types.mtsholds the public option surface and is not a device module at all — it ispublic-types.mtsnow. 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.mtscarries the seven functions v4 actually calls, copied verbatim, rather than v3's 1500-line file: not tidiness but scope, since the audits walk everything insrc/and the wholesale copy would have added a thousand undocumented lines to the internal tier.util/colors.mtsneeded one edit —is.array→Array.isArray— which is what let its v3is.mjs→window.mjs/event.mjstail be dropped.The v4 identity rename, which the plan did not anticipate and which follows from
exports["."]: bundles arebuild/cytoscape.*, the declaration isdist/cytoscape.d.ts, the UMD global iscytoscape, and the default export iscytoscape( options ). The two runtime error messages and the JSDoc that namedcytoscapeGpuwere rewritten with it, on round 31's rule that a message advising a form that no longer exists is a defect a consumer sees. TheGpu*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-writtenv3/index.d.ts, its rolldown/dts/tsconfig configs and its ownpackage.json.cd v3 && npm run buildworks off the rootnode_modules(npm puts every ancestor's.binon PATH), andcd v3 && npm testruns its 698 Node + 37 module + 18 chromium specs. v3's Playwright serves port 3334, not 3333 — with both on 3333,reuseExistingServercould silently attach one project's specs to the other project's server, which is the stale-bundle footgun inAGENTS.mdwearing a worse hat.42.3 Root
package.jsonis v4's alone —cytoscape@4.0.0-unstable, v4 asmain/module/typesandexports["."],./gpuretained as a deprecated alias resolving to the same files,unpkg/jsdelivrat the v4 min bundle, and the v3-only devDependencies (handlebars, marked, highlight.js, gh-pages, benchmark, lodash, heap, …) moved tov3/package.json.tests.ymlgains aci-v3job.42.4 The pieces that needed more than a move.
playwright-page/parity.htmlloads both UMD bundles, which now both export the globalcytoscape, so v3's is captured under its own name before v4's script overwrites it. It reads v3's bundle fromv3/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.mjsis 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 intov3/.build-dts.mjs,rolldown.config.mjs,rolldown.dts.config.mjs,playwright.config.jsand 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 outsidev3/" — 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:GpuCore→Core,GpuCollection→Collection,GpuEvent→Event,GpuStylesheet→Stylesheet,CytoscapeGpuOptions→CytoscapeOptions, and so on through all 42 exports. No deprecated aliases, unlikeexports["./gpu"]: that alias exists because v3's users already type the name, while nobody has yet writtenGpuCoreagainst a published build. Six names keep the prefix —GpuContext,GpuTimer,GpuForceRuntime,GpuTweenRuntime,GpuTweenSink,GpuWriteKind— on 42.1's rule: each names the device half against a CPU counterpart (GpuWriteKindis literallyExclude<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) andForceLayoutOptions(a module-local interface inlayout/force.mts) would have become the same name; the internal one is nowForceRunOptions.GpuWriteKindwould have collided withanimation.mts'sWriteKind, 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.
EventandEventTargetare globals. Taken anyway, because they are the right names for cytoscape's own event object and itstargettype, and v3 spells its own event classEventtoo. It costs nothing insidesrc/:event.mtsalready isolated the DOM type behindtype NativeEvent = globalThis.Event, and the only module using the bare DOMEvent(interact/pointer.mts) does not import ours. A consumer who importsEventfromcytoscapeshadows the global in that file, which is their choice to make.gpu-types.mtswas 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. Renamedpublic-types.mts, which is what v3 calls the same role — and which is available precisely because the vendored geometry types tooktypes.mts. Three internal fields went with them (_gpuPhaseRef,_gpuPhaseEle,_gpuBarred→_phaseRef,_phaseEle,_barred), and the rename was applied to PLAN.md,src/README.mdandAGENTS.mdon 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, fromsrc/gpu/README.md) andAGENTS.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 thev3/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 rootREADME.mdis v4's; v3's moved tov3/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:
- Four of the seven networks 404'd.
debug/networks.jspointed at../webgl/*.json, which resolved todebug/webgl/until round 42 moved the v3 tree tov3/. 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 HTMLsrc/hrefattributes, and these URLs are fetched from JS. sanitizeStylethrew the styling away — a 14-property whitelist, everymapData(...)dropped, every[attr = …]block dropped, and it looked forlabelwhile all four Cytoscape-desktop exports spell itcontent. 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.- 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. - 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 underv3/; a copy would double the repo's weight, and a v4 harness reading a v3 asset is whatbenchmark/graph.mjsandplaywright-page/parity.htmlalready do). The fetch gained a.catchthat writes the failure — and the reason — into the stats overlay.?layoutand?seedjoinedparamDefsas live params, so a control change stops silently wiping them. The overlay reads the publiccy.renderer()rather thancy._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 keptid/source/target/positiononly, 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 nodename(gene symbols) andNode_Type('TF'on 6052 of 19607) and edgeMechanism_of_Action(−1..1). One edge channel, not two, and the reason is worth recording: at 465k edges the key name is the cost, soLikelihoodwould 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_Actiondrives a diverging paint mapper, which is what the GPU eval kernel actually demonstrates at this scale. Edgeids are dropped (v4 assigns one), which pays for about half the increase: 28.6 → 34.1 MB. 43.3 Hand-authored styles.
sanitizeStyleis 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.jscarries one native sheet per fixture plus aplainsheet (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'snetwork-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
divergingmapper 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) andnode:selectedrestyling (no selector blocks; the accent is shader-drawn).43.4 Two real compound networks.
compound-fixtureports 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-clusteredmaterialises EnrichmentMap's ownmcode_cluster_idinto 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/panBytargets 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'slayoutstart/layoutstoptiming readout), the core toggles (v3's seven plus box selection,selectionType, the zoom range, wheel sensitivity, and the v4-onlyboxSelectionModeandboxSelectionIncludesLabels), v3's data-drivenbutton.togglerpattern (dispatch on the button's own text) extended with select/show/hide/remove, add/remove with itsmsreadout plus acompact()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.
showActiveBgwas 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
panmode while nothing pans, which takes bothuserPanningEnabled: falseandboxSelectionEnabled: 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.mjsloads 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), whichele.id()returns — now'cy-', and no spec asserted it — and the round-42 note under the directory layout still listedgpu-typesamong the names that deliberately keep the prefix, which 42.6 had renamed topublic-types.mtsfor exactly the opposite reason. One leftover of the round-42 split: the root'stest:buildscript was copied across whole, butTEST_BUILDis read byv3/src/test.mjsand by nothing in v4 — so the script silently re-ran the ordinary Node suite whileAGENTS.mdclaimed 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.tsandtest/modules/gpu-import-graph.mjsinsrc/README.md, three pointers at v3 sources that had moved underv3/, and a line-wrappeddist/cytoscape-gpu.d.ts. Extracting every rooted path from the markdown and testing it withexistsSyncfound all of them at once. That check is now a rule inAGENTS.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) anddist/is not in.gitignore, so the convention is commit-at-release and v4 inherits it unchanged. v4'sdist/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-runshipped 104 files / 2.4 MB —src/(100),package.json,README.md,LICENSEanddist/cytoscape.d.ts. (106 / 2.5 MB since round 47 addedMIGRATING.mdandCHANGELOG.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 bytest:modulesand so bynpm 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 (andsrc/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 nov3/file ships..npmignoreis 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.npmignore→filesmigration 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/exportsnames must be produced by a build script — proved statically againstdist:copy's own argument list and against the realrolldown.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 pinsdist: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:exportschecks only the v3 d.ts", which reads as though the root had one to widen. It does not — round 42 moved that script tov3/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;
typesis first wherever it appears (TypeScript takes the first matching condition, so atypesafterimportis silently never used);importresolves to.mjs,requireto.js,typesto.d.ts; the legacymain/module/typesfields 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.mdis 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 withoutnavigator.gpu— andenginesisnode: >=24. Nobrowserslistwas 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:copydropping one bundle fails 2 specs (the copy check and the manifest check, sincemainnames it);.npmignoreno longer excludingtestfails 1;.npmignoreexcludingdistfails 1; anexportssubpath naming an unbuilt bundle fails 1;typesmoved afterimportfails 1;maindisagreeing withexports['.'].requirefails 1;./gpudrifting from.fails 1; andunpkgnaming a filedist:copydoes 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.shshould run this spec afternpm run distso 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, withpureAliasescarrying v4's 84declare x: this['y']aliases the way v3's docmaker carries its own. It extends the existing scanner rather than adding a second one:auditFilenow 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:descris the first paragraph,formats[i].descrthe whole block. docmaker'sdescris 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/@seeare 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.mtshangs it on the factory — which is what separatesserializeElementsfrom 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 anymdfield (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 againstdist/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:docsprecedent 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@paramsplit (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.mtswas outside the audit's public tier whileEventis 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 missedexport 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_REdid not allow?between a member's name and its colon, sotarget?: EventTarget— and five siblings onEventalone — had never been counted by coverage,@param,@returnsor@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 thatLayoutContext"is not in the shipped declarations at all — it appears only inside a doc comment", but the consequence was never drawn:CustomLayoutOptionsshipped while the two types an external author actually writes against did not, sorun( ctx )typedctxasanyin the one surface the contract exists to make obvious.LayoutContext,LayoutImplandCustomLayoutare 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,CustomLayoutandEventhad none or nearly none, so their whole surfaces would have rendered as one undifferentiated section;CoreandCollectioneach 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 (nowv3/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@^4specifier and the plain<script>CDN form (theunpkg/jsdelivrfields 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 atMIGRATING.mdfor 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-weightandsegment-radiusare aliases of its plural forms; v4 has only the plurals (segment-radiifor the last), and no alias. displayis rejected, not renamed: the structural tier isshow()/hide()and the paint tier is thevisibilityproperty, so there is no property calleddisplayto port to.positionwas a v3 style property and is not one in v4.mid-source-arrow-fill/-widthand 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 growheapUsedby a steady ~2.2 KB per cycle, linear across five 200-cycle blocks — which reads exactly like a leak and is not one. Holding aWeakRefto 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/offis 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,highWaternever 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 thrownErroror 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'srefs[ 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
setBulkgrew 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.ingestColumnbecause both ingest branches already walk every index, so fused it costs nothing — where the same validation indeserializeElementsmeasured 4× 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 measurestest/*.mjsonly 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 afor...ofover 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, andunion()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 onother._refs— or, insame()'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 inCHANGELOG.md.48.5 Device loss under load (2026-08-04, the
rendererproject, 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
_debugLoseDeviceneutered, 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
rendererproject, 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
.notdefboxes 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().glyphsgrows 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 exactlydevice.limits.maxTextureDimension2D;limit + 1rejects 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-limitscalerejection, themaxWidthform 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.shupdated 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--bindtolocalhost, Node resolves that to::1on this box, andssconfirms it listens on[::1]:35729and nothing else — whilehttp-server -oopens the page at127.0.0.1:3333(its own rule for a0.0.0.0bind), solocation.hostnameis a literal IPv4 address and no DNS fallback can save it. The two halves have never met, on either project.watch:syncnow passes-b 0.0.0.0, which is whathttp-serveralready does and what the client'slocation.hostnameconstruction assumes. The client also gained anonerrorthat 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.catchrenders nothing and says nothing) applied to a<script>. Verified end to end in a real browser:window.LiveReloadis defined with the flag and undefined without it, and the control reproduces the maintainer's exact error text.v3's
watch:synchas 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.jsappended a row and then readel.scrollHeightto keep the view at the bottom; a DOM write followed by a layout read is a forced layout, once per event. Box selection emitsbox+boxselect+selectper element, so the numbers are large: measured onem-web, a box over the whole graph selects 7468 elements and the singlepointeruptask ran 22,406 forced layouts totalling 5,659 ms inside a 6,055 ms handler. The same gesture with this section'sselectionfilter 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_ROWSbefore 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. Revertingappendto 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 interleavesn8, n9, n4, n5, n1, …), four of the eleven edges differed, the threeshapedata values were dropped, and v3'scy.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, n5on one row andlong-name-6, n7, n3on the next, so the four parent boxes are disjoint; the sorted order over grid's aspect-derived two columns interleaves the families, andn1's auto-box ends up containingn2's andnon-auto's. That is the graph the maintainer could not read.Now a real verbatim port (order, edges and
shapedata), with alayoutfield on thenetworks.jsentry carrying v3'scols: 3—init.jsreadsdef.layoutfor any network that ships no positions. Two things went with it: the sheet maps the fixture'sshapedata through acasemapper (v3 carries the data and its page never reads it, so all three draw as discs there), and theparentsblock saysshape: 'rectangle'— the parents group overlays the nodes group (14.6), so the new nodes-group mapper reached parents, which carry noshapedata, 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: 3left 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 readsGraphStore.boundingBox(), the conservative columnar scan. On the fixed fixture that scan read 1718 × 1572 where the exactcy.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) addcurveBoxMargin()and then, for everything except taxi, the chord length.The chord is there for weight-extrapolated blob routes, where a
control-point-weightoutside [0, 1] genuinely puts a control a chord past an endpoint; round 14.10 addedCURVE_CMPDto 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'sfindCompoundLoopPoints), 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 forCURVE_CMPDinGraphStore.boundingBoxand inCollection.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:
marginalone violates by 338 px, andp2/2 + marginis 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 fullp2is 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 + nodeHalfMaxaround 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, andnodeHalfMaxis 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
- Cloudflare builds from git on every push, rather than a local
wranglerupload. Everything the site needs must therefore be in the repo or buildable there — which is what forces item 3. - 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.
- 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. - 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.jsonstill described the pre-split v3 package (3.35.0-unstable, withhandlebars,gh-pages,lodash,heap,gl-matrix). Refreshed: 484 deletions, and the only two added lines are the corrected version.markedandhighlight.js— installed by accident through that stale lock — are now declared at v3's exact specifiers. What it exposed: the root suite genuinely needscd v3 && npm install, becausetest/modules/benchmark-report.mjsreachesv3/src/test.mjsand soheap. 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 arecpu cores× sockets and not the record count (8 vs 16 here);gpusis 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, whichlspciandPCI_SLOT_NAMEspell differently. Deliberately absent: aprimaryflag — only WebGPU can say which adapter rendered. -
46.5.3 Provenance into the report —
benchmark/run-meta.mjs, because themetablock was being built twice and this is exactly the round that would have updated one of them. The merge exposed thatrender-bench.mjscaptured the WebGPU adapter and threw it away at the--jsonboundary, so a--rendererreport could not say which GPU it measured.meta.dirtyis new and renders in the failure colour: a measurement from a dirty tree is not attributable to the sha it prints.machineBlock()returns''whenmeta.machineis 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 andnpm run benchmark:publish. Runs group by machine fingerprint and are never plotted across machines;--prune nis 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 fixtures — built, then removed the same day by 46.5.7 below. A network could declare a
remoteUrlwhichdebug/init.jspreferred underwindow.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 opaqueTypeError) — 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.jsonis 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.jsresolves 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 thecodespanrenderer takes its escaping with it, andPLAN.mdcontains 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. @throwsand@seeare arrays in the docs model while@returnsis a string. The API page failed to generate entirely (e.replace is not a function).- A bare
.gitignorepattern matches at every depth.statusalso matchedscripts/status/— the ten modules that are the build — sogit add -Astaged none of them. Caught by readinggit status --shortbefore 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
existsSyncguard was dead code beneath its owncatch. 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 throughnode:vmthe 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.jsmaps network id to encoded fixture;init.jsprefers it and falls back to JSON when absent, which is whatnpm run watchsees. 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, notdocumentation/. 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: solidthrew — 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 onb/c/f/idrew 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 parameters —
control-point-distances/-weights,segment-distances/-weights/-radii— and the arrow widths reject mappers outright and are constants only. v3 givesab,bc,ehandedeach 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
#includeis a direct answer to the${COMMON}/${SDF}chunk inclusion, if that route is preferred over generating fromcontract.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.js663.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
wgsltemplate 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, soreturn ${X}must keep its space), whilepoly${id}SDstays glued (a space is never invented). Single-line generated fragments (the per-polygoncaselines) 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-webkitproject — 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-selection0 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:rendererran 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
- 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. - 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
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 whereFLAG_CURVEDis 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.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 afteranimate()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.pixelAtreads 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.The workflow splits into
ci-nodeand oneci-browserjob per Playwright project, each installing only the browser it drives and onlyvisualbuilding v3's baseline.npm testis nowrun-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
visualproject 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 installwill 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.
- Round 38's three sub-calls, taken (open call 1 — the round is
unblocked):
doubleports 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-offsetboth 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-joindrop, recorded as a deviation beside the existing edge-layer butt-cut note, with migration-guide rows.
- 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. - 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 throughpreventDefault(). 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. - 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 (errorPolicydemoting recoverable-tier throws only, never contract-tier) decided on the measured size of that list. - 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.
- 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 + nodeHalfMaxaround 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. nodeHalfMaxis 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_CMPDedge now contributes the union of its two endpoints' outer boxes (node.outerHalf, per-edge) grown by the stored excursion boundp2up 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 storedp2is kept rather than the tightp2 / 2: it is exactly the derivation-time bound whose 2× cushion the curve-index records as its staleness allowance, andp2 / 2would be zero-margin at freshness — one f32 rounding from unsound. Both call sites take the same form (Collection.boundingBoxAtat the hypothetical centres, withouterWidth()/outerHeight()per end). - 54.2 Per-edge margins for box-bounded routes — and taxi went
exact instead. The plan said "make
nodeHalfMaxper-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: adownwardtaxi 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.boundingBoxAtgets the newcurveRouteAtPositions— 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 carryframe.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.02they carry 2400 px of slack. A missing arrow gap atwidth: 3is 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)
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 distancekbehind the tip isk/2, and v3'sgap = 2wstarts 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 tocy.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.
Carrier: widen
edge.widthto two components and bitcast the existingedge.arrowShapesu32 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 wideningedge.curveParams4 -> 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.widthandedge.arrowShapes, so v3'sspacingderives in place.Order: baselines -> harness -> mechanical fixes, so every fix lands with a before/after number.
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, whichci-nodedeliberately 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 Bwould 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
hasAdapterskip 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 > tolisfalse, 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:
- 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, andtest/collection-dimensions.mjscurrently pins v4's answer as correct. - The compound tier, with the controls clean.
siblingandcross— the two arrangements that must route normally — match exactly, which is what makes the rest trustworthy. Against that:p-child,p-grandchildandchild-pdiverge on every field by exactly 1.000000 model px, andparent-parent(16.23) andleaf-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. - v3's arrow spacing, measured to four decimals.
teediverges by 16 = radius 15 + v3's constant 1 px spacing;circleby 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-taxiNaN onmid.x/mid.y; on that configuration v4's midpoint is finite and onlyboundingBox()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 akind: 'scalar'channel onedge.widthandcaptureEdgeWidthRidesreads the column directly; - the cull kernel, which is handed
edge.width's buffer inrenderer.mts; - four render pipelines' WGSL, where
widths[slot]becomeswidths[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, betweengand a child of the compound parent — answeredboundingBox() = {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:
- 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. - 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 exactly —
none->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
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 forveeagainst a 0.3 back extent.ARROW_AXIAL_DEPTHis 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
gapis 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'sdestination-outerase 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-alpha0.853% 0.000% 0.853% 0.000% closeup-gap0.020% 0.263% 0.020% 0.020% closeup-heads0.000% 0.047% 0.000% 0.000% closeup-curves0.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 inedge.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.- Back extent is the wrong depth for a concave head. v3 shows
the line through a
parity-arrow-alphawas measuring the wrong thing — round 55's own cautionary case, one level up. Atarrow-scale: 4its 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%.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-gapgolden 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
gapit 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.arrow-shapeis 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 becauserouting.spec.jsdoes 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 foredge.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 whatmidpoint()answers. The fix was to free a binding, which round 58's fusednode.outerGeomcolumn 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: itsfilledcontrol 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.)
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.
Adopt
oxfmt, and drop the extra-space call style.cy.foo( bar, baz )becomescy.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.The status site's design and record pages each open with one huge paragraph. Fix the source markdown (
src/README.mdand 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.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.
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.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_SELECTEDis 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 hasFLAG_ACTIVEand the publicactivate()/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 onFLAG_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 forcesbezieron both; v4 routes loops as loops and ancestry edges asCURVE_CMPDregardless of declared style, which is the same look by a different mechanism. Nothing to do.
Design calls (round 57)
The affordances stay shader-drawn; only what they draw changes. v4 has no
:selectedblocks 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
:selectedblocks" 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. Andstyle('background-color')on a selected node reads blue, which is what v3 answers. Design calls 3 and 4 below fall with it.FLAG_ACTIVEbecomes the press state, as in v3, set by the pointer layer on press and cleared on release/cancel — v3'snear.activate()/unactivate()inload-listeners.mts. It is already public API (activate(),active(),inactive()), so this makes an existing surface mean something rather than adding one.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-opacitybehind{ 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.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 —
hoveredis 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-codedcolor.rgb + 0.15that no spec covered and no stylesheet could turn off.oxfmtlands 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 byfile:line(round 37.1) — a reformat that moves athrowby one line fails the build, correctly, and the fix rides the same commit.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 outsidev3/, 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
@paramgate 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, andCollection.boundingBoxAthad no@paramat all behind that.memberBodystopped 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@throwsdetection for every exported arrow function insrc/algorithms/and forCollection.boundingBox. Anexport 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.@paramis 239/239 now (240/240 since round 52 added thewgsltag). Fivefile:lineallowlist 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_PATHSinscripts/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.mdopened 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 anh2, 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.md54 -> 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") andEXECUTIVE_SUMMARY.md, which round 56 had already tempered.README.md,CHANGELOG.mdandMIGRATING.md— the three that ship — were checked and already said "not released; usecytoscape@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 arrowgap, 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 fromv3/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 acase, 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 thepolygonnode 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-insidevariants (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
trianglefor nine rounds under a golden that passed throughout. It found one on its first run.arrow-fillnever resolved tohollow: the helper builds its clauses fromObject.keys, which turns the booleantrueinto the string'true', so the comparison waseq: 'true'against a boolean and every edge readfilled. 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:selectedrule instead of v4's accent ring: the fill goes#0169D9and the border keeps its own colour, and a selected compound parent takes v3's#CCE1F9/#aec8e5pair (: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_GRABBEDleaves the hover brighten, since 57.1c gives a pressed element v3's overlay instead and two affordances for one state is worse than either. Edgewidthdefault 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-selectionreads 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 styledbackground-colorand 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:selectedto write, so matching that exactly would leave an app no way to make selection visible at all. The shader wins instead; the deviation is insrc/README.mdwith 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
:selectedto write" — was wrong when it was written: thecasemappers 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=1rewrites every golden whether or not it exceeded its bound, so the run was re-checked without it — onlyselection-accent(its scene grew a parent pair) andpolygon-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.flagsand take v3's#0169D9online-colorand on all four arrow colours, which is what v3's:selecteddoes.Before this a selected edge in v4 was indistinguishable from an unselected one:
FLAG_SELECTEDwas 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 ofedge.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-selectiongrew selected and unselected straight edges, a selectedunbundled-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_ACTIVEand the publicactivate()/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'snear.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:activefor both would darken the padding ring twice.vsLayerPlain/fsLayerPlainare 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 pathactivate()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.:activereaches 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:hoveran app could write.The second one is gone (57.1d). "v4 has no
:hoveran 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 holdhoveredas easily asselected. The brighten — a hard-codedcolor.rgb + 0.15in 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:selectedand:activefrom the flags word inside the shaders, and recorded two deviations to explain what that cost. Both were rejected. This pass makes state acasecondition, 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, pluschildlessandorphanas 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 withfalse— one key per state rather than a pair. The binding lives incontract.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 exactlyparent; 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_BORDERand every selection branch infsNode/fsEdge/fsCurvedEdge/ bothfsArrows; theedge.flagsbinding 57.1b had freed a slot for;ACTIVE_RECORDandlayerRecord(); the duplicate of that record inNODE_LAYER_CULL, the one the cull comment said "must agree" with the shader's; thevsLayerPlain/fsLayerPlainentry points and thenode.flagsbinding the layer pipeline carried for them;activeCount()and its three writers in the store; and the three widened pass gates inrenderer.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.15in 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
:activeblock is three declarations, and two of them (overlay-color: black,overlay-padding: 10) are already v4's constant defaults — so onlyoverlay-opacitymoves, as{ case: [{ when: { active: true }, then: 0.25 }], else: 0 }. That is not brevity for its own sake: it makesoverlay-opacity: 0the way to turn the highlight off, and any otheroverlay-*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 } }onwidthchanges the node'sboundingBox(), 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
casemappers 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 — soapplyPartitionedmasks the flags word, hits aMap, and does the samewrite()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).setFlagandflagRefsnotifystore.onStateChangeinstead, 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 notwatchDataKeys— 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-namedstyles a fill on both sides, so in v3 the user block beats the default:selectedand 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-accentandpolygon-shapesboth declaredbackground-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 aselected: trueleft 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 renders —
label-boxesby 1.597%,label-alignby 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-goldenmaxDiffRatio(0.02 forlabel-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:
compareToGoldendefaults 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-boxesby 1.597%,label-alignby 0.811%, andedge-labels,label-visuals,labels-open-sansandcurved-edge-labelsbetween 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-boxeshad not been regenerated since round 13 B6 on 2026-07-31, whilelabel-layer,glyph-atlas,glyph-buffer,label-layout,label-pipelineandshaders.mtsall 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-sansinnode_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 wholevisualproject 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
visualjob. 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-boxesby 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.mdandsrc/README.mdboth 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.mtscompiles queries from the sameSTATE_CONDITIONStablestyle-scales.mtscompiles conditions from, so the two cannot drift.Querygainsselectable,locked,grabbed,grabbable,active,hovered,childlessandorphan. 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
whencondition. 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 theEXECUTIVE_SUMMARY.mdrewrite 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
:selectedrestyling was gone; and the perf section said the default sheet never restyles.And
MIGRATING.md, which ships, told a porting app that:parent:selectedwas "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.mdgained 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 ("
:activereaches 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 carriesFLAG_ACTIVEand 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, andpanStarteddoes 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 nullthis.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
panStartedneutered), and a released-before-the-pick spec pinning that a late resolution sets nothing (fails with thethis.down !== downguard removed — the wash sticks with no release left to clear it). Three circle specs moved from one-shot reads toexpect.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), andtapstartstill 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
findNearestElementcounts an edge hit withinwidth/2 + edgeThresholdof 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/8nodeThresholdbefore every shape check.The halo rides the pick frames.
Framegrew 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 accepthalfWidth + pickPadPx, and both edge cull margins grow by it — zero in scene/export frames, so drawing is arithmetically untouched, which the exact-goldenvisualproject (119 green, zero differing pixels) pins rather than asserts. The CPU node pick takes an optionalpadPx(absent means exact, keeping every frame literal in specs and benchmarks honest) and inflates before the LOD/radius derivations, as v3 padsouterWidthbefore everything downstream; the label-box test grows by the same halo (v3'slabelThresholdshares 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.pickstays 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.pickanswers 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 viamidpoint(). Controls: the halo removed from the straight pick FS, from the curved pick FS, and a nonzero default pad oncy.pick— each failed exactly the spec written for it. Node halos are pinned intest/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:
fsArrowalready evaluates a true signed distance per head (the generatedARROW_POLYpolygon SDFs + the circle), so the pick fragment shader reuses the same switch and testssd <= 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 (findNearestElementrunsshape.collideagainst the filled point table for all four ends, mid arrows included) and falls out of usingsdrather 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
pickPadPxexactly 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 ridesarrowEnds/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.pickat a point the line cannot answer, with its mirror point in front of the tip answering null; a curved-end head picks viatargetEndpoint(). 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. Thevisualproject 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=plainURL 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 andwidth: 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 viawithSelected(); and scale-mapped colours — where athencannot 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 constantbackground-coloradded 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 '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
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
tapon an element → the selection toggle — implementable as emitted today:tap()emits first and applies selection after (pointer.mts, the tap helper), so the layer readsisDefaultPreventedbetween the two. Prevention suppresses the whole toggle step (select, unselect-toggle, and the non-additive clearing of others), sotapselect/tapunselectdo not fire for a prevented tap.tapon the core → the background clear — same shape, same helper, implementable as emitted today.boxend→ applying the box's selection — implementable as emitted today in both box paths (mouseboxEndand the three-fingerapplyTouchBox), each of which emitsboxendbefore 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-elementboxevents still fire (they report the geometry, not the default);boxselectdoes not.tapstarton an element → grab initiation — not implementable as emitted today, and this is the call. The press handler setsFLAG_GRABBEDand emitsgrabon/grabbefore it emitspointerdown/tapstart, so the flag is decided before any handler could set it. Two honest options:- (a) Reorder the press emits —
pointerdown/tapstartfirst, 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, andpreventDefault()ontapstartkeeps 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.
- (a) Reorder the press emits —
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
voidtoday 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.preventDefaultdoc 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 gap —
edge-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, andedge.widthis already in it at vertex visibility —vsEdgeLayerjust 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 foredge.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.mtsowns the layout). Written byupdateOuterHalf(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 thenode.shapebranch ofsetScalar. Shape ids are ≤ 26, sof32(shape)is exact. Nothing reads it on the CPU — likeedge.widthlane 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 stream —
vsEdgeLayerresolves boundary points and applies the draw trim exactly asvsEdgedoes (including the crossing-collapse clamp and the haystack/triangle exclusions);vsMidArrowanchors at v3's four-point mean(lineStart + lineEnd + arrowStart + arrowEnd) / 4— the CPUmidpoint()formula — instead of the centre chord. No binding changes. - 58.3 The curved layer VS — its layout swaps
nodeOuterHalf+nodeShapesforouterGeom, spends the freed slot onedge.width, and passesarrowTrimOfto the evaluators where it passesvec4f(0.0)today. - 58.4 The edge label VS — same swap, same freed slot. A new
generated
arrowGapTrimOf(the plain-gap twin ofarrowTrimOf, mirroring CPUarrowTrimAt) feeds every evaluator call that passesvec4f(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'sallpts). The two curved mid-arrow call sites switcharrowTrimOf→arrowGapTrimOffor the same reason — a mid arrow is an anchor, not ink, and with a hollow end head the draw trim would seat it offmidpoint(). - 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.mtsand 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 testinvoked 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 × paddingwide where v4's iswidth + 2 × padding— a divergence nothing had measured, logged as ledger item 27 rather than silently patched. The shipped scene is casing-only, becauseline-outlineis 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:linekey — 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.
maxDispstarts at 0 andNaN > maxDispis 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):
- Stability discipline — d3-force: per-edge spring strength
stiffness / min(deg(s), deg(t)), applied to each end weighted bybias_i = deg(other) / (deg(i) + deg(other)), which bounds any node's aggregate per-tick spring correction atstiffness— 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. - 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'sp = 2repulsion 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. - 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.
- 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. - 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. - 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
- The model becomes stable by construction, not by tuning.
Springs take d3's rule verbatim (strength
stiffness/min(deg), degree-bias split,stiffnessre-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'slimitForceshape); and convergence treats a non-finite displacement as not settled (the NaN-reads- as-converged hole closes even though the cap makes it unreachable). - 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)^2per pair (sfdp's p = 2 shape;repulsionre-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)^2and 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 foldscellStart/cellItems/pyramid into one grid buffer, which is also what frees the binding the anchors need. - 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,componentSpacingdefault 40) lands at settle on both executors. - 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: falsekeeps meaning "relax the current positions"; a newinit: 'spectral' | 'scatter'option (default spectral) governs what a fresh placement is, so specs and controls can still pin the scatter path. - 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 (nestingFactorper 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. - 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.
- 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;stiffnessre-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.mtsis the new pure module (union-find in first-seen order, the disc-radius estimate, shelf packing in v3separateComponents' row shape for both the up-front anchors and the exact settle re-pack, and the anchor-relative scatter), specced intest/modules/force-init.mjsincluding 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 (gravityre-read as px/tick, provisionally 1); the anchors reach the force kernel through the csr buffer's tail (bitcast f32 atparams.anchorBase, the uniform's former pad slot), so the kernel stays at exactly its 8 storage bindings and no ninth crosses the budget.randomize: falseanchors 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;repulsionre-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,cellItemsand 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 exactlycomponentSpacing + 1apart, 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:
spectralSeedinforce-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 withinit: 'spectral' | 'scatter'selecting it (unknown values throw;randomize: falseignores 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 underinit: '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 ofgravity, default 1.5) andnestingFactor(an edge spanning compound boundaries takeslength × 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), withdecay/threshold/iterationsunchanged 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 initthrow specced —lint, format), 238 browser specs across renderer+visualwith every golden exact and every parity scene at its recorded value, test:types:allclean (the declaration regen also picked upround 58's node.outerGeomdoc, which had never been regenerated —a pre-existing staleness this round's regen caught). Benchmarks: the layouts.mjsforce row now measures a complete run at115 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 --layouton ndex reads 1308 ms to converge againstthe 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, thespectral 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 — thereport-html.mjspattern) joins the published runs of one (machine, profile) into per-row p50 series and renders a self-contained comparison page;scripts/status/bench-pages.mjsplans 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 page —
buildComparisonthrows 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
--suitefilter 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;planComparisonsmerging profiles — 1 fails. Comparison pages are grouped by exact profile, deliberately:rendererand the Node-tier profiles measure disjoint suites, so a joined page would be mostly gaps presented as history.- Runs from different machines never share a page —
60.2 Benchmark coverage for the unpriced recent rounds (2026-08-09) —
node scripts/bench-coverage.mjs --verbosereads 83.5%, and its missing names are mostly the audit's documented error direction (internals reached through benchmarked public paths — the Viewport methods behindcy.pan(), the StyleEngine members behindcy.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).applyAllunder 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 withinit: 'spectral'vsinit: '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 withpadPx: 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
applyPartitioneddisabled 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 byunion()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.- The 57.1d state-condition partition (
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, andnpm run statusruns 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 runexecutePlanover a synthetic five-op plan in a tmpdir — every op kind lands (write writes,jsonminre-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:
geoSpeedupis a geometric mean and answers null (not a fake 1×) when a run has no v3/gpu pair;byMachinequarantines 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:
jsonmindegraded to a plain copy (2 fail — the minify spec and the byte-count spec, the second being the cap check's number); themkdirSyncdropped (2); omit ops written as empty files (1);geoSpeedupas 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
e37d2444on 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-dirtybecause 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 + unselectwent 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
makeGpuinstances run v4's default stylesheet, which now conditions onselected, sodependsOnStateis 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'sonStateChangeroutes a flag flip intorefreshMapped— 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 machineryapplyPartitionedalready 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, andmutators.mjs/scenarios.mjs/core+collectionnow 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 astyle-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()/containsnoise,forEachorder bias), andlock + unlockwas 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
- A dedicated
StyleEngine.refreshState( group, key, slots ), wired fromcore.onStateChangein place of therefreshMappedcall. It knows somethingrefreshMappedcannot: 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 inpart.records.onReparentedkeeps usingrefreshMapped— its two pseudo-keys flip together, and reparents are rare structural ops. - 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
partitionRecordreader, which is what makes the records per-combination in the first place) and compare. The cache lives besidepart.recordsand 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. - Narrow writes only where they are provably safe; the partition
record's full
write()otherwise. TheapplyMappedcomment is the constraint: per-channel writes break the cross-channel couplings that live inwriteChannels(circle collapse, the arrow-alpha fold, the label re-anchor). So each fast writer is a private method factored out ofwriteChannelsand 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, edgeopacitywith its fold cluster — falls back to the full per-slotwrite()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 insidesetColor, the layer counts insidesetNodeLayer/setEdgeLayer), which is what makes a narrow write complete. - Fall back to the general path entirely when
def.partitionis 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 whendemoted[group]is set. Compounds split leaves/parents againstdefs.nodes/defs.parentsexactly asrefreshMappeddoes. Note what is not a concern: GPU-owned channels — a partitioned def is all-caseby definition and case conditionals never join the eval kernel. - An empty diff is a no-op, which the path gets for free: the
store's
watchedStatesset 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.mjsbeside 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 fullwrite()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 movesboundingBox()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'swrite— the partition suite's private-reach idiom. - 61.2 The implementation (2026-08-09) — as designed, plus
one addition measurement forced:
refreshState/refreshStateDef/partitionDiffWritersand thediffscache ondef.partition; eleven narrow writers factored out ofwriteChannels(both callers, one fold definition —foldRgba/foldLayerRgbareplaced four inline copies);partRecordForextracted fromapplyPartitioned; thecore.mtswiring. 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
widthfails both geometry specs — the round-61 fallback pin and 57.1d's own bounding-box spec. Measured throughbuild/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 + unselect6.30 ms → 250.9 µs (v3 2.14 ms — v4 is ~8.5× faster than v3 on that row again),scn: explore164.7 → 39.3 µs,scn: select-all + fit7.14 → 0.99 ms,scn: drag411.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.mjsgained 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.mjsheader 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 anddo_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 + unlockreproduced at 1.83× (3.64 → 6.69 µs on a 256-band) — real, and localized on sight: 57.1d'sflagRefsgate collects changed slots for any condition-family bit, butnoteStateChangediscards the array for a key nocasecondition 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-groupwatchedStatescheck 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 themutators.mjslock 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 exercisesflagRefsin 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@throws18/18,@param241/241,@returns280/280, the regenerateddist/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-
--allenumeration running as it lands. - 62.1
degreeCentrality(one root) (2026-08-09) — solved one level up from the plan's fast path: theSubgraphViewis a structural snapshot valid exactly until the store adds or removes elements, which is whatstructureEpoch(34.2) counts, sosubgraph()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 andmoveEdgeneed no invalidation. Measured through the bundle: 333.7 → 0.62 µs, ~11× faster than v3 on the row that was ~47× slower. Four specs intest/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 recomputedk·n+jtwice per iteration), a single-altrelax, 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×, withclosenessCentralityNormalizedriding 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:hierarchicalClusteringgets a per-runmakeGetDist(impl once, per-node vectors cached on the interned handles),k-clusteringa run-token WeakMap vector cache behind its unchangedgetDistsignature (the token, bumped per public entry, is the staleness guard; mutating feature-array centroids stay live-read), andfuzzyCMeans' 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
__namethere 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×), anddependsOnStateshares the method.getElementById:lookup()allocated an IdEntry and a Ref per call;IdMap.code()/GraphStore.lookupCode()answer the packed(slot << 1) | groupBitinteger 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: theresolveDistanceinsertion strandedclusteringDistance's doc block (instance #18; the coverage gate caught it) andlookupCodemoved the SHAPE_MASK allowlist key (the 37.1 mechanism's sixth firing) — both fixed in the pass. - 62.4 The
--alltier'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 thesurface/eventssuites. 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-methodAnimationHandleImplat ~93 ns vs ~2.9 µs, the chaininganimate()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-objectdata()(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()/hasFlagand every state predicate ride it, as doposition()andsource()/target().Collection._storebecame 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_refsimmutability), andforEach/map/some/toArrayiterate it — array-element access holds its speed in the JIT modes where indexed own-property access is bimodal.toArray()becomes aslice()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 beforeoff('*')allocates to remove nothing;structureEpochconverted from getter to field; theisParent/isChild/isChildless/isOrphanfamily 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 (onStructureChangefrom the onebumpStructureEpoch()funnel) so theelements()memo hit is two loads. One over-reach reverted by measurement: routingeq()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_MASKfile:linekey moved four more times, the_arrinsertion strandedforEach's doc block (instance #20, caught by three gates at once), and the one-off single-spectest:jsflake fired twice more without reproducing (the 2026-08-02 record's shape; output captured on the re-runs only).
- Hot column caches (62.5):
- 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
--allruns: 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:
indexOfskips the fullassertCollectionwalk 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×);effectiveOpacityreads the stored opacity column directly behind a newStyleEngine.ownsProp()gate — the one case stored bytes lie is a kernel-owned mapper, the same rulereadPropkeeps (2.27×);mutableElements()flattens its memo to one dedicated field (1.38×); bandconnectedEdges()was profiled to 17.7 of 22.2 µs in the walk —outEdges/inEdgesallocate a CSR subarray view per node — andAdjacencyIndex.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 viaarguments.length; 1.13×);_liveNodeRef(theisParent/isChild/isChildless/isOrphanshared head) and thedata()getters read the raw ref and letisCurrentrepair forwarded ones in place (the flags ×4 row: 1.36× from 0.895×). - The harness, twice more (62.6b):
data.mjs'scmpReadhad 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. Andpan() getexposed the artifact 62.5c's fix cannot reach: 0.83–0.87× across eight independent--allruns 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'sargumentsuse 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 — renamedpan() 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:modulesrun, its seventh firing; the key now sits at thethrowline, 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.
- Real shaves (62.6/62.6b/62.6c), each probed with margin before
riding to a verification run:
- 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 readcompact()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
- 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 (
caseonid) — 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 intodata(), the wire and every export. - The v3 spellings return:
ele.style( name, value ),eles.style( props )andremoveStyle( 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 andtest/decided-drops.mjs's pin both flip with the round. - 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 bypassedbackground-colorhides 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. - Bypasses export, and sheet swaps replace them.
cy.style()/cy.json()carry the section — strictly better than v3, whoseele.json()exports no bypass at all (verified againstv3/src/collection/index.mts: data/position/group/flags/classes only), so v3 bypasses are silently lost on export today. A fullcy.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. - Performance is the top requirement (maintainer, mid-sitting): a bypass-free graph pays nothing measurable, and every bypass operation costs what it touches.
- 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 everywhere
—
foo-barandfooBarare the same key in the bypasses section, the sheet blocks, the getters,transition-propertylists,animate({ style })andremoveStyle. Measured before planning: most of this is already true (sheet blocks normalize throughnormalizePropinresolveConst; the getters through the 34.5 memo;transition-propertyand 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.
- The section's key is
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:
- It cannot compose with scale-mapped channels. A clause's
thenand theelseparse throughparseOutputto scalars (src/style-scales.mts— thecaseProgram's clause values arenumber | RGBA), so "wrap the channel's current value in a case" is inexpressible when the current value is a scale — and em-web'sbackground-color, the flagship demo sheet, is exactly that. Fixing it meanselseholds a nested Program: a mapper-IR change with GPU-pack and readback consequences, at which point the sugar is no longer sugar. - 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 outsideCONDITION_FLAGS—idis 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, withapplyAllgone per-element too. - 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. Thecy.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)
- 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. - 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 — soele.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. - 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. - GPU eval demotes per channel, count-gated, reversibly. A
channel carrying ≥ 1 bypass joins the B1
paintInputsexclusion 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. - 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-opacitybypass) 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-propertytweens 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/heightstay auto-bounds-owned (the 25.1 filter),labelstays 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, andtransition-propertyandanimate({ style })take camel entries. The audit found every existing entry point already normalized — the spec turns the coincidence into a contract. Control:normalizePropneutered 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 fromresolveConstintoassertGroupProp, andapplyPropas validator/parser — a bare scratch object captures exactly theComputedfields an entry assigns, surviving a value equal to the channel default). Slot resolution is lazy againststore.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), therefreshStateDefpunch-out (bypassed slots take the merged full write; the run optimization untouched),paintInputsdemotion per bypassed prop (count-gated, reversible), and the one-load gate (mergeBypassis 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 andremoveStyle( 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 intest/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.mdflips its bypass rows with the two porting differences named (export is better than v3; sheet swaps replace);CHANGELOG.mdgains 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 onarguments.length, with a message naming the replacements (build withunion(), query withcy.$( 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 offilter()— over the v4 query API, in line withcy.$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.byIdlands beside$idas 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 toelementsinstead offilterfails the alias table and the decided-drops identity;byIdunwired fails the table's two directions.MIGRATING.mdcarries the porting rows (cy.$works over the new forms;collection( eles )is not ported and now throws),CHANGELOG.mdthe entries,src/README.md's selector-removal design bullet the amendment, anddist/cytoscape.d.tsregenerated. 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):
- 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. 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.- 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
*Asyncwrappers (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.mjspinning the routing contract with stubbed-navigator paths so every guard fires in the Node tier (throw gate at zero),cmpAsyncinbenchmark/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: extractingbuildBrandesNeighborsdisplaced 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.
workgroupUniformLoadjoined 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.mediansorts 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 displacedmin'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 throughtoStats, buildMeta provenance with the adapter identity, its ownalgorithms-gpuprofile. 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
cpubesidegpuis 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.mjshas 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--allrun since, its rows (the one measurement of style reads through the built bundle, the round-34__namelesson'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)andx·xround 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:
meanlinkage never worked — itssizefield 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 exercisemin). 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 explicitexecutor: 'gpu'and the parity suite, and the revisit that could change the verdict is a sparse SpMV kernel. ApageRankDensefamily 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
widepage 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_CSSpins those columns tonowrapand 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'smin-width: 46chis 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-wrapscrolls, 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.mjsat 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
cf5727b4steps on the v4 side only —core: filter(fn)263.5k → 344.2k ns with its v3 twin flat at 423.7k → 416.3k, and the same shape forsame(),collection(),degree(),outgoers(),neighborhood(),iter: filter(fn),nodes ($("node") vs filter({group})). Two controls place it:- Remove the eight alternations at HEAD and the old numbers come
back.
core: filter(fn)falls 352.2k → 261.8k, against 263.5k measured at6df994f1before 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. - 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
6df994f1tocf5727b4and 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, andnode.position()23.0 → 16.8 → 15.7 with the step visible at90b71d6b, 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.
- Remove the eight alternations at HEAD and the old numbers come
back.
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 intosrc/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 —
panScenariorecordedgpuFrameMsonly 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
e37d2444min 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.readreports 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 panningis 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 thegenerated 25k × 50kscene 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 insrc/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:fuzzyCMeansn=16384 reads 12.8 and 12.8 ms against the published pair's 12.4 → 16.3 (the "+31%"),kMeansn=16384 8.0 and 7.8 against 7.7 → 9.0, andkMedoidsn=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 thepageRankDensefamily 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:twinOfinreport-compare.mjslooks up a bench namedv3, and 65.9 taughtreport-html.mjs'spairOfabout thecpubaseline 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 atcf5727b4and 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 thearguments.lengthcheck 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 goesrepeatSpread, 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 makesindex.mjsanswer forcore.mjsandcollection.mjs, where its benches live) and the shared inputs — neversrc/, which is the subject.buildComparisonrefuses 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 —
oxfmtreformatted 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 inEQUIVALENT_HARNESSESwith a reason — the shape ofthrow-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.twinOfalso learnt thecpubaseline, whichreport-html.mjswas 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.
panScenariorecorded a device time only when the value changed, which cannot distinguish a repeat from a stale reading;GpuTimercounts its readings now andRendererStats.gpuFrameReadingsexposes 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 setspanned 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). Onlydecodemay blame the wire buffer;initsays how many nodes and edges came through and that the data is not the suspect. -
init.jsrestructured soloadNetworkruns outside the fetch chain, andshowFatal/showStatsmake a fatal message stick — the overlay cannot overwrite one. - A
file://diagnosis, because openingstatus/debug/index.htmlfrom 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.mjsencoded withbuild/cytoscape.cjs.jswhiledebug/index.htmldecodes withbuild/cytoscape.umd.js, and its own comment claimed that made a mismatch "impossible by construction". It did not:npm run watchrebuilds the UMD alone andnpm run statusbuilds 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 )incolumnar.mts— the conversion over already-partitioned defs, in two modes. The publictoColumnarElementsis the strict one and still throws on an endpoint it cannot resolve; the loader's is not, and answersnullinstead, 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. Notry/catcharound 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,grabbableandpannable; the columnar form has columns forselectedandselectableonly. Converting blindly would have dropped three flags silently — the exact shape of 46.5's lost dictionary column.buildColumnarreturns the deviating defs by index and_addColumnarwrites them, before the style pass (::lockedand::grabbableare styleable conditions, and that is where the def path has them). This also leavestoColumnarElementslossy for those three, which its doc comment now says out loud. -
_bulkAddroutes,_addPartitionsplits_addDefsso one partition serves whichever route is taken, andcy.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
nullfails 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:
setFlagnotes 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 throughaddNodesColumnarmoved 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 distinctMechanism_of_Actionvalues 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-onlycasemappers only whenflags & maskchanges 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.paintInputsdemoted oncomputed.<channel>Opacity !== 1 || mapped(...); the constant half is gone. A mapped channel opacity still demotes — its value varies per element, and the statecaseform this repo's sheets use is not packable at all. - Every colour program carries its own fold.
PackInputgainsalphaMul, resolved per prop byconstOpacityFor(background ←background-opacity, border ←border-opacity, line ←line-opacity); arrow programs keep overriding it frompaintContext, which also covers their mapped case. - The owned-prop getter folds too.
readPropre-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%) — _applyStyle →
applyGroupDef → applyMapped → write → writeChannels; 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_COLUMNSnames the three a shared record does not determine —edge.endpoints(the ingest's),edge.flags(per-element bits) andedge.curveParams(derived) — andEDGE_STYLE_COLUMNSis everything else. It sits besideCOLUMN_SPECSbecause 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) andwriteEdgePerSlot(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-opacityhas 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 op — ctx.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:
- 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.)
- 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. - 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
e97610c5 → c965d9b8. 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.
seedsis 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: falsefiles 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 aworkspacesfield in the root manifest, hoist cyext's dependencies into the rootnode_modules, and change what a rootnpm cimeans 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 vianpm --prefix cyext, like v3. - cyext's own source is plain
.mjs— no build step, no TypeScript. The.mts-through-.mjs-specifiers convention is asrc/convention for the bundled library; the repo's own tooling underscripts/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 meansnpm publishships exactly what the tree tests. Internal shape followsscripts/build-dts.mjs: pure exported functions plus apathToFileURL( 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
dependenciesof 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.chaistays 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:typesuse rolldown's JS API with the config built in memory bylib/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, socross-envis not needed at all.oxlint,oxfmtandtscare spawned:createRequire( import.meta.url ).resolve( '<tool>/package.json' )resolves each tool from cyext's own tree wherever cyext is installed, reads itsbinfield, and runsprocess.execPath <binpath> <args...>via array-argumentexecFileSync. Rejected: shellingnpx <tool>— it resolves from the consumer's tree first (defeating the pinning) and hits the network on a miss.cyext testspawnsnode --import <abs tsx entry> --import <abs shim> --test "test/*.mjs"with both--importtargets resolved to absolute paths from cyext's tree, because a bare--import tsxresolves 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.mjsis a byte-for-byte copy of the root's 46-line BDD shim, spec-pinned equal; the shipped oxlint rules equal root's.oxlintrc.jsonmodulo 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/— nobuild/+dist:copy. The root's copy list is a hand-maintained coupling round 44 exists to police, and an extension has no dev harness consumingbuild/, 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}plusdist/<name>.d.ts. cytoscapeis external in every format, withoutput.globals: { cytoscape: 'cytoscape' }— even though v4 extensions import only types. UnderverbatimModuleSyntaxa 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→ filesdist/cytoscape-layout-spiral.*, UMD globalcytoscapeLayoutSpiral(scope stripped for@org/…, then camelCase). Escape hatch: an optional"cyext"field in the consumer's package.json —{ globalName, entry, external: [...] }, all optional. Rejected: acyext.config.mjsfile — 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 scriptnode_modules/.binis 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
.npmignoregainscyextandextensions. 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 failstest/modules/packaging.mjsloudly. That failure is one of the round's controls, run deliberately before the.npmignoreedit lands. - CI: a new
ci-cyextjob, modeled onci-v3(.github/workflows/tests.yml:88): rootnpm ci+npm run build+npm run dist:copy(the e2e specs and the example resolvecytoscapeto the repo root, whose runtime entries live indist/; the committeddist/cytoscape.d.tscovers types), thennpm --prefix cyext ci && npm --prefix cyext test, then the example extension's install +cyext check. ci-node is untouched. Root gains atest:cyextconvenience script for humans; rootnpm testis not extended (rejected: folding intotest:node— it needs a second install and root dist artifacts, exactly the coupling the round-53 split removed). cyext releaseis deliberately minimal, and round 50 is not front-run.release [patch|minor|major|<version>] [--dry-run] [--tag <disttag>]: refuse a dirty git tree → runcyext checkin full → roll the changelog (## Unreleased, which must exist and be non-empty, becomes## <version> — <date>with a fresh Unreleased inserted; a pure function inlib/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--provenancehere 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 tarball —
packaging.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.mdwritten as the manual before the code (command table, the"cyext"manifest field, the pinning philosophy); AGENTS.md gainscyext/+extensions/in the structure section;src/README.md's extension-contract prose gains the tooling pointer. - 71.1 Package skeleton —
cyext/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.npmignoreedit (control first), roottest:cyext, theci-cyextjob 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
--importpaths, 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, extendci-cyext, the root shape/skew specs, thedebug/init.jscomment. - 71.7 release — preflight, changelog roll, bump, publish
--dry-run; specs. - 71.8 Closing sweep —
src/README.md, MIGRATING.md (an "authoring an extension" pointer), CHANGELOG.md, AGENTS.md final pass, EXECUTIVE_SUMMARY.md rewritten from this file.
Risks tracked
- npm name squatting —
cyextis unclaimed today; every unpublished day is exposure. Recommend a placeholder publish right after 71.1 (a maintainer action — listed under Open). - Toolchain skew vs root — spec-gated; "upgrade the toolchain" becomes a two-manifest change CI notices rather than a drift.
- Node floor — cyext carries
engines >= 24(it runs the same tools the repo does); generated extensions deliberately carry.nvmrc, notengines(the reasoning in the templates section). - Windows — array-argument
execFileSync,node:paththroughout,pathToFileURLfor--importarguments; no Windows CI here — round 49 (reserved) owns that runner and inherits cyext, noted so it does. - rolldown JS API stability — the exact pin makes it a deliberate, per-cyext-release exposure; the bundle-level specs are the canary.
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 —pickNodeAtand 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:
- 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.
- 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:
- pageRank/Katz GPU SpMV is genuinely dense —
algo-gpu-pagerank.mtsuploads the full n×n f32 matrix frombuildPageRankMatrixand runs the workgroup-per-rowMATVEC;algo-gpu-katz.mtsreuses the same kernel. The CPU sides went sparse in 65.10 (O(E+n)/iteration), which is why both wrappers passminGpuN: Infinity(page-rank.mts:54,katz-centrality.mts:119) — the kernels serve only explicit'gpu'and the parity suite. - AP's
A_UPDATEwalksrr[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. - Closeness is Floyd–Warshall on both executors — O(n³) even
unweighted. The GPU already owns a batched level-synchronous
BFS in
algo-gpu-brandes.mtswhose forward half is exactly the distance computation closeness needs, but it is not exported as a reusable plan the wayfwRelaxPlanis. - The heat family is combinatorial-Laplacian only —
buildHeatStructurebuilds L = D − A; nolaplacianoption. A normalized Laplacian (‖L_norm‖ ≤ 2) would boundsquaringsby t alone instead of by weighted degree. - No device-side timing exists on the algo path —
acquireAlgoGpurequests 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:
- The contract is storage-buffer-shaped.
src/contract.mtsis the co-signed column layout;column-mirror.mts:13-14uploads 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), theedge.widthmirror 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 viatexelFetchfrom 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 whereColumnMirrorstands today. - Culling and indirect draws have no WebGL2 form.
cull.mts:6-28: a three-dispatch order-preserving compute compaction into visible lists +drawIndexedIndirectargs 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 wherecpu-pick.mtsalready runs), or draw-every-slot with a vertex-stage collapse of invisible instances. Cost unknown — this is 73.2 (b). - 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, andreadPixelsinto a PIXEL_PACK_BUFFER polled throughfenceSyncgives a non-blocking readback of the same tile; region culling comes from the CPU cull or a scissor. Latency delta unmeasured — 73.2 (c). - 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 withfwidth. Derivatives are core ESSL 3.00, R8 is core,writeTexturemaps totexSubImage2D, and the LOD inputs (labelFadePx,minZoomedFontSize) are uniform math. Glyph instancing rides the same vertex-pulling answer as every pipeline. Low risk; not spiked. - 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_webgl2is widely disabled (to-verify per browser at 73.3), sogpu-timer.mtsmostly has no substitute andstats().gpuFrameMsreports 0, whichpublic-types.mts:571already documents. The Catmull-Rom upscaler is a straight GLSL port. - 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 onlyif (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. - 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-typesrenderer.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. - 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. - 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.mtsheader). 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. - Device loss recovery translates. The core re-mounts on an
external loss (
core.mts:2909-2960); WebGL2'swebglcontextlost/restoredevents 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:
- Weighted betweenness is contracted CPU-only and is n independent
Dijkstras —
betweenness-centrality.mts:52-57passesgpu: nullwith the no-GPU-path rejection message wheneverweightis given, and the reference loop (:207-277) runs one heap-driven Dijkstra per source, accumulatingC[w] += e[w](:270) across sources. The only cross-source coupling is that sum. - The weight is a user closure and cannot cross a worker —
weightAt(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 choicebuildBrandesNeighborsmakes (betweenness-centrality.mts:70-134— which also returnsnumber[][], so the snapshot flattens to CSR rowPtr/colIdx anyway). - 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 independentdiffuseVectorcolumns (heat-kernel.mts:344);rwrProximity's n independentsolveWalkcolumns (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. - The executor contract has the slot —
AlgoExecutoris'cpu' | 'gpu' | 'auto'(executor.mts:36),runAlgoroutes with per-familyminGpuNand falls back to CPU only on acquisition failure orGpuUnfitError(executor.mts:116-141), and the GPU side caches a lazy module singleton with a_resetAlgoGputest hook (algo-gpu.mts:32,:95) — the exact lifecycle shape the pool should copy. - 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'sC[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. - No worker exists anywhere in the tree today (grep: zero hits
for
worker_threads/new Workeroutsidenode_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 --jobs —
schedule.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.mtsupdated 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:
- Container auto-resize (#2401) is two-thirds landed. The
renderer installs a
ResizeObserveron 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 callsrenderer.resize()directly, so the public'resize'event fires only from manualcy.resize()(src/core.mts:2008-2013); no option gates the observer; and no spec anywhere exercises the observer path — grepplaywright-tests/forresize: nothing. - Font-load re-raster (#3408 + the round-9.7 follow-up) landed
its core in round 10.
renderer.mts:302-317hooksdocument.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) anyloadingdone— 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()thendocument.fonts.add(face), fires no event at all, leaving fallback glyphs forever; (c) nodocument.fonts.readybelt exists; (d) there is no user escape hatch —setFontwith the same family early-returns (src/render/glyph-atlas.mts:243-249). - Collections are not iterable.
Symbol.iteratorappears nowhere insrc/; iteration isforEach/each(src/collection.mts:683,6321),toArray, and numeric index +length(collection.mts:232-238, interned singleton handles per slot).cy.addtakesElementsInput(src/public-types.mts:134-139): defs (array or{nodes,edges}), columnar, or a wireArrayBuffer/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. - 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 pickpickNodeSync(renderer.mts:580) is what the pointer layer uses for pan-vs-grab (src/interact/pointer.mts:371) — but it is absent fromRendererLike(core.mts:82-95), so no public surface reaches it. #1209 reduces to: expose the sync half, and name the pair. - 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) callse.preventDefault()unconditionally, before theuserZoomingEnabledcheck — 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-elementpannableexists (public-types.mts:33, edges default true),panify/unpanifyexist (collection.mts:3772,3782), pannable overrides grabbable incanDrag(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. - Cull counts are on-device only. The compaction's scan
writes
instanceCountinto the indirect args (src/render/cull.mts:445) in a buffer createdSTORAGE | INDIRECT— noCOPY_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 — thepicking.mtsstaging/coalescing pattern is the in-repo precedent. The model-space sync form already exists as a composition:cy.elementsInBox(...)(core.mts:1274) overcy.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 firstanimate()-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-flightapplySize), and the plan asserts no feedback loop from the canvas's own sizing. STORAGE | INDIRECT | COPY_SRCis 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:
- Gradients exist.
background-fill(solid | linear-gradient | radial-gradient) withbackground-gradient-stop-colors/-positions/-direction(src/style.mts:812-814) andline-fillwith stops along the drawn span (src/style.mts:903-904), packed into thenode.gradient/edge.gradientUint32Array×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 thegradientsgolden (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. text-border-styleis the one honest gap.text-border-width/-color/-opacityexist (src/style.mts:874-876) and draw infsLabelas 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 ridesuv1.x(shaders.mts:4379);uv1.yappears unbound on solid quads (glyph-buffer.mts:219— the spare lane a style id would take; to-verify at implementation).- Screen-space sizing touches every model-px reader.
boundingBox()readsnode.sizein model px (src/collection.mts:2899) with labels in the box by default (round 16.4,collection.mts:2846);fitderives 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 consumeedge.widthmodel px per vertex. - Ledger 23's arithmetic, re-derived.
edge.arrowShapesholds 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 atarrow-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.
fsLabelgains 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
rendererfingerprint 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:
- Image export is
png()/jpg()only, promise-only, and renderer-bound —core.mts:2053/2064route through_exportImage(core.mts:2070), which throws on headless instances (core.mts:2087-2091), callsrenderer.exportImage()(render/renderer.mts:657, offscreen render + GPU readback), and encodes through a DOM canvas (core.mts:2094-2102). The option surface isExportOptions(public-types.mts:338-358):bg,full,scaleormaxWidth/maxHeight,quality,output. The view math — full-graph bounds vs viewport, the scale fit — iscomputeExportView(renderer.mts:833). - Edge routing is CPU-canonical.
store/curve-index.mtsderives 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 pointsasx/asyrecordedspacingbehind each boundary (collection.mts:3442-3482,curve-geometry.mts:202,:811), and midpoint + tangent (curve-geometry.mts:1899). This is exactly the surfacerouting.spec.jscompares numerically with no WebGPU adapter and no frame drawn. - Arrow polygons are CPU tables; arrow rotation is the one
GPU-only derivation.
shape-points.mts:185holdsARROW_POINTSin v3's arrow frame (tip at (0,0), lateral ±0.15), plus the compound tables and the computedARROW_MAX_BACK/ARROW_AXIAL_DEPTH. The head's position+rotation quad is built in the arrow vertex shader; the CPU carries twins ofboundaryOffset/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). - 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:493defaultsans-serif;graph-store.mts:280) — SVG<text>can carry that exact CSS font string, and there is no per-element font to represent. - Everything else the SVG needs has CPU style records:
gradients (
background-filllinear/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-257→stroke-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 indrawScene(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:
- The entire Node test tier runs headless instances — no
container, no DOM, no adapter; a headless instance is ready
immediately (
core.mts:338) withheadlessWidth/headlessHeightstanding 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 bytest:jstoday. 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 needsnavigator.gpu(gpu-context.mts:43) and a container element.- Label measurement is the one degraded surface. The glyph
atlas rasters through canvas 2D (
glyph-atlas.mtsheader) and cannot exist in Node; headless label dims come fromestimateBlockwith flat per-character advances (graph-store.mts:4029), a recorded approximation (label-wrap.mts:9-11). So a Nodesvg()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. - 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:
- The formats and where their truth lives. Element
definitions:
ElementDefinition/ElementsDefinition(public-types.mts:9-44; group inference and the accepted shapes inelement-defs.mts:10-64). The columnar bulk form:ColumnarElementswithPackedIds/DictColumn(public-types.mts:46-134). The object stylesheet:Stylesheet(public-types.mts:295) — butStylePropsisRecord<string, StylePropValue>(public-types.mts:284), deliberately stringly, so the real acceptance surface lives instyle.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 — incore.mts:703-737. The envelope:CytoscapeOptions(public-types.mts:610). The wire format is binary (wire.mtsheader) — no JSON Schema applies; its JSON-side equivalent is the columnar form. - 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 isrolldown.dts.config.mjs→scripts/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 — thetest/modules/migration-guide.mjsprecedent, whose whole design is "a prose claim about runtime behaviour verifies itself by probing the library" (migration-guide.mjs:9-25, thenameKnownprobe at:53-70). - The fixture supply exists.
debug/fixtures.jsproduces every harness network's element defs through one exportedtoGpuElements(already exercised bytest/modules/debug-harness.mjs), anddebug/styles.jsholds fourteen real hand-authored v4 sheets, including the ported enrichmentmap.org style — the exact documents the schemas must accept. - 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 files — test/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:
- The chart family is 8 props (
src/style.mts:797-804), withchart-valuestaking the{ data }per-element passthrough andchart-colorsconstants-only (list or named scheme). Readback decodes the record, not the computed (style.mts:5227-5252read throughchartAt), which constrains the design below. - 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 isoffset | 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. - Donuts already exist —
chart: pie+chart-hole(round 23 call 1). "Ring/donut" is not a new kind. - 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. - 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. - 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. - Bench:
benchmark/store.mjs:249prices 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/@paramgates at 100%,test:throwsat zero (the new heat/domain throws each get their deterministic spec), d.ts regenerated,src/README.mdchart section rewritten, MIGRATING/CHANGELOG rows,EXECUTIVE_SUMMARY.mdrewritten 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
writeChartkind 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.mtscomment 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
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
GroupNameto a three-way union would touch every co-signed surface insrc/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: anAnnotationTableowned 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?: truebeside 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.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 integerzexists 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.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: unknownkind, 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: anannotationssheet group with mappers — it would drag the compiler, the columns and the GPU mapper runtime into a feature whose population is countable on hands.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 withmovable: 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.Interop: the
__Annotationsdialect is the wire story. A pure module (no instance, no renderer) parses the|-separatedkey=valuerecords 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 aforeignbag 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 mapsshapeTypeonto v4's existing SDF shape ids (RECTANGLE/ROUNDEDRECTANGLE/ELLIPSEplus the regular polygons and stars);CUSTOMpaths warn once, are not drawn, and survive inforeign(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).zoomnormalizes 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.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-annotationfontFamily/fontStylewould re-key it by (font, char) and is out of scope, same as it is for labels. v1 honorstext,fontSizeandcolor; the imported family/style ride theforeignbag so export returns them untouched. This goes in "Known deviations from v3" — strictly, deviations from desktop — as its own entry, besideCUSTOMshapes 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 (background ↔ foreground) — 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:
- 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. - 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. - Removed elements are terminally dead (
src/README.md:1998) — norestore(), no import-formcy.json(). But the display tier is exactly the non-destructive hiding collapse needs:show()/hide()is structural state (src/collection.mts: 3644-3673,GraphStore.setVisibilityatsrc/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. - 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 lazyflush()) is the mold for a HullIndex. - Dirty tracking is one coalesced span per column per frame
(
src/store/dirty.mts:13-19,markat :57), drained by the renderer'stakeDelta. Hull recompute can key off thenode.positionspan the same way — over-recompute inside the span hull is the accepted cost, per the tracker's own doc. - 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. - The harness already has the exact fixtures this round needs
(
debug/networks.js:23-38):em-webshipsmcode_cluster_idon 354/569 nodes, andem-web-clusteredmaterialises those 41 MCODE clusters as real compound parents. - 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. - 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-clusteredis 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:
collapsedCountalways; 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) andjson()would need the field too; before paying that, shipcy.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-377reads 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:
- Curved edges are one instance per edge over a fixed strip:
CURVE_SEGS = 24quads (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. - Variable-length curve records live in the CurveBlob
(
src/store/curve-blob.mts:3-30): the f32×4edge.curveParamscolumn 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 atMAX_CURVE_PTS = 11interior points (src/curve-geometry.mts:728). - 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. - 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). - 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 = 256as the auto floor (:44),runAlgorouting (:90), kernels on the compute-onlyacquireAlgoGpucontext — no canvas, headless parity suite in the Playwrightalgorithms-gpuproject. - 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.
- 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). Acurve-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 }—edgesBundledis 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 toCURVE_SEGS − 1 = 23interior 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-edgesegment-distancesbypasses — 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- Serialization instead:
BundleResultcarries a columnar payload (edge ids + counts + offsets) thatcy.bundleEdges({ preset: result })re-applies — the preset-layout precedent, and consistent with "kept definitions are the app's job".
- Serialization instead:
- 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:
- 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), andexportColumnsalready emits index-aligned wire shapes per key (:207).DataStore.epochbumps on every value write/clear/ingest and deliberately not on dict compaction or slot remap (:105-109) — one counter over both groups. - 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 bindingdata()per row therefore pays one object per row, re-materialized after every write burst. - 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.filteron a subset does not — it callsstore.data.getper element per condition (collection.mts:1330). Nothing consults the dictionary: a stringeqre-compares strings per slot where one dict lookup plus a u32 compare per slot would do. - 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 spelledneighborhood()+ intersection today. - Change signals exist but are fine-grained: element data writes
emit
dataper element, listener-gated (collection.mts:2324,:2377), with no changed-keys payload; graph-levelcy.data()also emitsdata(core.mts:2131-2143);add/removefire per element. The epoch — the natural coarse invalidation key — is private. - Collections are eager: a result is a refs array plus interned
handles (
collection.mts:319-352), and every speed mechanism around them (theelements()memo,_dataObj,_eles, packed-key maps) assumes immutable eager refs.scanRefsIntowrites 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 } ]—kindis the column's ('number' | 'string' | 'mixed'),coveragehow many live elements carry a value. Keys are the historical union, stated in the JSDoc (a cleared key stays listed at coverage 0 — the documentedkeys()semantics, not a defect).cy.dataColumn( group, key )andeles.dataColumn( key )— a snapshot in exactly the shapesexportColumnsalready emits (Float64Array with NaN holes /{ dict, indices }/ plain array), aligned to a stated row order: insertion order for the core form (thescanRefsIntoorder, which is alsocy.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 edgesource/target, nodeparent) 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/removeevents 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 thedataevent 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
eqresolves the value to a dict index once, then u32 compares per slot;inbecomes a Set of dict indices; numeric ops test the Float64Array + presence byte directly; mixed columns keep the generictestCondition. The case-mapper absence rule (a missing value fails every op,neincluded) must survive the accelerated path — a spec pins it per kind. OnecompileColumnTesthelper serves bothscanRefsIntoandCollection.filter, andfilteralso gains the reader hoist it lacks today (collection.mts:1330vsgraph-store.mts:4222-4230). - Topology predicates, per the matcher's own extension note
(
matcher.mts:23-26):degree/inDegree/outDegreeas numeric-bound terms (nodes-only — an edges-restricted query throws, the structural-term shape atmatcher.mts:237-241), answered O(1) per slot off the CSR; andadjacentTo: 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
dataevent 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:
- 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 — aconcentricscore (default degree) bins nodes into rings bylevelWidth(concentric.mts:14-36); breadthfirst'scircle: truerings 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). - Two layout shapes exist: handle-level discrete layouts through
the
layoutPositionsfinisher (breadthfirst.mts:422; circle/ concentric/random likewise) and the columnar round-17 LayoutContext (layout/contract.mts—nodeSlots/endpoints/positions/setPositionsplus the same finisher), with force as the contract's production consumer. edgeLengthalready 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 at584-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 ridecy.json()or the wire.- 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. - 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). - The compound clamp is centered by decided design
(
hierarchy.mts:11-25): the flush materializes the children-bb center with symmetric padding andcoreW = max( bbW, minWidth )(hierarchy.mts:385-391); compound props enter viastyle.mts:4861-4871and read back at5686-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; alignment —
alignment: { horizontal?: string[][], vertical?: string[][] }
(fcose's shape; id arrays, serializable), groups sharing a node merge
transitively; relative placement —
relativePlacement: [{ 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.mjsedits batch into one commit so the benchmark fingerprint moves once (round 68). - Every geometry spec sets
headlessWidth/headlessHeightor 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,weightdefault'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? }onforce.edgeLengthandconcentric.concentric, and the sort shape{ data, order? }ongrid.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 publickind(). - 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-leftleft 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 inforce-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 theconstraindispatch 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:
- 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.ColumnMirrorconsumes 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. - But the renderer also holds direct core refs beyond ModelView:
cy._store(37 call sites),cy._styleEngine(thearrowEnds/midArrowEndstables, renderer.mts:1839-1869),cy._viewport(pan/zoom read per frame, renderer.mts:595, 2478),cy._elefor pick decode (renderer.mts:617-636), and — the deepest coupling —cy._animations: the frame loop is the animation clock (attachDriverat 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-typingrenderer.startForce(layout/force.mts:426-445). - The sync surface:
pickNodeSync(renderer.mts:580; five pointer call sites, the pan-vs-grab decision at pointer.mts:342) resolves throughpickNodeAtover 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.exportImageandpickare Promises already;stats()is a sync counter snapshot. - 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. - 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 offgpuFrameReadingstransitions, 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:
RenderStoreViewextends the contract'sModelViewwith everything the renderer and label layer actually consume beyond it, enumerated: the frame-uniform scalars (curveSlack,haystackSlack,outlineSlack,arrowScaleMax,arrowWidthMax), the draw-gating counts (parentCount…hasCurvedEdges),flushDerived,boundingBox(export views),compactEpoch,takeMapperSpans,nodeImagesAt, and the label layer's font surface andsetLabelDimswrite-back.GraphStoresatisfies 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 ⇒ noMapperRuntimeis constructed — the CPU-applied style columns stay canonical) andcreateImageDecoder(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 (nodeslot + 1; edges carryEDGE_PICK_BIT),pickNodeSync()answers a slot, andCore._decodePickdoes the id decode plus the two-frame-staleness revalidation against the live model. The pointer layer wraps sync picks through anodeAthelper. 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
Rendererclass runs in the worker, constructed against anOffscreenMount(transferred canvas + explicit device-px size + the main thread's resolved dpr) instead of a container;setSizereplaces the ResizeObserver, the glyph atlas rasterizes on anOffscreenCanvaswheredocumentis absent, and export views arrive pre-resolved (exportFromView; the view maths extracted as the pureresolveExportView). - The message contract (
worker-protocol.mts, written first, under the contract.mts discipline): oneStoreBatchshape 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+collectTransfersare pure over the store, so the Node spec exercises the very code the proxy runs. RemoteModelView(remote-view.mts) implements the whole 86.2RenderStoreViewseam over local mirrors, re-expressing each batch through the sameDirtyTrackerclass the store uses — the renderer's frame drains an ordinaryStoreDeltaand cannot tell the hosts apart. Measured label dims flow back (labeldimsmessages) into the canonical store, where label bounds and the text-events pick live.- The proxy (
worker-renderer.mts) satisfiesRendererLikeand the pointer layer's newGestureRenderersurface. 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 (pickNodeAtover 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 —
importScriptsfor the UMD script-tag path, a message-buffering dynamic import for native-ESM — and callscytoscape.__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, sotest/modules/packaging.mjsis untouched. - Fail-loudly: mounting without Worker/OffscreenCanvas throws its
own clear message (spec'd in Node, where neither exists); a worker
without WebGPU rejects
readythrough aniniterrormessage. 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:
- Packing is force-private. All of it sits in
src/layout/force-init.mts:computeComponents(:36-88),estimateComponentRadius(:100-105),packAnchors(:160-203) andpackComponentsExact(:261-336 — "exactly v3'sseparateComponents, translation-only", per its own header), while the actual bin-packershelfPack(:119-148) and itsPackBoxshape (: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). - The GPU handoff is gated on
animate === true(force.mts:423), soanimate: falseruns 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-1413passesmirror.buffer('node.position')toencode), 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. - Discrete
animatesupport is split down the middle. circle/concentric/breadthfirst/random finish througheles.layoutPositions(collection.mts:5280-5443), whose animate branch tweens per node with the fit-at-final-positions viewport animation (:5377-5433). grid and preset ignoreanimate,animateFilterandtransform, and never calloptions.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-475exercises circle and random). - The debug page forwards
animateonly for force (debug/layout.js:47-52; the?layout=load path hardcodesanimate: truefor 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 onCustomLayoutalone (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 itsconsole.timeEndnever fires. ForceLayoutOptionsis missing four options the layout accepts (public-types.mts:484-504vsforce.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: trueon 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 lackpromise()/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.mjsedits 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.1 —
src/layout/pack.mtsextracted verbatim (round-42 body-diff checked; seeded force suites bit-identical),shelfPack/PackBoxexported 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.mtsleft 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 flatanimate: falsesync → 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;
spacingFactorhanded 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,
animatejoined 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
ForceLayoutOptionswith 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:
- 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), andsrc/README.md's layer paragraph records the same. v3:drawEdgeOverlayUnderlaysetscontext.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), anddrawEdgesetslineJoin = '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. - 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 (thelength(m) < 1e-4fallback, :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. - 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. - Ledger item 27 is the same surface and still open: v4 strokes
the band at
width + 2 × padding, v3 at2 × paddingalone. 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-layersgolden 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-typesandedge-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:
- 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 undersrc/orv3/src/touchesstyle.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 amouseover/mouseoutpair writingcontainer.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. - Every state the cursor depends on is already tracked in one
object.
PointerHandlerowns hover (updateHover,src/interact/pointer.mts:1586, fed by the throttled async pick at :1563 and cleared onpointerleaveat :195), the press mode'pan' | 'grab' | 'box'decided once at pointerdown (:405), the tap-thresholdmovedflip (: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. - 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
mouseoveralready has, and the pan/drag cursors must key off the press, never off hover. - 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 thegrabbingaffordance unless something writes at document level. - The option surface has an obvious seat.
CytoscapeOptionscarries 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 notdefaultorgrab: 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
canDragaccepts: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; aftercy.autoungrabify( true )the same hover readspointer; hover an edge →pointer; leave the canvas →''; - background press-drag →
grabbingfrom 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
grabbingwould 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
cursorstyle 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-menuduring 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 (
progressduring 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 thefalseopt-out are the mitigations, and MIGRATING.md names the behaviour. Do not "improve" idle todefault: 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
updateHovershape). - 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
stepsbatch 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.hoverOntonudges inside the poll, which is state-driven, not a sleep-to-offset. - Waiting on the hover is what makes the control honest. With
pointerCursors: falsea 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:
- v3-internal, now public (42 names). The headline cases, each
verified in v3's source:
cy.forceRender()(core.mts:2001) is@internalin 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@internaland commented "for backwards compatibility" (v3/src/core/notification.mts: 117-118, 151) — legacy twice over; andinstanceString()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, thesilent*family), and some are internally load-bearing so they can demote but not vanish (boundingBoxAtislayoutPositions' bounds source,collection.mts:5405;getFitViewport/getCenterPanbackanimate()'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. - Machinery classes riding the public tier.
cy.style()returns the wholeStyleEngine(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.mtsis 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 theAnimationhandle 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._viewportis private — so the class may belong in the internal tier outright. The root cause is structural:PUBLIC_APIis 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 TSprivatecannot mark them; the fix needs a marker the audits, the docs generator and the d.ts build all respect (@internalsupport, or the_prefix the scanner already skips) — that mechanism choice is itself a maintainer decision. - Verified-deliberate v4 additions — no action, listed so the
next reader knows they were checked: the algorithm tier
(rounds 65/69/70:
katzCentralitythroughmotifCensus), 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, theLayoutContext/CustomLayoutextension surface (round 71's substrate), and theEventfield surface (type,originalEvent,timeStamp,preventDefault,stopPropagation— v3's event shape, documented there in prose rather than as fns). - Discussion, not caught by the diff because v3 documents them:
the jQuery-era listener aliases
bind/unbind/removeListener/pon(core.mts:1427,1520,1522,1577and the Collection equivalents). Era cruft by the same argument asforceRender, 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:
- 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 inscripts/): 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/@throwsgates stop applying, since those exist for shipped hover text); the docs generator omits tagged members; and the d.ts build strips them fromdist/cytoscape.d.ts(rolldown-plugin-dtscarries doc blocks through, so the tag is visible in the declaration text and a post-pass inscripts/build-dts.mjscan 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. - The seven flagged members, ruled member by member:
forceRender,onRender/offRenderandmutableElementsare removed (onRenderwas pure sugar foron('render', …);mutableElementsiselements()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@internalrather than removed — zero callers insrc/, 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)andsilentShiftare demoted to@internal— the plan's keep-public recommendation is overruled; the layout path isctx.setPositions/layoutPositionsand consumers should not see the silent family (zerosrc/callers, verified); andcy.renderer()(andCollection.renderer()) are demoted to@internal, with the stats story resolved by option (b): a new typedcy.stats()passthrough (RendererStats | null, null when headless) so the one documented consumer surface that lived behindrenderer()survives the demotion. The debug page's stats overlay moves tocy.stats()with it. - The events API is ruled by one principle: analogous to Node's
EventEmitter, pluspon. SoaddListener/removeListenerstay (they are Node's own spellings),ponstays (the maintainer likes it),bind/unbindare removed, andlisten/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 saidoncewould join as an alias ofone— measurement says it has been there all along (core.mts/collection.mtsboth declare it), so the ruling's Node-emitter set was already complete and nothing was added. - Class 2 lands as recommended: the
StyleEnginemachinery (~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; theAnimationhandle a consumer holds is untouched.Viewportleaves the public tier entirely — nothing hands a consumer one (cy._viewportis private,index.mtsexports nothing viewport-shaped) — by droppingsrc/viewport.mtsfromPUBLIC_APIand stripping the class from the declaration. - The remaining class-1 table, delegated to the round with the
leanings endorsed: keep, documented as deliberate —
zoomRange,multiClickDebounceTime,isReady,headless,styleEnabled,hasCompoundNodes,hasElementWithId(both owners),window,options,indexOf/indexOfId,takesUpSpace,interactive,padding,paddedWidth/paddedHeight,show/hide; demote to@internal—batching,getFitViewport/getCenterPan(they backanimate()'s fit/center),element,byGroup,isBundledBezier,inactive/activate/unactivate,boundingBoxAt(layoutPositions' bounds source); remove —batchData(@internaland "for backwards compatibility" in v3 — legacy twice over, anddata()covers it).
Two insights from the pre-implementation verification, recorded so the implementation sweeps for them:
src/animation.mts:690has a runtime error message that advisesonRenderby 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.mjshas rows callingmutableElementsand the silent family — themutableElementsrow 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:
- 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) callsresize()→applySize()(renderer.mts:2407-2415), which sizescanvas.width/heightfromcontainer.clientWidth × dpr. So the distortion is transient, not steady-state — but during a live window drag "transient" is every frame of the drag. - The stretch mechanism is the
100%canvas CSS plus a frame-late redraw. v4's canvas is styledwidth/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 isschedule()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. - 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. dpris frozen at construction (renderer.mts:319-322) where v3 readsdevicePixelRatiolive 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:applySizeis the one consumer. Note browser zoom does not changeclientWidth(CSS px), so the ResizeObserver alone will not catch it — the standard hook is amatchMedia('(resolution: …)')listener re-armed per change.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): callframe()directly afterapplySize()instead ofschedule()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 (aframe()mid-rAF-chain must not double-tick animations — the clock isperformance.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 toleratecanvas.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/heightwrites) and the swapchain resize is the browser's own; if profiling shows reallocation churn inensureSceneTargetduring 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:
- Measured today: fit zoom 0.874 against an exact-box fit of
1.077 (
Collection.boundingBoxreads 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". - The slack is asymmetric, so it also de-centers. 54.1's
directional compound-loop box grows up and left only;
fitcenters 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. - 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. - 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 viacurveRouteAtPositions; 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_BOXkind — compound loops, taxi, weight-extrapolated blobs — through the exact memoized curve bb:GraphStore.boundingBoxgeneralizes round 54's taxi-onlycurveBBAtbranch (the directionalp2box and the per-edge outer-half + chord margin are deleted, 54.3'scurveBoxMarginprecedent), andCollection.boundingBoxAtcalls the newcurveBBAtPositions—curveBBAt's sampling at hypothetical centres, unmemoized (no epoch covers a hypothetical), backed by the newcurveEvalAtPositions(theCurveEvaltwin of 54.2'scurveRouteAtPositions) 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 derivedouterHalf— including materialized parent auto-bounds (materializeParentGeombumps after writing, andcurveBBAtrunsflushDerived()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. -
curveBBAtanswers 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:
- Every curved edge is one strip of
CURVE_SEGS = 24quads (src/curve-geometry.mts:55; indexCount 6×24 per instance,src/render/cull.mts:803), evaluated in the vertex shader. - 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. - 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 spends2n+1pieces forninterior 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. - 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.jsis 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 moved — segments-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:
- 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 atshaders.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. - v3 rasterizes text per frame at the current zoom (canvas
fillTextunder 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. - 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_SIZE32 → 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_PADagainst 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 afterstats()reports tier 2 —waitForAtlasTier, because frame-count exports race the 250 ms settle meter.label-outline-closeupgains 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:
- 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 from0.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. - v3 never composites a stroke over a fill within a line: it
draws
strokeTextfor the whole line, thenfillTextover it (v3/src/extensions/renderer/canvas/drawing-label-text.mts: 399-413) — all outline under all ink, per wrapped line. - 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 infsLabel) 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:
- Given v3's exact parameters, v4 routes
ehidentically — 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. - The debug sheet cannot give
ehits own arrays, and says so. v3 styles#ehas round-segments withsegment-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 matcheh. 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). - Round 63's
bypassessection closes it, verified: a bypass entry carrying v3's arrays forehproduces v3's exactsegmentPoints— 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). - What remains visually after the arrays match is arc faceting at the radius-50 corners — round 93's defect, deliberately not this round's.
- The segment families have zero numeric parity coverage:
routing.spec.jsprobes bezier/taxi-era scenes and nothing withsegmentPoints()(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:
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:cpuPickNodehit ⇒ return). The edge pick (cached tile or GPU pass) runs only when no node was hit.- The node scan itself already knows better:
cpu-pick.mtsscans 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. - 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. - Both gesture seats route through the same
renderer.pick()(press atsrc/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
padssemantics: the edge halo (edgePadPx) and node halo (nodePadPx) are v3'sfindNearestElementthresholds (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) overHierarchyIndex.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 byz-compound-depthfirst and only within a depth puts edges under nodes (z-index-compare: auto);findNearestElementsthen walks that list in reverse draw order and takes the first hit (v3/src/extensions/renderer/base/coord-ele-math/coords.mts:328-337, returningnear[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.mts — z-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 offresolvePressTarget; if the edge tier outranks the parent before the press moves, the grab is dropped (dropProvisionalGrab) with thefree/freeonthat balances thegrab/grabonalready 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.
tapreadsthis.domEventfororiginalEvent, and the listener wrapper clears it in afinally(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:
src/imports zero runtime built-ins and zero bare specifiers — nonode:import anywhere, no dependency inpackage.json(dependenciesis 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.mjsclassifies bare specifiers as "a dependency, not a repo edge" and skips them (its own comment, ~line 73), so anode:fsimport added tomorrow passes the invariant that reads as pinning this.- 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 aDate.now()fallback (src/animation.mts:2241), and the animation auto-driver already guardsrequestAnimationFramebehind atypeofcheck with a 16 mssetTimeoutfallback (animation.mts:1741-1748). Everything named is in the WinterTC minimum-common-API baseline. - The GPU algorithm tier needs no canvas and no DOM.
src/algorithms/algo-gpu.mtssays it in its header — "a headless instance can run GPU algorithms wherevernavigator.gpuexists" — and gates onglobalThis.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). - Nothing runs any artifact of this repo under any runtime but
Node and the Playwright browsers.
enginessaysnode >= 24, CI isci-node+ theci-browsermatrix (tests.yml:13,39), the Node tier runssrc/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. - The test suite is
node:test+ chai behind a shim (test/node-test-setup.mjs), over.mtssources imported through.mjsspecifiers 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/headlessHeightset (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
TextDecoderproduces 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'squeueMicrotaskflush 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@^4specifier and the CDN<script>form — landing insrc/README.md/MIGRATING.mdnow 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 fromdist/, 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/ browserWorker— 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'ssvg()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:js— 3,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:soak44 lines,test:throws9,build11,lint6,typecheck4 — 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
:quiettwin —test:js:quiet,test:modules:quiet,test:soak:quiet,test:throws:quiet,lint:quiet,typecheck:quiet,build:quiet— plus the compositestest:node:quiet,test:playwright:quietandtest:quiet, built from the quiet pieces with the same structure as the loud ones (test:modules:quietstill 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.mjsparsespackage.jsonand 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 -sdrops it, and whether-son the outer call silences the nestedrun-schildren's banners too (loglevel should inherit through the environment) is measured at implementation and the answer written intoAGENTS.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-scriptsspec 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
yielddropped) — 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:quietloses 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.mtshoverPick, ~:1563;::hoveredinsrc/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 andstyle()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 (asrc/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):
- 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.
- 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.
- Design (b): column write + upload cost, and the full-frame shader cost of the multiply.
- 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:
- 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 (CurveIndexre-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. - The API sketch, refined after (1): a chunk-accepting load
—
cytoscape( { elements: asyncIterable, ... } )or an explicitcy.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, andcy.readymeaning "first chunk rendered" with a second signal for "complete" (naming open). - 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.
- 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:
- 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.
- The greedy pass's cost at those counts (it is a sort plus a linear claim walk; the sort is the suspect).
- 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.jsand 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 thedebug/slim-ndex.mjsway — 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, andbezierbundles 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):
- 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. - 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
- 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-republish —mark()/markResized()/touch()stay byte-identical (the mutation hot path), and the first consumer to drain takes the live state exactly as today'stake()does, then folds the result into the other consumers' pending buffers, so fan-out costs at frame rate, not mutation rate.registerConsumer()returns aDeltaConsumer(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 atdirty.mts:156becomes 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.mtsis the co-signed source of truth: itstakeDelta/consumer docs change first in the diff, with the mirror docs atdirty.mts:93-102andgraph-store.mts:574-579in 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. cy.clone( opts ), one-shot. Serialize → new instance ingest; the style sheet carried (opts.styleoverrides 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; thejson()element form carries locked/grabbable/pannable and bypasses at definition-ingest speed).- 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.
- 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
viewporttwice per tick:core.mts:1800passes['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.mtsdrains 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;
mergemode'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:
- destroy + recreate (the honest baseline apps use today);
- app-side diff through the public API (
remove()+add()+batchData); - 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 /
merge — patch 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.mdis 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 Fedoralibjpeg.so.8recipe. Its longest line is 2,021 characters. - F2 —
PLAN.mdcannot 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 outsidev3/:style457 → 189,renderer213 → 117,boundingBox116 → 73,controlPoints37 → 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 nestedAGENTS.mdinv3/, so an agent that opensv3/src/style/index.mtsand 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, branchround-90-api-cleanup, still registered ingit worktree list) makestest:modulesred: the round-44.2 markdown allowlist intest/modules/packaging.mjssees 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.npmignoreis a denylist with no.claudeentry, sonpm pack --dry-runlists 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:
typecheck0.2 s,lint0.2 s,build1.0 s,test:js7.4 s,test:soak1.6 s,test:throws31.5 s,test:modules46.9 s —test:node≈ 88 s, of which the two audits and thenpm 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.jsonandtests-examples/demo-todo-app.spec.jsare 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.mdtoday; 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-sidestyleEnabled/layoutbias.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 thatAGENTS.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 hoped — test/modules/agent-docs.mjs:
AGENTS.mdis under the byte budget. Without this the file grows back, which is exactly how it got here.- Every rooted path mentioned in
AGENTS.mdor anydocs/agents/*.mdresolves on disk — round 42's lesson (existsSync, allowing for the.mjs-specifier convention), applied to the agent docs for the first time. - Every
docs/agents/*.mdis linked fromAGENTS.md, and every link fromAGENTS.mdintodocs/agents/names a file that exists — an unlinked note is an invisible note. - Every
npm runscript named in the agent docs exists inpackage.json, and (the reverse) every verification script inpackage.jsonis 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.mjsregeneratesplan/INDEX.mdand the index block insidePLAN.mdfrom the round files' front matter (round number, date, title, status: planned / landed).test/modules/plan-record.mjsgates 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.
108.3 — v3 is declared frozen, and quiet by default in search
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
.ignorelistingv3/(andstatus/,test-results/) so ripgrep andfdskip 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 "grepv3/documentation/docmaker.jsonfor the v3 API" workflow inAGENTS.mdis rewritten to carry the flag. - Measured after, not assumed: the four probe terms are
re-counted through a default
rginvocation 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:
.npmignoregains.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.npmignoreand pass after.- Every tool that walks the tree is audited for the same blind
spot and excludes
.claude/worktreesexplicitly:jsdoc-coverage.mjs,throw-coverage.mjs,docs-generate.mjs, the status build,packaging.mjs,quiet-scripts.mjs. A walker that finds a second copy ofsrc/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 listis 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.mdand watchagent-docs.mjsgo red. - The path check can fail — introduce one bad path in a moved note; it must be named, not merely counted.
- The
.claudepack 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
.ignorelands. - 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 intoplan/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, againsttest:node's ~88 s. Both twins land together and the new twin is registered intest/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?":
.npmignoreis a denylist, so every new top-level directory ships. It happened twice in one round —plan/anddocs/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.mjsverified the path exemptions againstPLAN.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'sHISTORICAL_PATHS. - The site would have lost what the root file gave up. Moving 58 KB
out of
AGENTS.mdwould 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-90and its branch are kept — 8 unmerged commits, the maintainer's call. - The notes live at
docs/agents/, as assumed at planning;PLAN.mdkeeps 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.4 → 9.4, rnd0102_0107 → 102–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:
- 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
beforeEachregistration 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. - 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.execArgvor 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
- Wire decode is already zero-copy.
deserializeElementsreturns 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. - Bulk ingest copies payload columns into store backing.
_addColumnarwrites 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. - The per-frame CPU→GPU upload is the floor, and it is WebGPU's.
ColumnMirroruploads one coalesced span per dirty column viaqueue.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 (mapAsyncstaging rings,mappedAtCreationfor 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().uploadedBytesexists) so the record can say "at the floor" with a number rather than a shrug. - 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. - Export readback does per-pixel CPU work, not just a copy.
readbackExportmaps the staging buffer, then walks every pixel in JS: row-unpad, BGRA swizzle, un-premultiply (renderer.mts, thereadbackExportloop) — at a 4K-class publication figure that is ~33 MB touched byte-by-byte in JS, thency.pngcopies again through canvas2dputImageData→toDataURL. 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. - 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.elementsarrays 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-largeat load and on an animated session, headless and rendered. Publish the table in the record andsrc/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.endpointsis 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, spelledrenderer: { 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
plan→landed: 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 (functionalpreventDefault(), 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
### Landedheading 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
landed→note:rnd0091_0097,rnd0098_0100andrnd0102_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 aslanded, they marked seventeen unbuilt rounds as shipped. They are notes, like thernd0015_0018design 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
planfile 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 toplanturns the gate red. - No round is both a
planand alandedrecord. 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.mdpublishes 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 bynpm run plan:indexand 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
rnd0040is renamedplan→landed. 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.landingEvidencegainscloses, besidedeclares: 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
planfile that records its own close, and the index's derived table moves round 40 intolanded—7–48now 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 theLayoutunion 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:
- 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.
- 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). - 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.
- 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. - 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. - Y-coordinates — rank rows from cumulative max half-height plus
rankSep; computed canonically downward, transformed todirectionat 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.mts—FlowLayoutImpl implements LayoutImpl; defaults merge, loud validation, mappings resolved once; per weak component build → FAS → rank → order → position → y → direction; thenctx.packComponents; bare runs take the columnarctx.setPositionspath, animate/transform/subset take thectx.layoutPositionsfinisher (grid's hybrid rule).flow-graph.mts— compact reindex ofnodeSlots()into a typed-arrayFlowGraph(src/tgt/weight/minLen/reversed edge columns, CSR both directions, halfW/halfH from the size column, group columns frompaddingSumsOfin global compound mode); parallel edges collapse to weights, loops drop;greedyFAS+dfsFAS.flow-rank.mts—rankLongestPath,rankNetworkSimplex,promoteNodes; global compound mode synthesizes per-group border ranks (bTop/bBotvirtual 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.mts—assignX(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.mjsover 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/dagreandelkjsas devDependencies (outsidesrc/, 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 withnpm 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.mdsection, debug dropdown, CHANGELOG, and abenchmark/layouts.mjsrow (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).
alignLongEdgeswas 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 theflow-taxiadapter so the gap stays measured; the option returns with the taxi-aware pass (112.5).compoundModewas 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
packComponentsExactoverlapping deps' 164 singleton components (130 body overlaps), and flow packs its own extents viashelfPack.
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 at240d80a2, the commit that added the row — it had never passed at the size the--alltable 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()hashesMath.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.mjsrecomputes the id from each published run's storedmeta.machineblock — the harness backfill's shape from 65.12 — and all 21 runs moved from2d2ea233to5cf3f79c, 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.mjsexits before spawning anything on a different major (--any-nodeis the deliberate override, for pricing a runtime rather than the library);benchmark:publishcarriesmeta.nodeVersioninto the index asnodeand 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:
- Through the built bundle, the same operation (
nodes().filter(n => n.data('weight') > 3), 2,000 nodes) read 68.6 µs at0d3561deand 73.4 µs at HEAD — +7%, not +19%; the suite's tsx__namewrapper amplifies closure-heavy rows (round 34's lesson). - 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. - 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 ata34e96bd, 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._queryanswered 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, whileelements()had answered the same question from the round-34.2 memo since round 34. It now filters_allOf(restrict), socy.filter(fn),cy.nodes(fn)andcy.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'shandlespath, whichslice()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:
- 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
--repeatruns record, before deciding whether 94.1's first-raster work is a cost worth a lazy tier. - 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. - 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
--repeatfor the CPU side alone would screen them. - The GPU-side speedups with no source change. Worth one
npm run gpuand a driver-version line inmeta.adapterso 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:
nodeDimensionsIncludeLabelsdefaults totrue. A recorded v4 deviation from v3 (whose default wasfalse), consistent with round 16.4's decision thatboundingBox()/fit()include labels. - Full v3 lock semantics. A locked node — or every node under
cy.autolock(true)— holds its position againstposition()writes, position tweens and every layout; it still takes part in a layout's structure and is an obstacle for overlap avoidance. animateLive: trueis force's live-streaming spelling;animate: truebecomes 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.mtsandctx.nodeDimensions();Collection#layoutDimensionsreimplemented over it, its default flipped to labels-on. - 114.2 The finisher tests
fit !== falseand animates a lone zoom or pan;ctx.layoutPositions( fn, overrides )re-pins the wrapper'sstop;ctx.finish(); flow'srun()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
packComponentBodiesshared;ctx.packComponentspacks 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:
animatetweens,animateLivestreams,avoidOverlapseparates. 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=truefits; 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 (theanywhererule 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 ofbenchmark/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: truechanges meaning (streaming → tween);animateLiveis the escape hatch, andMIGRATING.mdsays so. fit !== falsein 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:
- 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. - 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 thenode.opacitycolumn and multiply it in the fragment stage, and the label pass alone did not (onlytext-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), socolor,text-opacityand the outline / background / border channels read back declared. v3's effective label alpha isopacity × 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-opacityon 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)
boundingBoxon 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: falserestores 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. AboundingBoxholds 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 becomesbbox: 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: falseoverlaps.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; nopositionevent 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
n8locked andn4dragged by its padding:n8stayed at (155, 200),n9moved with the pointer, andn4read 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
avoidOverlapInSimthe 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
avoidOverlapInSimrun.
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: trueholds 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:
- 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.
- 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.
- 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.
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.
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'sapplySepfolds its pushes into the same atomic max the poll reads).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.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:
grabon a scoped node pins it into the sim (ForceSim.setPinned; on the deviceGpuForceRuntime.setPinnedrewrites the one slot word whose bit 31 is the pin).- every
positionevent on a scoped node — the pointer's drag writes throughnode.position(), and so does a program — copies the store's coordinates into the sim (setPosition; on the device an 8-bytequeue.writeBufferintosimPos, 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.wakeForceon the device). freereleases the pin and reheats, so the field relaxes around where the node was left.add/removeunder a whole-graph scope ask for a rebuild: the currentrunOnceends (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), andrunOncegoes 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, layoutstart → layoutstop:
| 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, layoutstart → layoutstop,
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) andradial. - Cytoscape.js v3 (
cytoscape ^3.34):grid,circle,cose,concentric, andcytoscape-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
- Component packing on the discrete layouts, and a standalone
re-pack. Asked for by EnrichmentMap, which vendored a packer and
runs
gridfor 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 throughforce's settle and throughflow, which packs its own;breadthfirst,radial,circle,concentricandgridplace 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 loggedpackComponents: trueon the discrete layouts as a small round; nothing took it. The shape: apackComponentsoption on the five (run per component, shelf-pack the boxes, group and order as force does), and apacklayout that regroups existing positions with no sim — the EM "keep my sim, regroup" case. Logged as item 58; the next layout round. - Scale as a reason to switch engines. Cytoscape Web falls back
to
gridat 1,000 elements and added Cosmos for the sizes beyond that. Both are v3-cose limitations, not use cases: v4'sforcelays out em-web (569 nodes, 6,899 edges — 7.5k elements, seven times the threshold that sends Cytoscape Web togrid) 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. - A left-to-right layered layout. Cytoscape Web wrote one.
flowtakesdirection: 'rightward'; covered since 112. - A tidy tree (Reingold–Tilford / Buchheim: non-layered, compact,
the tidytree extension's case). Shipped by neither app;
radial,breadthfirstandflowbetween them draw a tree three ways. Declined until an app asks. - A crossing-minimised circle (AVSDF) and cluster circles
(CiSE). Shipped by neither app. The
sortmapping oncircleis 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. - 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
forceabsorbed fcose's constraints (85.2), its spectral seed (59), cola'sinfinite(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 listbreadthfirstandrandomafter them. - No second engine for scale. A v3 app's "too big for a layout"
threshold does not carry to v4;
forceis 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
componentSpacingapart, 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):
- 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. - 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, withbundle_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 ataxi-direction: rightwardedge whosetaxi-turn(px from the parent) isb.x − parent.x— the bundle's trunk is a taxi turn distance, and the family's trunks are routing tracks. - A node in several bundles is stretched so the attachment points
stagger by
metro_d = 4px (the metro-map merge). - Long links get a larger corner radius (
c1 = min(bigc, xb−xt, yb−yt) − cwhen the span is > 1) so they read as a sweep rather than a step. - Every bundle's path is drawn twice, casing then colour, bundle by
bundle (
stroke=white width 5, thencolor(b) width 2), so a later bundle's casing gaps the earlier line at a crossing. - 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 leavescratch(concentric.mts:153,breadthfirst.mts:82). TheLayoutContext(src/layout/ contract.mts:52-540) has no per-edge result channel. taxi-turnis 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-9448→src/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 bothevalTaxi(src/curve-geometry.mts:1387-1560, v3'sfindTaxiPointsverbatim) andevalRouteWinsrc/render/shaders.mts— the dual- implementation discipline. The px turn is measured from the source boundary along the taxi axis (d = turn × sgnL, thensrcH/2whenedge-distancesincludes the body); a turn insidetaxi-turn-min-distance(10) of either end silently falls to the Z-/L-shape (:1466-1470). - The casing exists —
line-outline-width/line-outline-color(round 13 B4, columnedge.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 atflow.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 withheldalignLongEdges(112.5's first lever). - The harness already scores the taxi polyline:
benchmark/ layout-quality.mjs:387-417(flow-taxi, 50 % turn) overcountCrossings(:56-165, proper intersections only). - A categorical scheme is on hand for the colour recipe
(
dark2,src/style-schemes.mts:214).
The decisions
- The pass lives in the style layer, not in the layout. A
taxi-turn: autoedge 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 writescontrol-point-distances). Flow's contribution is what only the layout can give: node-free corridors and a gap wide enough for the tracks. - 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.familygroups 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.targetis the mirror for fan-in drawings (the turn near the target, v3's negative-turn sense). - 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-distanceto 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 atbandMid + (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 — anautoedge with nothing to disambiguate draws exactly what'50%'draws today. - Delivery is one f32 column, not the blob.
edge.taxiTrack(px, signed astaxi-turnis) is written by a CPU pass over theautoedges, refreshed lazily off the geo epoch, not eagerly in the position write: the store'ssetPositionsonly writes the column and bumpsgeoEpoch, 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 bothevalTaxiandevalRouteWread 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 noautoedge exists.segmentPoints(),boundingBox(), picking and the parity ledger follow because they read the same route. - 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 usingline-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. - 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 tomax(rankSep, slots × edgeSep + 2 × minTurn),edgeSepa new option defaulting to the style default's 10 — positions only, ELK'sedgeSpacing. Both hold whentaxi-turnis a constant too; the gap is simply wider. - Declined: stretching a node to stagger its attachment points
(device 3 —
source-endpoint/target-endpointare constants and the merge at a shared target already reads as a metro junction); the long-edge radius (device 4 —taxi-radiusis mappable, a recipe); a colour-per-bundle mechanism (device 6 — aline-colorordinal 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-tracksrow that assigns tracks from a prototype of the module over flow's positions. Rows:flow-taxiat 50 %, at20px, 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 undersrc/).test/taxi-tracks.mjs: disjoint runs untouched, a bus shares one turn,familyputs 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, theedge.taxiTrackcolumn and its epoch-keyed refresh at frame start and on the CPU readers,evalTaxiandevalRouteWreading it. Gates:test/curve- derivation.mjs,test/curve-routes.mjs,test/style-readback- all.mjsandtest/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.mjsand the routing ledger re-baselined with the crossing gap asserted in pixels. - 124.5 — flow.
edgeSepand the gap growth; target-anchored, merged corridors inflow-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 totaxi-turn: autowith a background-coloured casing, and a Greek-gods scene withtaxi-track: family,dark2per family anddirection: '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
autoedge 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.
- 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. - 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
--layoutrows. 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. - 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.
- 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.
avoidOverlapwith labels is broken, at least onforce. The label-inclusive overlap path does not hold the labels apart. → 125.1.avoidOverlapfights the tidy small components onforce: 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,avoidOverlapon,packComponentson, tidy on. → 125.9. flowis too spread out on reactome, even withavoidOverlapoff; 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-inclusiveavoidOverlapcase. WithavoidOverlapoff, reactome underflowis mostly fine. → 125.2.flowhas 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.breadthfirston reactome is too airy even withoutavoidOverlap. → 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
spacingFactorfor the next run. The new one should rescale the current positions about their centre as it is dragged — apresetwith 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
flowthe 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,gridandconcentriccompute their spacing from a factor over their own default; the maintainer's suggestion is an option that names the margin between nodes directly (anodeSeparation/ 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 withspacingFactorandavoidOverlap, 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):
- 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_CAP1.25,EXPAND_PERCENTILE0.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. avoidOverlapPadding10 → 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.edgeLength60 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 readsnodeSepcentre 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.
assignXseparated a rank byhalfW = max(-x1, x2)andassignYstacked rows byhalfHlikewise, 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 (padXalong 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.mtshad 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 carryleft / right / top / bottomon the canonical axes (CanonicalExtents) in place ofhalfW / 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 byright(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;compactends withplaceFree— 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'srankPadMarginstakes the pad array.test/layout-flow.mjs, "extents and placement (125.2)": a right-hung label's boxes sitnodeSepapart; an under-hung label's rows sitrankSepapart; 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,avoidOverlapdocs),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):
nodeSepsets 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.rankSepbelow the track floor does nothing: the gap below a rank ismax(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
nodeSepapart — 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.
- Root inference on a directed component. Infer
rootsas the nodes with no incoming edge when a component has any (v3's breadthfirst rule underdirected: true), falling back to maximum degree; the picture reads as a hierarchy, at 274 crossings against 140 on reactome. Adirectedoption (breadthfirst's) is the alternative spelling that keeps today's default. - 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 orflow's picture, not radial's, and say so in the docs. The sitting decides whether the band is worth a sub-round. condenseas the default is the third call; the app graphs are unaffected either way.- 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
radiusoravoidOverlap's growth changed that.condense: true(designed with 125.3) makes the ring the tangential radius atavoidOverlapPadding: 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
sortmapping 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) andclockwiseread 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 withavoidOverlapoff. It is the model the 125.3 / 125.5condenseoption copies, and its spelling (minNodeSpacing) is v3's; the sitting's call on one spelling across the four is whetherminNodeSpacingshould also exist on circle and radial as the gap, oravoidOverlapPaddingon concentric. equidistantspreads 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/colsinference follows the box's aspect (v3'ssqrt(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 documentedavoidOverlapbehaviour.condenseis 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. UnderpackComponentsthe 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: whethercondenseshould 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
sortandpositionmappings (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:
- Sub-round 125.11 — the page's option surface. Expose more,
perhaps all, of each layout's options on the debug page, flow's
directionfirst, each option shown only when a compatible layout is selected. The page sittings for 125.2 and 125.9 wait on it. - 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 andcondense'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. - Item 62 — AVSDF, reconsidered. The crossing-minimised ring
order round 122 declined, now that the clustered fixture measures
it (the
sortmapping alone cuts crossings 4.3×).
- Sub-round 125.11 — the page's option surface. Expose more,
perhaps all, of each layout's options on the debug page, flow's
- 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:quietandnpm run -s test:node:quiet. Exercise affected compute paths and runnpm run -s test:playwright:quietfor shader compilation, rendering and pixel parity. Open the debug harness withnpm 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.mtsdeclaresCOL, oneas constobject holding every column id with the JSDoc that used to sit on the union member, and derivesColumnIdfrom it — so the union cannot drift from the object, andCOLUMN_SPECSis written againstCOLrather than spelling the ids a second time.- Every
'node.*'/'edge.*'literal undersrc/outside the contract becomesCOL.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 thatCOL.NODE_POSITIONstill 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_TARGETbesideGroupNameinsrc/contract.mts(the columnar converter, the store and the style engine all already import the contract;element-defswould be a new edge for the store).EndKeynames the source/target pair.- The comparisons in
collection.mts,columnar.mts,graph-store.mtsandstyle.mtsread 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.mtsholdsPROP, oneas constobject of every property v4 accepts — node, edge, core, compound and transition — withStylePropderived from it. The list is taken from the engine itself (theapplyPropandresolveCorePropsswitches, 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.mtsandrender/mapper-runtime.mtsbecomesPROP.<NAME>:case PROP.BACKGROUND_COLOR:,[PROP.LINE_COLOR]:as an object key,PROP.OPACITYin 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 throughSHAPE_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.mjsscanssrc/with the comment- and string-aware walkimport-graph.mjsuses 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 outsidestyle-props.mts. Each rule runs its control on a planted line, perdocs/agents/testing.md.- It also pins the tables to the engine: every
COLUMN_SPECSid is aCOLvalue and vice versa;PROPhas no duplicate values; everyPROPvalue 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 (COL ≡
COLUMN_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.mjsderives v4's style surface by regex overNODE_READ/EDGE_READand the core switch — and found nothing once every member readPROP.X, so every feature row disagreed with an empty surface and the status-site plan failed behind it (15 of the 17). It now resolvesPROP.Xagainst the table's own text, still without importing the library; the literal form is still accepted. 171 names, as before (the two-relative-tonames are throw-only and were never in a read set).scripts/throw-coverage.mjskeys its allowlists byfile: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 — theSHAPE_MASKinvariant ingraph-store.mtsby twelve lines, the export-scale guard inrenderer.mtsby 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 verifyafter 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()× 6kstyle()× 5kbefore 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
tsximport (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.jsminified 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.Xmember accesses, which the minifier mangles the object name of but not the property name —PROP.BACKGROUND_COLORships 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 inlinesas constmembers) 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 listshows 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.