Skip to content

Fix performance issues at most levels - #1

Open
foxnne wants to merge 85 commits into
mainfrom
push-qtpmrmwrorlv
Open

Fix performance issues at most levels#1
foxnne wants to merge 85 commits into
mainfrom
push-qtpmrmwrorlv

Conversation

@foxnne

@foxnne foxnne commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

No description provided.

foxnne added 30 commits August 23, 2026 14:38
Add `--pan` to the `--world` sweep: at every zoom, 120 parked frames then 120
with the camera moving 13 screen px a frame, reporting mean/p50/p95/max frame ms
and how many of those frames rebuilt the lift. The parked sweep could not see the
pan cost by construction — parked is 0 lift rebuilds and ~0.4 ms, panning is
119/120 rebuilds and 3-4 ms.

Split `liftLinks` into focus/scan/build/sort/fade counters (no-op unless the
bench sets `prof_io`), and print the world step/sync/lift and misc buckets in the
debug HUD, which were timed every frame and never shown.
Three mechanical changes to `liftLinks`, all of which leave the lifted set
bit-identical — the sweep's marks and links columns are unchanged at every zoom.

- The five hash maps and two lists it used were `.empty` on entry and freed on
  exit, so a pan frame (119 of 120 miss the lift cache) grew a 17k-29k entry table
  from nothing and rehashed all the way up, sixty times a second. They are `World`
  fields now, cleared rather than freed.
- The per-cut-cell neighbour accumulation is a scatter-add over cell ids, so it is
  a dense epoch-stamped array instead of a hash map — the same trick `cut_of` uses
  to avoid a per-frame memset of one entry per cell.
- The budget truncation wants the *set* of the heaviest `keep` links, and a full
  sort also ordered the ~20,000 it was about to throw away: 2.0 ms a frame at 300k
  notes, the largest single item in a moving frame. `selectTopK` is an introselect
  to the same set. `heavier` is a strict total order, so that set is unique.

300k notes, budget 4000, camera panning, at the zoom where every note has just
exploded: 4.40 -> 2.27 ms mean, p95 8.37 -> 4.80, max 10.55 -> 6.19.
`updateLabels` ended with a pass over every node in the vault to step `label_vis`,
and allocated and memset one bool per note to drive it; `drawLabels` then walked
the vault again to find the names to draw. Both ran on every frame of a pan.
`GraphNode` is 160 bytes, so this is the same whole-vault stride
`syncNodesFromWorld` was already rewritten to refuse — about 1 ms a pass at 286k
notes, twice a frame, to move at most 96 non-zero values.

Nothing but `updateLabels` ever raises a `label_vis`, so the set of non-zero ones
is exactly (placed this frame) + (was showing last frame), both bounded by
`max_labels`. `Panel.label_live` tracks it per cloud, `GraphNode.label_epoch`
separates the two cases without a per-node array, and `rewarmLabels` re-seeds it
after a rebuild renumbers the nodes, exactly as `rewarmPointer` does.

Same names in the same places: the only behavioural difference is that a fade
snaps to zero at the 0.004 settle threshold instead of decaying below it forever,
which is four times under what `drawLabel` draws at.
…hash map

`fadeLinks` built a whole `live` set each frame — one insert per lifted link, so
~22,000 of them at the top of the quality slider — purely to decide which entries
of `link_fade` were no longer present. `link_fade` can answer that itself: its
value carries the epoch of the frame that last touched it. Same crossfades, same
links; the append loop also reserves once instead of testing capacity 22,000 times.

300k notes, budget 4000, at the zoom where every note has just exploded: parked
0.74 -> 0.37 ms/frame, panning 2.27 -> 1.98 mean and p95 4.80 -> 4.26.

The pan probe now also breaks out `step` from `liftLinks`, which is how we know
the remaining spikes are in the lift and not in lazy cell placement (step is
0.13 ms of a 1.98 ms moving frame).
… vault

Three whole-vault walks in the draw path, all per frame:

- `worldMarkHoldsOpen` scanned a cell's entire note range for an open document,
  once per mark. The marks partition what is on screen, so at overview the sum of
  those ranges *is* the vault — 300,000 reads at a 160-byte stride every frame to
  find the two or three notes that are open. Asked from the other end it is the
  union of the open notes' ancestor chains: a handful of tabs times seven levels,
  built once in `updateHoldsOpen` and read as a set. Identical answers.
- `world_draw` and `updateLabels` each built their own
  `AutoHashMapUnmanaged(cell, point)` from the same mark list, to serve two
  lookups per link endpoint — 40,000 hash lookups a frame between them at the top
  of the quality slider. `World.present` now publishes cell -> mark index in a
  dense stamped array (`World.markIndex`) and both read that. `markWorldPos`, which
  was a linear scan of the marks, reads it too.

The sweep's marks and links columns are unchanged at every zoom.

Also report cut churn per pan frame, which is the number that decides whether an
incremental lift is worth building: 10.8% at the worst zoom, 0.0% parked.
Placement is lazy, so a fast pan at the coalesce boundary opens thousands of cells
in a single frame — and each one ran an exhaustive walk over every arrangement of
its ring children, evaluating the full cost at each of up to (arity-1)! = 720
leaves. That was a 5 ms hitch inside `World.present`, right where the reader is
already asking the most of the frame.

The walk is the same walk, with branches that cannot win cut. The bound is the
part of the cost the assignments so far already fix, plus the terms no arrangement
can change: every ring slot is the same distance from the centre, so the `mass_k`
pull and any sibling pair with the centre child at one end are constant.

The arrangement is bit-identical to the unpruned walk's, and it has to be: every
surviving complete arrangement is still scored by `arrangementCost` itself, in the
same enumeration order, on the same strict `<`. Pruning decides only which
branches are reached. A tolerance keeps near-ties out of the bound's reach so they
resolve exactly as they did before — without it, arrangements that tie
mathematically but differ in the last float bit resolve the other way, which moves
children between slots and was caught by the `ext_k` test.

`ring_pruning` exists so 'pruning changes no layout' is asserted directly, over
real ladders, by placing each twice and requiring bit-identical positions.

300k, budget 4000, 40 px/frame at the boundary: worst-frame `present` 4.86 -> 3.12
ms, pan p95 5.05 -> 4.53.
Two costs at the coalesce boundary — the zoom where every note has just resolved,
the web is at its densest, and a fast pan hitches.

The label placer's collision test walked *every* link segment for every slot of
every candidate. At the boundary that is ~20,000 segments against a couple of
hundred candidates and their slots, in both the keep_in and bounds passes, plus
the trimmed and relaxed retries — tens of millions of segment/rect tests per
frame, on every frame of a pan, and none of it visible to the headless sweep.
`labels.SegGrid` buckets the segments by the cells they pass through, so a
candidate only tests the handful near it. `segmentHitsRect` still decides; the
index only decides who it is asked about. The walk is per column and widened
outward, because a missed cell is a name sitting on a link — checked by a test
that runs 20,000 random queries against 900 segments through both paths and
requires identical answers. Below 256 segments the linear walk is cheaper and the
index is skipped.

`cut_of`/`cut_stamp`, `side_w`/`side_stamp` and `mark_of`/`mark_stamp` were
parallel arrays that are never read apart, so every lookup was two cache misses
into two megabyte-sized arrays instead of one — and the lift does one per
neighbour, 52,000 times on a hard pan frame. One struct each.

The sweep's marks and links columns are unchanged at every zoom.
Three reasons a click's connections could go missing, all on the path between
'the note is drawn as itself' and 'it has coalesced'.

`lift_hold` bypassed the whole lift fingerprint, focus included. It exists so the
ambient web can lag a few frames while the camera flies — invisible, and it saves
the entire lift on the frames that can least afford it. But clicking a node is
what *starts* the flight, so the frames where the hold engages are exactly the
frames where the reader is waiting to see what they just clicked connect to.
Held, the lift answers with the previous note's links, and releases only once the
camera slows: connections that vanish on the way out and return on arrival. The
fingerprint is now two keys — the cut may lag, the focus and open set never may.

`world_draw` nested the focused note's pass inside `if (w.links.items.len > 0)`,
so a frame whose lift produced no cell-to-cell web took the highlight down with
it. The two sets exist for different reasons; the gate admits either.

`clearFrame` left `mark_epoch` alone, so the cell -> mark index outlived the marks
it points into. Harmless today because every reader runs after `present`, but on a
frame `step` returns early from it is a read past the end of `marks`.

The regression test fails without the first fix. Sweep columns unchanged.

`bench --world` also grows `--focus=N` (sweep with a note focused and centred, and
flag any zoom where the highlight would not be drawn) and `--zoom-mul=F` (finer
than doubling, which can step clean over a narrow band).
… cap

This is why the focused note's connections vanish at one zoom band and come back
if you zoom either way.

`LineBatch.max_lines` is 16,383 — dvui's `Vertex.Index` being a u16, which is
permanent app-wide since the web, raylib and dx11 backends all reject
`-Dvertex-index=u32`. It is a *batching* limit, and `SpriteBatch` has always
treated it as one, flushing and continuing. `LineBatch.add` just returned.

`world_draw` adds the ambient web first and the focused note's own links last. At
the zoom where a large vault has finished exploding, the drawn ambient web alone
is past 16,383 lines — 20,453 at 300k on the bench — so the batch was already full
before a single highlight line was offered to it, and every one was discarded in
silence. Zoom either way and fewer links survive the viewport clip, the count
falls under the cap, and the highlight reappears. The node itself stayed lit
throughout because marks are a separate sprite pass.

Overflowing into another draw call also preserves the ordering the highlight
depends on: later calls paint over earlier ones, so the focused links still sit on
top of the web.

Costs one extra `renderTriangles` per 16,383 lines, at a zoom that was already
drawing that many.

Note: `galaxy` builds its mark `SpriteBatch` without an `auto_tex`, which is the
same silent drop for marks. Not reachable at the 4000 mark ceiling, so left alone.
Atlas: hit where we draw, name what is hovered, calm the highlight

Three bits of polish, and one correction.

The overview draws its marks from the world's own positions, but the proximity
pass shoved `n.pos` — which is what hit-testing and the label placer read. So near
the cursor the ring you can see and the target you can click drifted apart: the
node lights up, the cursor never becomes a hand, and the click lands on nothing.
Worse at some zooms than others, because the shove reach is a world distance
derived from a screen radius. The shove is not drawn on the overview at all, so it
is now skipped there; drawing it instead would pull nodes away from their own link
endpoints, which is a failure this graph has already paid for once. The interior,
which does draw from `n.pos`, keeps it.

The hovered note's name takes the highlight colour. At overview density a swell is
easy to lose among neighbours, and the name is the part that actually says which
one you are on.

The focused note's links drop to half opacity. A note with many links drew each at
near-opaque highlight and the starburst buried the neighbour names it was pointing
at. The ambient web already thins as it thickens; the highlight needed the same.

Correction: `drawLabels`' overview branch returns before the `p.label_live` loop
added earlier, so that loop was unreachable — the live path was already walking
`p.visible.items`, which is bounded by the mark budget. Removed. The `label_live`
set is still what `updateLabels` fades over, which was the real cost.
The sweep could report perfect marks, links and frame times while the picture was
a lattice glob — and did. A curve-order coarsening was judged on link spread, the
stated proxy, and came out worse while changing the look not at all; the look was
the complaint. Four measurements that encode what a reader actually sees:

* link spread — how far a link travels, as a fraction of vault radius. Still the
  honest grade for *grouping*, kept for that.
* sibling overlap — what share of sibling disc pairs intersect, and by how much.
  This is the 'giant glob of overlapping dashed discs'.
* hex order — the six-fold order parameter of a cell's children, locally and
  across the vault. Local says how lattice-like one system is; global says whether
  they all share an orientation, which is what reads as a '+' and as rows.
* radial density — equal-area rings, so a uniform layout is eight equal numbers
  and the shape of the list *is* the gradient.

Plus  now works on the containment path, so a layout can be looked at
without launching the editor. Squinting at a hairball in a window is how the last
two attempts were judged.

Baseline, simplewiki: crossing 50.6%, siblings overlapping 59.1% at 0.68x
penetration, hex order local 1.000 / global 0.700, radial density
72.9 21.8 4.3 0.1 0.0 0.0 0.7 0.3. Every one of those confirms something the
reader described, and the last one explains the blank space: 95% of the vault sits
in the inner quarter of the area while a handful of two-note components out at the
rim set the extent everything is framed against.
…osts

`--fill`, `--radius-exp` and `--pack-gap` reach `containment.Options` from the
sweep, so the layout's three knobs can be swept against the layout report rather
than guessed at. `Cell.height` — levels of ladder beneath a cell — falls out of
`assignRanges`, which was already walking the tree; it replaces the second
`maxDepth` traversal per root.

What the sweep says, on synth:100000:scale-free:4 at budget 4000:

  radius_exp  sibling overlap   peak marks / notes
  0.50 (now)          60.6%         3712 / 3640
  0.58                14.0%          949 /   -

  fill        radial density (equal-area rings)      peak notes
  0.90 (now)  31.4 30.5 25.9 11.6  0.5  0  0  0        3640
  0.98        23.3 21.4 24.2 18.4 10.5  2.3 0  0        2837

Both knobs do exactly what they promise and both bill it to the exploded view.
`radius_exp = 0.58` clears the overlap and costs **four times** the on-screen note
count; `fill = 0.98` fills the frame and costs 22%. Neither is worth taking.

There is a proof underneath that: seven equal circles fit inside a circle only at
r ≤ R/3, while area conservation demands r = R/√7 = 0.378R. Non-overlap and area
conservation are *incompatible* for a rigid arity-7 ring, so no setting of these
knobs escapes the trade — only unequal, solved-for positions can, since a cell's
children have wildly unequal counts and the ring geometry ignores that entirely.

Also tried and reverted: packing and framing islands by `fill^height * radius`
instead of the declared radius, to close the annulus between a vault's content and
the disc it claims. That expression is a *lower* bound on reach, not the reach —
52% of notes fell outside it, so `extent` cropped the view. A true bound needs a
bottom-up recurrence over the ring geometry, which would be a second copy of
`ensureChildren`'s arithmetic and is better solved by the arrangement itself.
`ensureChildren` placed children on a jig: one centre slot and `arity - 1` ring
slots at identical radius and identical angular step, rotated by level, with a
permutation search choosing who sat where. Everything wrong with the macro picture
followed from that. Every cell came out a mathematically perfect hexagon — the
six-fold order parameter measured 1.000 — and since the rotation was a function of
level, every cell at a level shared one orientation, so the hexagons lined up
across the whole vault into rows and a '+'. The jig could not use mass either: all
its slots are equidistant from the centre, so `mass_k` was provably unable to
affect the outcome, and at the zoom where notes are drawn individually every body
had `count` 1 and therefore identical mass. There were no stars.

Now the children settle, in the spirit of boids: separation (the packing
constraint, done softly, per-body radius, applied last and it wins), cohesion
(only for bodies with room — run both on everything and they fight to a stalemate,
and the stalemate *is* overlap), sibling-link attraction, `ext` steering, and a
seeded wander that dissolves the lattice. The primary stays pinned at the
barycentre: it is the star, and it guarantees the middle of a cell is occupied —
without it, diving into a mass lands the camera on empty space.

`Cell.weight` gives every cell the link mass beneath it, and a leaf's separation
radius grows with it — logarithmically, capped. A hub sits in a clearing and a stub
sits in a crowd, and the room a note reserves is the room its interior cloud will
want when the reader dives in.

simplewiki, before -> after:

  hex order global      0.700 -> 0.013     (the lattice is gone)
  hex order local       1.000 -> 0.533
  radial density   72.9 21.8 4.3 1.2 ...  ->  60.2 26.6 11.0 1.2 ...
  link spread crossing  50.6% -> 56.7%     (worse)
  sibling overlap       59.1% -> 64.3%     (worse)
  peak notes on screen  3640  -> 3363      (synth, -8%)

The two regressions are the honest cost and they have the same cause: a hexagon is
the *optimal* packing of six equal circles around one, so breaking the lattice
costs packing quality. Worth it at 0.700 -> 0.013, and far cheaper than the other
routes — `radius_exp = 0.58` bought less and cost four times the note count.

`leaf_pitch` is recalibrated from 1.381 to 0.71. It used to be read off the ring
construction; there is no ring, so it is now measured, and its test is the
definition.
A coalescing cell slid its children into the parent's centre and then they vanished
on one frame. The animation was never missing — it was being discarded at the last
step.

`present` does the work already: it chases `anim` toward the topology decision,
lerps a closing cell's children in toward their parent's centre by that same
`anim`, and hands each child an alpha of `mul * (1 - anim)` that falls to zero
exactly as it arrives. Both the position and the opacity are driven by the one
value, so they land together by construction.

None of it reached the screen. `MarkStyle` carries colours, `StyledMark` had
nowhere to put a per-mark opacity, and `world_draw` copied the style through
untouched — so every mark was painted at full strength until `present` stopped
emitting it below 0.02. That is the pop. The parent mass fading *in* underneath was
lost the same way, which is the other half of the crossfade and why a merge read as
a disappearance rather than as a handover.

`Mark.alpha` now multiplies the fill and border on the way into the batch.
`Color.opacity` multiplies rather than replaces, so this composes with the global
interior/overview fade `galaxy.drawStyledMarks` already applies.

Not fixed, but noted: `StyledMark.dying` is a binary 0.35 opacity multiplier that
nothing sets — it looks like an earlier, coarser attempt at this and is now dead.
One number over all links buries the only part that is decidable. A hub link cannot
be short and no layout can make it short: a note holds one position, so at arity 7
at most six of a 29,448-degree article's neighbours can be its siblings and the
other 29,442 are elsewhere by arithmetic. Reporting 56.7% 'crossing' therefore says
almost nothing about whether the placement is working.

Bucketed by the busier endpoint, simplewiki:

    deg    0-8    0.7% of links   mean 0.094r   crossing 17.9%
    deg   9-32    9.2%            mean 0.198r   crossing 37.0%
    deg  33-128  22.7%            mean 0.304r   crossing 56.6%
    deg 129-512  24.7%            mean 0.331r   crossing 62.9%
    deg   513+   42.6%            mean 0.305r   crossing 58.1%

The local web — the part a layout can actually place — is short. Two thirds of all
links have an endpoint above degree 128, and those are what the aggregate was
measuring.
Two thirds of Simple English Wikipedia's edges have an endpoint above degree 128 —
links to *France*, *country*, *year*. They say nothing about the note carrying
them, because nearly every note carries them, and weighted equally with everything
else they were deciding which notes coarsen together, which lines the ambient web
spends its budget on, and which neighbours a focused note shows.

`fold.degreeNormalised` applies Salton's cosine normalisation — `w · K/√(dₐ·d_b)`,
with K the mean degree so a typical link stays at 1.0 and every constant calibrated
against 'a real link' keeps its meaning. `World.init` computes it once and hands the
same array to `fold.build` and `cellweb.build`, so the hierarchy and the drawn web
cannot disagree about which links matter. `Cell.weight` deliberately stays raw
degree: normalisation exists to stop a hub dominating the grouping, mass exists to
make a hub look like the star it is, and the two must not move together.

simplewiki, by the busier endpoint's degree — crossing %, before -> after:

    deg    0-8   ( 0.7% of links)   17.9 -> 12.9
    deg   9-32   ( 9.2%)            37.0 -> 29.7
    deg  33-128  (22.7%)            56.6 -> 46.6
    deg 129-512  (24.7%)            62.9 -> 54.0
    deg   513+   (42.6%)            58.1 -> 59.2
    overall                         56.7 -> 52.0

Every decidable bucket improved; the local web tightened by about a third in mean
distance. The hub bucket did not move, which is the point — it cannot.

Two defects surfaced on the way, both real beyond this change:

`ext` aim was pointing children at cells that had not been placed yet. An unplaced
cell's position is {0, 0}, a perfectly valid coordinate — the root's centre — so
this was not detectably wrong, just wrong, and placement is lazy and depth-first so
it hit roughly half of a cell's out-of-cell neighbours.

And `ext` cannot be a force in the relaxation. It is one pull among several, and on
a cell whose children are chained to each other the chain wins: aim came out
*worse* than seeding at random. It is now a rigid rotation of the settled cell,
which changes no distance between any two bodies — so it cannot disturb packing,
containment or the sibling links — applied in closed form, since maximising
`Σ wᵢ·cos(aᵢ + θ − tᵢ)` is minus the argument of `Σ wᵢ·e^{i(aᵢ − tᵢ)}`. It also
replaces the per-cell hash as what decides a cell's orientation, so a cell now
faces something real. On the pinned test the residual misalignment goes to exactly
zero.

Cost: `World.init` 890 -> 1033 ms on simplewiki, which is over the 1 s target. The
normalisation itself is a 40 MB edge copy per rebuild; passing a per-note scale
factor instead of a rewritten edge list would remove most of it.
Two channels of hover, not one. `hover_t` is a proximity *field* — it falls off
with distance and lifts everything near the pointer, which is what makes a dense
region feel responsive — and `pointer_t` is exactly one node. Folding them together
means either the whole neighbourhood opens up, which is unreadable, or the node you
are actually on does not stand out at all. `bubbleScreenRadius` now takes both, so
the node under the cursor opens further than the field around it.

Everything downstream reads that radius, so the rest follows without being asked
for: `hitTestNodes` grows the click target with the ring, and `updateLabels`
re-places the name against the expanded disc — a hovered node's label steps clear
instead of sitting on it. `labels_dirty` already tracks `pointer_settled`, so the
placer re-runs while the pop eases.

The fill and the ring both take the highlight colour. A `fill` -> `fill_hover` step
is right for a button in a row of buttons, where position already says which one
you are on; in a field of thousands of near-identical discs it is a few percent of
luminance against neighbours the proximity swell has already made every shade.
Highlight is the colour this panel already uses to mean 'this is the one' — the
focused note's links, the focused note's name — so hover joins that vocabulary.

Not done yet: sizing the expansion by what is inside the note. See the next message
— `interior.radius` is currently the same for every note by construction.
A two-line stub barely moves; a note with forty paragraphs opens a visible
clearing. The ring answers 'what am I about to open' before the reader commits.

`SnapNode.interior` is headings + body blocks, filled by two grouped passes over
indexes that already exist (`headings_note`, `blocks_note`) — deliberately not a
correlated subquery per row, which is the shape that once made degree the largest
single cost of publishing a snapshot. It reaches `GraphNode.interior_items` and is
read by `pointerGrow` and nowhere else. Presentation only: the layout, the ladder
and the LOD thresholds must not move with content, or every edit to a note reshapes
the vault around it. An earlier attempt fed this into layout mass and the push
radius, and that is what cost the frame rate.

Logarithmic to a reference of 64 items, because a real vault runs from 0 to several
hundred and a linear law leaves every ordinary note indistinguishable at the bottom
of the range.

Both size ceilings now yield to the expansion — `max_node_screen_r` and the
lattice-gap cap. Both exist to stop a node swallowing its neighbours at coarse
zoom, and at overview density the gap cap is what binds, so applied unchanged they
clipped the whole expansion away at exactly the zooms where standing out of the
crowd is the point, and every note opened by the same clipped amount whatever was
in it. Lifting one node clear of the crowd is the intent, so the caps yield to that
and to nothing else.

It is an honest indicator of size, not a literal preview of the interior:
`buildInteriorWorld` picks `scale = berth / extent` so every cloud fills one
footprint, which is what keeps entry consistent — so there is no per-note interior
bound to match, and creating one would make a two-item note and a two-hundred-item
note settle at different zooms.

Cost: publish 0.38s -> 0.54s on a 283,888-note vault, steady state over three runs
each. Once per index publish, not per rebuild and not per frame. Synth vaults have
no headings or blocks, so they report 0 and take the minimum expansion.
Three faults from looking at the last change on a real vault.

**The expansion was uniformly enormous and said nothing.** It was folded into
`bubbleScreenRadius` ahead of that function's two ceilings — `max_node_screen_r`
and the lattice-gap cap — and both were then relaxed by the same factor to stop
them clipping it. At overview density the gap cap is what binds, so every note
inflated to roughly the same fraction of the lattice spacing whatever was in it.
`hoverHaloRadius` now multiplies the *finished* radius instead, so 2x means twice
the disc you can see, and the growth law is √items rather than log: a log law
compresses the top of the range so hard that a ten-item note and a two-hundred-item
one open by nearly the same amount, which is the distinction this exists to draw.
Tuned so an ordinary note lands at 2x and a long one runs to 6x.

**Full highlight made the label unreadable.** The fill went all the way to the
highlight colour, which is also what the label uses, so the name sat on a disc of
its own colour. The fill now mixes 45% of the way — the node lifts out of the field
while staying a node rather than a solid chip of accent.

**The hovered node was painted over by its neighbours.** Marks are emitted in
whatever order `present` walked the tree, so in exactly the dense regions where the
reader most needs to know what they are pointing at, later marks buried it.
`MarkStyle.on_top` holds one mark back and `world_draw` emits it last, together
with a dashed halo underneath it: `StyledMark.halo` is the mass's dashed rim over a
*fully opaque* face, where a real mass keeps 0.28 so the web reads through it. The
halo takes the node's resting fill, not its lit one, so it reads as one node
expanding into a container rather than as a second brighter object. Nothing moves —
this is drawing, not layout.

And the hovered note's name is now drawn unconditionally by `drawHoverLabel`,
outside the halo, in the ordinary text colour. Whether the reader can read the name
of the thing under their cursor must not depend on how crowded that part of the
vault is; at any zoom where the placer is suppressing names it is the only way to
tell what a click would open. `drawLabel`'s `hot` parameter goes with it.
Three rings were on screen at once: an enormous outer band, a smaller ring that
grew faster and was the same size for every note, and the note's own rim.

The enormous one was compounding. `hoverHaloRadius` multiplied the *live*
`bubbleScreenRadius`, which already carries the proximity swell and
`zoom_rest_swell` — so a 6x clearing came out at about fourteen times the
neighbouring dots. It now anchors on the note's resting radius with both hover
channels cleared, so the multiple means what it says: 6x is six times the dot every
other note is drawn at. The same-size-for-every-note ring was the proximity swell on
the disc underneath, and it is simply covered now.

The clearing is drawn *over* the node rather than under it. An opaque face in the
node's lit colour, so the effect is one dot expanding into a dashed container
instead of a ring with the old dot stranded inside it — and the reader still sees
the hover colour, which taking the resting fill would have hidden.

Exactly one of these exists per frame, so it is drawn the expensive way and looks
it: `fillCircle` plus `strokeCircleDashed`, both vector, no atlas sprite. The
standing rejection of path-stroked dashed rings is about *thousands* of masses —
4,000 of them measured at ~8 fps — and says nothing about one. The soft-atlas
sprite's feathered edge is obvious when there is a single ring on screen to look at,
and its glow pass was itself the second band.

Installed with `zig build install -Doptimize=ReleaseFast`.
The enormous delayed circle was not the hover ring at all. `drawMassInfluenceRing`
drew a second one: after 400 ms on the same mark, a dashed ring eased in at
`separationRadius(cell) * camera.zoom` — a *world*-space push radius scaled to
screen, which is why it dwarfed everything and why it arrived late. Removed
outright, along with `stepMassRing`, `resolveMassRingTarget`, `hoverDwellKey` and
the five `hover_dwell_`/`hover_mass_` fields behind it. One ring, no dwell.

The other half of 'sometimes there is no expanding ring': `pointer_t` decays over
several frames after the cursor moves off, so for a moment two notes have a
non-zero value — the one arriving and the one leaving — while `world_draw` keeps a
single slot for the ring, won by whichever mark comes later in the list. So the ring
could be drawn around a note the cursor was nowhere near while the one actually
hovered had none. `Panel.halo_node` names the owner: the arriving note claims it
immediately, and the leaving one keeps its ease only until something else does.

Installed with `zig build install -Doptimize=ReleaseFast`.
Two reports, one cause. The ring was anchored on the note's *resting* radius while
the node underneath it is drawn carrying the proximity swell — up to `grow_factor`
on top of `zoom_rest_swell`. So for a note with little in it the ring came out
*inside* its own swollen disc, and the hover read as an ordinary node with a faint
outline rather than as anything dashed.

It now measures against the largest a *normal* note is ever drawn at this zoom —
`bubbleScreenRadius` with the hover channel full and the pointer channel empty — so
the floor means something: twice the biggest dot on screen is always bigger than
this dot, whatever this dot happens to be doing. And the ring grows *from* that
maximum rather than from nothing, so it covers the node on the first frame of the
hover and expansion is the only thing the reader sees.

`ringMultiplier` replaces `pointerGrow` and returns the multiple directly rather
than an increment: 2x floor for an empty note, +0.32 per √item above it, 7x ceiling.
An ordinary ten-item note lands at 3x, forty items at 4x, and the ceiling arrives
near 250 items.

Installed with `zig build install -Doptimize=ReleaseFast`.
**Slower, and only when you mean it.** The ring rode `pointer_t`, which is tuned
for colour — `pointer_chase_k` of 22 settles in about a sixth of a second, which is
right for a few pixels of fill and violent for a ring several times the size of
everything around it. Worse, skimming across a field changed the hovered node every
few frames and every one of them started an expansion, so the cursor dragged a
stutter of half-drawn rings behind it.

The ring now has its own channel: `Panel.halo_t`, chased at `hover_ring_chase_k` of
7, gated behind a `hover_ring_dwell_s` wait of 160 ms that any change of target
resets. A skim opens nothing; a deliberate pause opens one thing, smoothly. Colour
still answers instantly on `pointer_t` — the reader needs to know what a click would
take before they finish moving, and that part was never the problem.

Eased with `outCubic` rather than `outBack`. The overshoot that gives a small button
a pleasing pop is, at this size, a lurch past the target and back.

**Highlight is now consistent.** An open document's name takes the highlight colour
like its disc, and so does the hovered note's. The panel has one colour for 'this
matters to you right now' and a note you have open belongs in it — leaving its name
the same grey as the thousand around it makes the reader hunt for a tab they already
have open. The hover label was briefly the ordinary text colour because the hovered
disc used to go *fully* to the highlight and the name sat on top of it; the disc
only mixes part way now and the ring puts the name outside it regardless, so that
reason is gone.

Installed with `zig build install -Doptimize=ReleaseFast`.
…over flag

Third attempt, and the first that does not fight the panel it lives in.

The other two keyed the ring on *which node is hovered* — first `pointer_t`, then a
dwell timer — and both inherited the same flaw from that choice: a binary state
drawn at a size the hit test does not share. The ring is several times the disc and
the click target is the disc, so moving the cursor inside the ring but off the disc
dropped the hover and collapsed it. Parts of the thing you can see do not respond.
The dwell made a skim quieter and made that worse, because the ring then took time
to come back.

`hover_t` has no edge to fall off. It is a field — `applyProximity` computes it from
screen distance with a smooth falloff — so as the cursor travels between two
overlapping notes one ring grows while the other shrinks, continuously, with no
moment of handover to get wrong and nothing to wait for. Every note can draw one and
the nearest is simply the largest, because the field peaks under the cursor: the
single-largest guarantee falls out of the geometry instead of being bookkeeping.
Cubed, because raw `hover_t` falls off gently enough that a whole neighbourhood
would open to a similar size.

The ring also grows from the disc *as currently drawn*, which already carries the
swell, so the two never separate at any value of t.

`world_draw` carries up to 24 rings (only notes inside the falloff have one) and
`galaxy` sorts them smallest-first so the nearest ends up on top rather than under
whichever neighbour the mark list happened to emit later.

Removed with the dwell: `stepHoverRing`, `halo_t`, `halo_node`, `halo_dwell_s`,
`halo_dwell_key`, `hover_ring_dwell_s`, `hover_ring_chase_k`.

Installed with `zig build install -Doptimize=ReleaseFast`.
There is no separate ring any more. A note that is hovered or open draws *as* a
dashed rim over an opaque face; everything else draws as it always did.

Both previous shapes failed for reasons the last screenshot shows plainly. Keyed on
the discrete hover, the ring had a hard edge the hit test did not share, so parts of
a thing the reader could plainly see did nothing. Keyed on the proximity field it had
no edge — but every neighbour then drew a faint ring of its own, and mid-zoom that is
a nest of concentric circles with the real node showing through the middle of them.
The illusion only ever held while exactly one ring was on screen, and neither design
could promise that.

Dashed is now a *state* rather than a size: the note under the cursor, and the notes
the reader has open. Nothing else, ever. It always takes the highlight rim, which is
what makes dashed mean "this one" rather than "this one is drawn differently".

The expansion moved inside `bubbleScreenRadius`, applied after its caps. After,
because folded in before them the lattice-gap cap binds at overview density and
every note flattens to the same fraction of the spacing whatever it contains.
*Inside*, because `hitTestNodes` and `updateLabels` both call it — so the thing you
see, the thing you click and the space the name is placed around are one circle
again, which is the actual fix for parts of the mark not responding.

`galaxy` holds every dashed mark back past the sprite pass and sorts by size, so the
largest lands on top; `world_draw` needs no deferral slot, no ring buffer and no
ordering of its own. Gone: `MarkStyle.on_top`, `halo_r_px`, `halo_fill`,
`StyledMark.halo`, `hoverRingRadius`, and the 24-ring scratch.

Installed with `zig build install -Doptimize=ReleaseFast`.
A typical note was doubling. The floor alone was 2x, so every note the cursor
touched claimed a large piece of the screen and the size stopped meaning anything —
a difference every note shares is not a difference.

The floor is now 1.0: a note with nothing in it gets no extra size at all, only the
ordinary proximity swell it already had. Growth here is *information*, so a note
with nothing to say should not claim any of it. The dashed rim already announces
which note the cursor is on, which frees size to mean exactly one thing.

Shallower above the floor too — 0.05 per root-item against 0.32:

     0 items -> 1.00x        100 -> 1.50x
     5       -> 1.11x        200 -> 1.71x
    10       -> 1.16x        500 -> 2.12x
    50       -> 1.35x       1600 -> 3.00x

so the ceiling belongs to documents of genuinely unusual length rather than
arriving at forty items. Still a square root and not a logarithm: a log law
compresses the top so hard that a ten-item note and a two-hundred-item one open by
nearly the same amount, which is the one distinction this exists to draw.

Installed with `zig build install -Doptimize=ReleaseFast`.
Kept: an open note draws as a dashed highlight-rimmed mark, the note under the
cursor mixes its fill toward the highlight, and every note near the cursor grows on
the proximity field it always did.

Removed: hover no longer makes a note dashed, and nothing grows by document size.

Dashed is the one shape that has to survive a zoom. An open note is a dashed rim
out here and the sun at the centre of its interior is a dashed rim too, so diving in
continues something instead of cutting to something new. Spending it on hover spent
the meaning — every note the cursor passed became dashed, so dashed stopped saying
"this is the one you are in", which is the only thing it was for.

The document-size growth is gone with it, and so is everything that fed it. It was
never noticeable: the interesting range came to a few percent of a disc that is
already small, and it cost a whole mechanism to say it — `ringMultiplier`, three
tuning constants, `GraphNode.interior_items`, `SnapNode.interior` and the two
grouped index passes behind them. Publish is back to 0.36s from 0.54s on a
283,888-note vault.

That also leaves one radius again, and only one reason for it. `bubbleScreenRadius`
is the proximity swell and the zoom swell, capped, as it was — no post-cap multiple,
so there is nothing left that can make the drawn mark disagree with the hit target
or with the space the label placer reserves.

Installed with `zig build install -Doptimize=ReleaseFast`.
The ambient web has done this since `ambientAlpha` landed — keyed on the *drawn*
count, not on zoom, because what decides legibility is lines per screen and the same
zoom is a wash on a hub vault and nearly empty on a sparse one. The focused note's
own links never got the same treatment: they were a flat 0.5 whether there were six
of them or two thousand.

Two thousand at 0.5 is a solid disc of highlight centred on the note. Every line is
individually correct and the picture says nothing, because a starburst that saturates
cannot show which directions carry the most or where the structure is. At low ink the
same two thousand read as a density, and the heavy directions separate from the thin
ones.

`focusAlpha` is the same log interpolation, on its own curve: full at 40 lines,
floored at 3000. Deliberately above the ambient curve at every count — these are the
answer to a question the reader asked by clicking, and must stay the brightest thing
on screen even when there are a lot of them. My first pair of constants crossed under
ambient somewhere past 900 lines, which would have made a note's own links *dimmer*
than the web they sit in.

  lines     focus   ambient
      6     0.550     0.400
    100     0.480     0.400
    900     0.312     0.258
   2000     0.251     0.183
   8000     0.220     0.080

The highlight pass now collects its segments before emitting, the same two-pass shape
the ambient pass already uses, because how thick the web turns out to be is only
knowable after culling.

Installed with `zig build install -Doptimize=ReleaseFast`.
Thinning the focused note's links did not make its name readable, and could not
have. Near a hub the lines do not sit side by side, they overlap: `1 - (1 - a)^n`
reaches 1.000 by about twenty-five of them whatever `a` is, so halving per-line
alpha takes the composite from 1.000 to 0.999. The ink under the label is opaque and
no amount of thinning changes that. The previous change is still right for the
sparse regions, where lines do not stack — it was simply the wrong tool for this.

So the text carries its own contrast. `renderTextOutlined` draws eight offset copies
in the panel's background colour and then the glyphs on top, for the focused note's
name and the hovered note's name — the two that are drawn unconditionally and
therefore have to be legible wherever they land.

An outline rather than a plate, and the distinction is the reason ambient labels
still have neither: a plate reads as a chip and blots out the links and dots it sits
on, while an outline occludes only the pixels immediately around each stroke, so the
web still reads through the gaps in the letterforms. Nine `renderText` calls for at
most two labels a frame.

Installed with `zig build install -Doptimize=ReleaseFast`.
foxnne added 29 commits August 25, 2026 13:08
…ing as holes

**Phantom discs read as holes.** A phantom -- a wikilink with no file -- should
look lighter-weight than a real note, but it was mixed from `control.fill`
toward `window.fill` while real notes were derived from
`lighter(content.fill, control.fill)`. The two shared no base, so brightening
the rest fill so discs would sit off the pane silently widened the gap until a
phantom read as a hole punched in the map. Confirmed against the vault: of the
discs that looked wrong, `Mirandinha` and `Kobuk River` are real files and every
darker neighbour is a phantom. The phantom fill now derives from `noteRestFill`
and is dimmed by one named constant, so the two move together.

**Hover said it with a ring.** Dashed now means *open* and nothing else; hover
-- on a note or a mass -- says it with the fill, repainted last over whatever
buried it. A mass has no `GraphNode`, so `nodeFill`'s proximity lift never
reached it; hovered masses take the same lerp toward highlight that notes do, or
dropping the ring would have made hovering a mass do nothing at all.

The overlay draws the mark as exactly what it already was rather than a full
-radius lit disc: the fill stops half a stroke short of `r` and the stroke is
centred on `r`, so they meet on the stroke's inner edge with no seam. Painting
over the border made the one mark you are looking at the one that stops matching
its neighbours.
… again

**The open note flies away as you zoom into it.** The budget's radius cutoff
moves with the zoom, and nothing held the open note's ancestors above it -- so a
cutoff that crossed one of them closed it. The leaf stopped being its own mark
and `present` slid its pose back down the chain toward the mass centre, which is
a long way. Only that note moved, because only its ancestor sat at the class the
cutoff happened to land on, which is why it read as one node misbehaving rather
than a rule, and why it was so hard to reproduce.

The fix is a topology guarantee, not a drawing one. A cell containing an open
note opens ahead of `wantsSplit` and ahead of the cutoff, and is exempt from
`max_expand`'s deferral. `Cell.ls`/`le` bound a contiguous run of `note_at`, so
the test is two integer compares against the reader's tab strip.

This used to be attempted by pinning the leaf's *pose* to its resting position.
That cannot work: the note is then drawn where its own mark is not, so its links
terminate off the disc and the mass still slides out from under it -- the
artifact that pin was removed for. Deciding it instead means pose, links, label
and camera agree by construction, and `focusNode`'s existing promise that "the
selected note never coalesces" becomes true rather than aspirational. On
simplewiki the focused note now resolves at every zoom in the sweep, 7 notes
where there were 0 at the coalesced end, frame times unchanged at 1.24 ms.

**`settled` never came back.** It starts true, goes false the first time any
cell crossfades, and nothing ever set it back -- so `wantsRepaintFor`'s
`!w.settled` was true for the life of the `World` and Atlas repainted at full
rate with a parked camera and nothing moving. `clearFrame` resets it now, beside
the `bound` reset it already did.

That bug was propping up `max_expand`: a split deferred to avoid the settle
hitch sits at its closed pose, so nothing animates and nothing asks for the
frame that would let it open. It only ever caught up because frames were
arriving anyway. The deferral reports itself now.

All three are pinned by tests that fail on the previous code.
… the cursor

Two things that go wrong at the boundary between the overview and a note's
interior, both from two places encoding the same idea differently.

**The dashed ring snaps size on the way in.** The sun's radius and the overview
node's radius both come from `bubbleScreenRadius`, but from different inputs:
`open_screen_r` against `layout_slot * zoom` on one side, `sun_screen_r` against
`interior.slot * zoom` on the other, each with its own cap and `gap_radius_frac`
clamp. Nothing made them agree, and the overview copy is `omit`ted on the same
frame the sun appears, so whatever they disagreed by was a snap -- the ring
jumping to another size and then growing back to the one it already had. The sun
is described as "the overview ring, grown", so it now starts as exactly that
ring and interpolates to its own size on `interiorYieldT`, which is the curve
that fades the overview out. Size and opacity hand over together.

**Hovering the vault stops growing anything once a note is open.** The overview
is drawn at `1 - yield_t`, which holds at full strength through
`overview_hold_t = 0.45` of the descent and only then yields. `updateBubbles`
handed the pointer to the interior at a bare `interior.t >= 0.5`, where
`yield_t` is 0.09 -- so across the whole band from there to 1.0 the overview was
still on screen, up to 91% opaque, with nothing responding to the cursor. Select
a note, let the camera settle in that band, and the vault went inert until you
zoomed back out.

`interiorYieldT` is now one shared function and both passes read it. During a
descent both layers are drawn, so both answer the cursor -- the handover is a
crossfade, not a switch. The overview pass is bounded by `p.visible`, this
frame's resolved marks, so running it alongside the interior costs the mark
budget rather than the vault; the O(N) walk the old comment warned about was
`decayProximity`, which still only runs over the small side.
…ile moving

**A note flies away as you zoom toward it, even with no tab open.** The earlier
fix only held *open* notes resolved, so it did nothing for a note you are merely
diving into.

`worldParams` scales `split_px` by `motion_bias`, and `motion_bias` rises with
`pan_speed`, which is camera-centre motion. Zooming about a point *always* moves
the centre -- that is how the point under the cursor stays put, see
`Camera.poseZoomAround` -- so diving toward a note that is not dead centre
registers as a pan, in proportion to how far off centre it sat.
`hold_topology` hides that while the flick is fast; the moment the zoom slows
enough to release the hold, topology is re-decided against a `split_px` the
still-decaying bias has left inflated. The note coalesces, its pose slides back
down the chain to its mass centre, and it flies off -- then drifts back as the
bias settles. Which note it happened to was a function of where it sat relative
to the viewport centre, which is why it read as one node misbehaving and was so
hard to reproduce.

Coarsening exists to bound cost while the view moves fast. Zooming in *reduces*
the cells on screen, so there is nothing to bound, and the other coarsening term
was already zoom-out only for the same kind of reason. Panning and zooming out
still coarsen exactly as before.

The world half of the invariant is now pinned: at a fixed `split_px`, resolution
is monotone in zoom, so once a note is drawn as itself it stays that way however
much further you go in. The `graph.zig` half has no test -- that file needs a
live dvui window and is not in the headless set.

**The vault stopped swelling under the cursor whenever the camera moved.** That
gate was justified by the neighbour shove being O(visible squared) and unread at
speed. It no longer applies to the overview: the shove is skipped for it
outright -- see `drawn_from_world` -- because overview marks are drawn from the
world's own positions, so a shove there moves nothing anyone can see. What is
left is one linear pass over `p.visible` chasing `hover_t`, which is the whole of
the swell the reader sees. Gating it only meant the vault went inert exactly
when the cursor is being used to aim.
The HUD said it outright, once it was asked:

    focus: at none   pose 18.72   rest on   par shut/0.00   bias 1.00

Resting position on screen, parent cell shut, and the leaf's presented pose 18.7
slots from where the note actually is. Not coalescing (`bias 1.00`), not the
interior (`t 0.00`), not a reframe (`framing free`) -- the three causes fixed
earlier, all excluded at once.

Rule 1 of `decideTopology` culls a branch whose parent disc is off screen, on the
stated grounds that "children live inside their parent's disc, so this test is
exact". That is exact only for an *expanded* cell carrying its settled bound. An
unexpanded cell's radius is an estimate and the packing is tighter than the
drawing, so a parent's estimated disc can fail to contain a child that is
genuinely on screen.

For the field at large that is a rare, invisible miss -- one mass drawn a level
coarser than it might have been. On an ancestor of an open note it is the whole
bug, and it happens *before* the never-coalesce rule is consulted, which is why
adding that rule did not help: the cell never reaches it. The note is never
visited, so never opened; `present` only descends while `anim > 0`, so it never
reaches the leaf either, and the pose stays at whatever the walk last wrote from
some earlier camera. Neither the leaf nor any ancestor emits a mark, so the note
vanishes while its links -- which fall back to the resting position -- carry on
converging on empty space. Panning showed it in two places because two consumers
were reading two different positions for it.

A cell holding an open note is now admitted to `vis` whether or not its disc
looks on screen, so the chain stays intact and the rule below can do its job.

The deeper fault is that `radius()` is not a true bound on the subtree -- that is
the positions-first work, where a cell's disc is the bounding circle of its
members by construction. This is the targeted repair for the notes where the miss
is not survivable.

Honest about the test: it asserts the invariant -- whenever an open note's
resting position is in view it is drawn as itself, at that position, across a
grid of camera positions -- but it does *not* fail on the previous code. The
synthetic chain corpus does not produce a containment miss, so this is verified
by the HUD reading and by deduction, not by a headless reproduction.

The `debug_hud` constant is left on deliberately, to confirm the fix reads
`at leaf  pose 0.00`. Revert it once that is seen.
`decideTopology` culls a branch whose disc is off screen, on the stated grounds
that "children live inside their parent's disc, so this test is exact". It was
not exact, and had not been for some time.

A parent's bound is fixed when the parent is expanded, from `countRadius` of each
child -- the bare area law, read while the child is still closed. Expanding that
child floors its own bound at `estimatedRadius`, which is deliberately larger for
exclusive, low-degree groups. Nothing told the parent. Measured on real ladders
at 3000 notes:

  chain   worst child reaches 1.162x its parent's radius   502 of 503 cells
  star    worst child reaches 1.267x its parent's radius   502 of 503 cells
  mixed   worst child reaches 1.215x its parent's radius   560 of 561 cells

So essentially every cell held a child sticking out of it, and the cull was
free to discard a branch holding notes that were plainly on screen. Those notes
lose their mark; `present` only descends while `anim > 0`, so it stops before
reaching them and their pose keeps whatever the walk last wrote, from some
earlier camera. The note vanishes, then flies in from that stale position when
the branch is admitted again.

Two changes. A parent's bound is now built from `radius` -- the value every other
reader sees -- rather than the bare area law. And `growAncestors` walks up when a
cell's disc grows, enlarging each ancestor until it contains it. Growth is
monotone, so `wantsSplit` and the cull only ever become more permissive and
nothing here can oscillate.

The existing containment test passed throughout because a 300-node star is too
shallow for the error to accumulate; depth is what exposes it. The replacement
runs a chain and a star at 3000 and fails on the previous code.

Cost on simplewiki: the root disc grows about 15%, marks per zoom are unchanged
within noise, and frame time is flat to slightly better (2.31 -> 1.84 ms at the
mid-zoom peak). I first read this as a large regression by comparing against a
`--focus` sweep, which forces a chain open and therefore reports marks at zooms
where an unfocused camera has none; the zero-mark tail at high zoom is
pre-existing and identical either way.

This is the targeted repair of an invariant that positions-first gets for free:
under `spatial.zig` a cell's bound is `max over children of (distance + child
bound)`, so containment is arithmetic rather than something to be maintained.
`debug_hud` returns to false now that the flying-note bug is confirmed fixed.

The readout it gates stays. It is what settled a question three separate guesses
had failed to: one line naming where the focused note resolves, how far its
presented pose is from its resting position, whether that resting position is in
view at all, its parent cell's open flag, and the motion bias. Those failure
modes are indistinguishable from outside the app and have entirely different
causes -- and the reading

    at none   pose 18.72   rest on   par shut/0.00   bias 1.00

excluded coalescing, the interior, and reframing in one shot, and pointed
straight at a severed cull chain.
Without `--focus` the sweep centred the camera on world (0, 0). At the coarse end
that is fine -- the whole vault is on screen either way. At the fine end it was
measuring nothing: adjacent notes sit a `slot` apart (224 world units on
simplewiki), so by zoom 44 a 900x600 viewport spans about 20x13 units and the
origin is empty space between notes.

Every row past that reported 0 marks and a 0.03 ms frame, which reads as the
renderer being free when in fact nothing was drawn -- and those are exactly the
rows a zoom sweep exists to put under load. Centred on a real note the same run
reports 383 marks at zoom 44 and, more usefully, 7.74 ms at zoom 11 with 33,594
links drawn, which is the actual cost peak.
Fizzy fills the window with `.content.fill` and the graph panel paints no
background of its own, so that is the surface every mark and every web line sits
on. `intoBg` and the phantom fill were mixing toward `.window.fill` instead. In
Fizzy Dark:

  content.fill  rgb(42, 44, 54)   <- the panel
  window.fill   rgb(28, 29, 36)   <- what "the background" meant
  control.fill  rgb(28, 29, 36)

So anything faded all the way out landed fourteen levels *below* the surface it
was sitting on: not absent, a hole punched in the map. Same shape as the
phantom-disc complaint, and it applied to every fade — dying marks, the overview
yielding to an interior, the whole web. `galaxy.panelFill` is now the single
answer to "what is behind this", and the three call sites read it.

The second defect was hiding behind the first. `noteRestFill` returned the
lighter of `.content.fill` and `.control.fill`, meaning to sit a disc off
whichever surface the panel inherited. The lighter of those two *is*
`.content.fill` — the panel colour itself. Every resting note was filled with
exactly the background it stood on, which is why notes have been reading as bare
outlines with nothing inside them. It is now derived from the panel colour by a
fixed lift, so the relationship holds in any theme rather than depending on which
way two palette entries happen to be ordered.

Label plates keep `.window.fill` deliberately: a darker chip behind text is what
they are for.

Also here, because it lives in the same function: a hovered *mass* was losing its
dashed rim. `drawOverlay` chose its stroke from `m.dashed`, which is only the
open flag, so the overlay painted a solid ring over the dashed one every
coalesced mark already carries — pointing at a mass changed what kind of thing it
looked like. It now keys off `dashed_rim`, the rim the mark actually has.
…ne times

"Is the reader inside the interior?" was answered independently in nine places
in `graph.zig`, each as a bare `p.interior.t >= 0.5`. Hover, hit-testing, aim,
clicks, label routing, cluster zoom, camera pinning — all of them, inline.

That is how the proximity bug got in and stayed invisible. There are two
different questions here and one number was serving both: `interior_takeover_t`
is where the interior takes over *input*, while `interiorYieldT` is how far the
overview has given way *visually*. At the takeover point the overview is still
drawn at 91% opacity, so any pass that used it to decide what to paint or what to
respond to went inert with the vault plainly on screen — which is exactly what
`updateBubbles` did.

Behaviour-preserving: every site keeps its surrounding conditions unchanged, only
the comparison is named. The point is that the next time these two need to differ
there is one place to change and a doc comment saying which is which.

Reviewed and left alone: `drawLabels` also switches clouds on the input
threshold, so overview names stop while overview discs are still 91% opaque. That
is the same shape as the proximity bug but not the same call — names are noise
during a dive, and the two branches share a placer that runs over one selection,
so making both draw is a restructure rather than a threshold change.
…ladder

`spatial.build` now produces the ladder `world` and `cellweb` run on. `fold` and
`containment` still supply the positions; what changes is that they no longer
also decide which notes are drawn together.

The reason is containment. A `fold` cell's radius is an estimate until it is
expanded, and the estimate is not a bound — measured at up to 1.27x, with 502 of
503 cells holding a child that stuck out of them. `decideTopology` culls a branch
on the claim that a cell's disc contains its subtree, so that gap threw away
notes that were plainly on screen, which is the whole family of "the note flies
away / vanishes / has two positions" bugs. A spatial cell's bound *is* `max over
children of (distance + child bound)`, computed bottom-up once, so the claim is
arithmetic. `expanded` is uniformly true, `ensureChildren` and `ensurePlaced`
return immediately, `radius` is one array read, and `growAncestors` never runs.

On simplewiki:

  descent travel p90   0.268r -> 0.218r      max  0.341r -> 0.218r
  link spread crossing  58.3% -> 51.7%
  zoom sweep peak       7.74 ms -> 4.54 ms   budget never bound at any zoom
  pan                   mean 0.46 ms, p95 1.51 ms

Travel's max now equals its p90 — the long tail is gone rather than shortened.

Two costs, both expected and both transitional. `World.init` goes 1084 -> 1998 ms
because the vault is now placed eagerly *and* grouped, running two position
systems at once. And sibling disc overlap goes 39.1% -> 80.4%: containment
*placed* siblings to avoid each other, while a spatial cell is the bounding
circle of a run of notes that already overlap — median leaf spacing is 0.95x
note_r where non-overlap needs 2.0. No hierarchy can fix that; the layout can,
and measured with `layout.solve` positions the same number is 35.5% at 2.61x
spacing. Both costs come back when the position source is replaced.
The web's cut crossfade was removed — with opaque `intoBg` it read as a flash, the
whole web walking to pane colour and popping back, rather than a dissolve. The
bookkeeping behind it stayed.

Every frame, for every lifted link, `fadeLinks` did a hash `getOrPut` into
`link_fade`, stamped the entry, then walked the entire map end to end collecting
keys to remove and removed them. Up to 35,000 inserts plus a full iteration and
prune, to produce a value nothing read: every entry was stamped `1` and every
link emitted at `alpha = 1`. It is a straight copy from the lifted set now, and
`Fade`, `fade_epoch`, `link_fade` and `sc_dead` are gone with it.

On simplewiki the zoom sweep's peak frame goes 4.54 -> 3.66 ms and the coalesced
end 1.69 -> 0.70 ms.

Also here: `debug_hud` back on, to localise a report that the graph starts snappy
and degrades to single-digit fps after a few zoom cycles. That is something
accumulating, and it survived the hierarchy rewrite — which rules out the radius
growth this was first blamed on — so the per-phase readout is the way to find it
rather than another guess.
Notes and coalesced masses were answering the pointer with two unrelated curves.
A note grew by `grow_factor` — 1.0, doubling, and up to 1.6 more when the lattice
was crushing it — across a reach that widens by `proximity_falloff_zoom_boost` as
you zoom in. A mass grew by a constant of its own, 0.18, across a flat 100 px.

So at any zoom showing both, notes leapt under the cursor and masses barely
moved, and the field stopped reading as one kind of thing responding to one
gesture. A mass now takes the same reach and the same growth fraction, applied to
its own larger base, which is what keeps it proportionally bigger rather than
merely differently animated.

`coalesce_grow` deliberately stays out of it. That exists because a note is
squeezed below its natural size when the layout gap closes (`coalesceCrush`); a
mass is not squeezed that way, it is soft-capped by `mass_cap_px`.

`debug_hud` back off — the slowdown it was turned on for is no longer
reproducible after the dead link-fade bookkeeping came out.
Groundwork for replacing the position source. `World.init` runs on *every* save,
so a layout that costs seconds cannot live inside it — the link-gravity solve is
6-22 s on a 284k vault against the ~2 s the rest of a rebuild costs.

Three changes, all behaviour-neutral on their own:

`World.initFrom` takes the positions rather than deriving them. `init` keeps its
signature and delegates, so the twenty-odd test call sites are untouched, and the
`fold` + `containment` derivation moves into `placementPositions` — one function
returning nothing but positions, which is the seam the solve slots into.

Per-note `weight` and `body` now come straight from the link array and
`fold_opts.bodies` rather than from a `fold` cell, so the hierarchy no longer
needs a link ladder to exist at all.

`Panel.layout_cache` remembers where each note was put, by note id. A rebuild
seeds from it and skips the solve when `layout_reuse_min` of the vault is
covered; below that the vault has changed enough that patching new notes into an
old arrangement would describe a graph that no longer exists. Notes new to a
build take the average of whichever neighbours already have a place, so a new
note lands next to what it links to instead of at the origin.

Keyed by id, not index: `finishRebuild` reallocates `p.nodes` in id order, so
indices shift whenever a note is added or removed and an index-keyed cache would
hand notes each other's positions. Cleared on vault switch for the same reason —
two vaults' ids collide.

The cold path still derives positions from `fold` + `containment`. Swapping that
for `layout.solve` is the next commit and now a local change.
22.2 s to 0.99 s on simplewiki (284k notes, 3.3M links), with the arrangement
substantially better rather than traded away:

  link spread crossing   58.3% (containment)  ->  1.0%
  link spread mean       0.302r               ->  0.069r
  sibling overlap        80.4% (spatial)      ->  32.0%
  median leaf spacing    0.95x note_r         ->  2.60x

Four findings, each measured before it was acted on.

**Repulsion was 98% of the solve.** A uniform grid is linear only while occupancy
is uniform, and a force layout of a real vault deliberately builds dense cores.
The grid was 265x266 averaging 4 notes a cell — and holding up to 739, with 499
cells over 64. All-pairs inside a cell is quadratic in occupancy, so `sum(b^2)`
came to 49.5M and roughly 250M pair evaluations an iteration: 579 ms of a 592 ms
iteration. Attraction over all 2.7M links was 11 ms of it, so the `@log` everyone
would suspect was 2% of the problem.

A crowded cell is now sampled rather than enumerated — a fixed stride, the force
scaled by what was skipped, so the expected force is unchanged. Work is
`n * 9 * min(occupancy, cap)` however tight the cores get. Sampling turns out to
*improve* the result as well: every reduction of the cap lowered crossing.

**The coarsening barely coarsened.** Heavy-edge matching only pairs a node with
an *unmatched* neighbour. Hubs are claimed immediately, the thousands of notes
pendant to them then find every neighbour taken, and the level stalls: 281k ->
192k -> 143k -> 115k -> 99k, each step worse, until `min_shrink` gave up. The
"coarsest" graph still had 99k nodes and 1.7M edges and every refine on the way
down cost nearly as much as the finest. Leftovers now fold into a neighbour's
existing group, capped at `group_max` so a hub cannot swallow its whole
neighbourhood — that cap is load-bearing, and raising it to 8 took crossing from
1.4% to 12.9%.

**Both passes are threaded.** Repulsion needed no guarding once it was written
per-node: each thread writes `force[i]` for its own disjoint range. Attraction
writes both endpoints, so it accumulates into per-thread buffers and reduces.
Both partitions are fixed, so the answer does not move between runs — `solve`
stays a pure function of the graph, which its own test checks.

**`dedup` sorted 2.7M edges with a comparison sort, once per level.** It is a
sort by a packed 64-bit pair key, which is what `radix.sortByKey` is for. 1.78 s
-> 1.08 s.

Repulsion stays exact below `repel_exact_max`. The cap bounds the quadratic tail
of a crowded grid; on a small graph every cell is crowded relative to a cap of
two, so sampling there is not bounding a tail but discarding most of the force —
two hundred unlinked notes stopped pushing apart and piled up, which
`disconnected nodes do not stack on the origin` catches.
Position reuse on its own is completely static: link two notes that both already
have a place and *nothing* moves, because nothing is recomputed. The new edge is
drawn across whatever distance already separated them, and the arrangement stops
describing the graph. Re-solving instead is correct and costs a full solve on
every save.

So `layout_full.relaxDirty` unpins a part of an existing arrangement and holds
the rest still. Pinned nodes are still in `edges` and in the repulsion grid —
they push and pull, they are just not integrated — so the edit settles against a
frozen vault and everything the reader was not editing keeps the position they
already knew.

The free set is every note whose own links changed since the last build, every
note new to it, and their neighbours. Capped twice, because both are hub guards:
linking one note to *France* must not unpin its 29,448 neighbours and turn a
keystroke into a whole-vault solve wearing a different name.

Also in here, from another agent working the same file: the disc fill
restructure, giving rest / hover / selected three distinct colours.

Known limitation, and it is not small: an incrementally settled vault is *not*
the vault a fresh open produces. Cached positions came from a solve over the
pre-edit graph, only the free set has moved since, and a fresh open re-derives
everything from the post-edit graph. Both paths are individually deterministic
and they do not agree with each other, so the map you close is not the map you
reopen. Persisting positions is what fixes that — a fresh open then loads rather
than re-derives — which makes persistence a correctness requirement rather than
the performance optimisation it looked like.
The position source is the link-gravity solve now, and the cache in front of it
is gone.

The cache existed because a rebuild runs on every save and a solve cost six to
twenty-two seconds. It costs 1.05 s, so the reason is gone — and reuse carried a
defect no tuning fixes: an incrementally settled vault is not the vault a fresh
open produces, because the cached positions came from a solve over the *pre-edit*
graph and only a capped free set had moved since. Both paths were individually
deterministic and they did not agree, so the map you closed was not the map you
reopened. Solving from the graph every time makes the arrangement a pure function
of the vault again, which is what a map has to be.

Out with it: `Panel.layout_cache`, the job's seed, `relaxEdit`, `relaxDirty`, and
the four constants that tuned them.

End to end on simplewiki, against the `fold` + `containment` placement:

  link spread crossing   58.3%  ->  0.8%
  link spread mean       0.302r ->  0.054r
  descent travel mean    0.231r ->  0.066r      p90  0.268r -> 0.102r
  sibling overlap        39.1%  ->  31.9%
  World.init             2043ms -> 1795ms       (1050 ms of it the solve)

`leaf_pitch` is now `layout.default_spacing` rather than a number pasted back
from a measurement of `containment`'s relaxation. Everything downstream is
calibrated in those units — camera fit, zoom thresholds, `interiorWant`, label
placement — and the layout normalises its own output to that spacing, so the two
cannot drift. Its test asserts they agree and that the pitch clears 2.0, below
which discs of radius `note_r` overlap by construction.

The bench builds through the same two stages the panel does. Measuring
`World.init` instead would have graded a path nothing takes.

Left standing: `World.init` still derives positions from `fold` + `containment`
for the twenty-odd `world` tests, which want *some* arrangement and do not care
where it came from. That keeps a whole placement pipeline alive for tests alone,
and is worth removing once those tests can be handed positions directly.
Unlinked notes were strung into a single path, sorted by filename. Two things
wrong with that, and the vault it was measured on shows both.

A path is the worst shape a force layout can be given: it settles into a thread,
not a blob. 2,817 orphans came out at radius 1,269 — a twelfth the density of the
real vault — and packed outside the giant component, which made those 1% of notes
set the whole extent. They are a star now, per directory where directories exist,
and the drawer is radius 644.

The order was also invented. `hasFolders` exists because lexicographic order in a
flat vault is the alphabet, not folder structure, and `fold.build` checks it
before chaining — `layout.solve` did not. On a flat 284k vault that wired
`Mitsuhiro Toda` to `Mitsuhiro Kawamoto` and called it a relationship. A star
claims only "these are unplaced", which is the one true thing about them.

New metrics, because the question "is nearby the same as related" had no number:

  co-citation    pairs sharing >= 3 non-hub neighbours and no direct link, how far
                 apart they sit against random pairs as the null
  giant radius   percentiles within the largest component
  giant density  equal-area rings inside it, so the drawer and the packing cannot
                 flatter the profile

Honest about the result: the drawer fix moved neither number it was expected to.
Radial density is 97.6% -> 97.7% in the innermost ring, co-citation is 1.69x
either way. The compression is not the drawer, and the metrics above are what
proved it — the giant component alone is 97.1% in its own innermost ring, with
p50 radius 479, p90 874, and a bounding radius of 3,646. Ninety percent of the
vault lives in six percent of its area.
"Connections shift and pop and disappear and reappear a lot while moving."

Not what it looked like. Links *do* draw with an endpoint off screen —
`world_draw.endpoint` falls back to `field.pos` for any cell without a mark and
clips the segment to the viewport rather than culling by endpoint. That path is
deliberate and already carries a comment about being a past bug.

The real cause is membership. The drawn web is a budgeted top-K over the cut, so
any camera move replaces part of it. A new probe measures how much of the *drawn
set* turns over per frame, which is the number that decides whether it is visible:

  parked      cut churn 0.0%   web churn 0.0%
  close zoom  cut churn 5.0%   web churn 11.2%

Eight to eleven percent of ten thousand lines is about a thousand appearing or
vanishing between frames. `LiftedLink.alpha` exists for exactly this and its doc
comment says so — "without this they blink" — but nothing drove it and the draw
ignored it, so every one of those was a hard pop.

It was driven once, and removed for a reason that was true at the time: a dying
line was mixed toward `.window.fill`, which is *not* the surface the graph is
drawn on, so the fade ended on a different colour instead of on nothing — the web
walked to pane colour, sat there, and popped back. That was a broken mix target,
not an argument against crossfading. `galaxy.panelFill` is the real backdrop now,
so mixing toward it genuinely reaches invisible.

Bounded, because a departing link is still drawn while it fades and the drawn set
is therefore the budget *plus* the tail. At the mark rate that tail ran ~23
frames and stacked 20,000 ghosts on a 10,000-line budget — three times the work
the budget exists to bound. A dedicated rate cuts it to six frames, and the
retire threshold rises when last frame's tail was over half the budget, shedding
the faintest without a sort. Web churn 11.2% -> 4.0%, overshoot held to ~50%,
pan frames 3.1 -> 5.1 ms mean against a 16.7 ms budget.

Two dead ends worth recording, since both looked convincing. `cap` in `liftLinks`
is derived from the cut size, so it steps for every cell at once when the
division crosses an integer boundary — a global discontinuity, and exactly the
shape that would amplify a small cut change into a large web change. Quantising
it to powers of two changed the measurement by nothing. And the coarse-zoom churn
figures are inflated by the probe itself: `panProbe` pans `13/zoom` world units a
frame, which at the coarsest band walks 5,253 units across a 5,719-unit vault, so
the camera leaves the map and "everything vanished" is counted as churn. The
honest numbers are the close-zoom rows.

Also here: `linkSpreadReport` overflowed on `lo = c + 1` for the final degree
bucket, whose cut is `maxInt(u32)`. Latent under ReleaseFast, and it aborted the
bench outright once the default build mode changed.
Edges shimmered while the view moved. Not the colour: `intoBg` returns an opaque
mix and `LineBatch` writes that straight to the vertices, so a line is a solid
colour and the "opacity as t in a lerp toward the background" this was meant to
be is exactly what it already was.

The width was the problem. The thickness reached `LineBatch` as a raw physical
pixel count — `1.0`, unscaled — while every other on-screen size in the panel is
written in tuned units and multiplied by `dpiScale` at use. On a 2x display the
whole web was therefore half a logical pixel wide, and a quad narrower than a
pixel never covers one: the rasteriser gives it partial coverage that shifts as
the line moves sub-pixel, and blending that against the background is the
shimmer. An opacity artifact, but from coverage rather than from any alpha we set.

`tuned_scale` and `dpiScale` move to `galaxy`, which `graph` and `world_draw`
both import and neither may import the other. Two copies of that constant is how
the web came to be drawn at half the width of the marks beside it, and I had
started to write a third before noticing.
Clicking a link flies the camera and the flight hitched. My regression: a
departing link is still drawn while it fades, so the drawn set is the budget plus
whatever is on its way out, and the only limit was a threshold that sheds the
faintest a frame late. That is fine for the drift of an ordinary pan and useless
for the case that hurts — `lift_hold` holds the web for the whole flight, and
when it releases a new cut lands at once, so *every* line of the old web departs
on a single frame. Fading all of them tripled the drawn set exactly when the
camera was moving fastest: 30,877 lines against a 10,000 budget.

Past `link_budget / 8` a departing link is now retired outright rather than
faded. Nothing is lost: a fade says "this is the same web, changing", and when
the whole web has been replaced that is not true — there is no continuity to
draw, so snapping is both honest and free. Overshoot is pinned at the cap.

Measured while reverting an experiment, and worth keeping: widening
`repulse_cut` does spread the vault and does make nearby mean related, but it is
not the free change the sampling cap made it look like.

  cut      density ring 1   co-citation   solve
  2.6x     97.1%            1.69x         1.0 s
  8x       94.5%            2.11x         8.1 s
  24x      87.6%            2.62x        13.4 s

So the diagnosis holds and Barnes-Hut is the right shape for it — global reach at
O(n log n) rather than O(n x area). Left at 2.6x until that lands.
Barnes-Hut over a pyramid of cell aggregates, replacing a repulsion that stopped
at 2.6 node spacings.

That cutoff is what collapsed the map. Springs pull at any distance while nothing
pushed back beyond a hair's breadth, so the graph fell inward until only local
repulsion held it apart — 90% of the notes inside 6% of the area, and two notes
sharing three neighbours sitting only 1.69x closer than two picked at random,
which is the map failing at the one thing it is for.

  density, innermost of 8 equal-area rings   97.1%  ->  81.7%
  giant component p99 radius                  2737  ->  1568
  co-citation vs random pairs                1.69x  ->  2.22x
  link spread crossing                        0.8%  ->  1.4%
  sibling overlap                            31.7%  ->  33.2%

Distant crowds are one force from their centre of mass; only what is close enough
to matter is looked at individually. Cost grows with the logarithm of the reach
rather than its area — widening the old cutoff to 24x bought a worse version of
the same shape for 13.4 s.

Notes on the parts that are not obvious:

The base cell is sized to *density* now, not to `repulse_cut`. That constant was
the interaction radius, and Barnes-Hut has no interaction radius; once the vault
spread out it meant 593,000 mostly-empty cells for 281,000 points, walked by the
prefix sum and by every pyramid level, every iteration.

The force is mass-proportional, `repulse_k * m_j / d^2`, where it used to go as
`sqrt(m_i * m_j)`. Not a retune: that is what makes a cell's whole contribution
equal its total mass at its centre, which is the approximation the tree exists to
make.

`theta` is 2.0, which is loose. The sweep says it is also *better*: 0.7 gave
crossing 6.9% at 15.3 s, 2.0 gives 1.4% at 7.2 s. Blurring distant structure
leaves the springs to decide it, and they are the ones that know.

Two measured dead ends. Walking nodes in cell order rather than index order, for
cache locality on the descent, changed the time by 8 ms in 7,700. And iterations
cannot be traded for the cost: 40 gives crossing 8.2%, 20 gives 14.3%.

The cost is the open question. 1.0 s -> 7.2 s, and this runs on every save, so it
needs the positions persisted before it can ship — which is also what makes a
fresh open agree with an incrementally edited one.
The never-coalesce rule asked each cell in turn whether it contained any open
note, by scanning the open set. That set grows all session as the reader clicks
through the map, so the cost was `cells x tabs`, at every level, every frame —
and it grew with how much of the vault you had visited.

The cells that must not coalesce are exactly the ancestors of the open notes, and
there are `depth` of them per note. Walking down to mark them costs that. Stamped
rather than cleared, so a frame costs nothing to invalidate, and an ancestry
already marked stops the walk early.

No measurable change in the bench, which is honest and expected: `--pan` runs
with a single focused note, so the term that grows with tabs is never exercised
there. This is kept for its shape, not for a number.

Not, therefore, a fix for the hitching that prompted it. The likely cause of that
was me: `zig build bench` on this vault runs a multi-threaded solve that
saturates the machine the editor is being tried on, so an agent measuring in the
background is indistinguishable from the app stalling. Recorded in memory so the
next session does not spend rounds chasing it as a rendering bug.
A tree came out as a dense knot with a third of its branches flung to the
outskirts, and where they landed the discs sat almost exactly on top of each
other. Both were my Barnes-Hut change, and both were the same mistake: the near
field and the far field had drifted into different force laws.

  near   sqrt(m_i * m_j) / m_i, cut off at `repulse_cut`, tapered to zero there
  far    m_j, no cutoff, no taper

The mass terms agree at the finest level, where every mass is 1, and diverge at
the coarse levels where a supernode stands for a whole subtree. A tree coarsens
into exactly those, and every subtree hangs off exactly one edge — so it got the
repulsion of a whole community answered by a single spring, and left.

The cutoff was worse. It used to bound the interaction *and* size the grid, so
the two agreed by construction; sizing the grid to density broke that and left a
hole — two notes in the same cell but further apart than the old cutoff got no
repulsion from the near field and were never considered by the far field, so
nothing kept them apart at all.

A cell standing in for its contents is only honest if opening it would give the
same answer, so the near field is now the aggregate's law exactly. `repulse_k`
retuned from 0.9 to 0.45, because mass now enters linearly where it used to enter
as a square root.

  tree      overlap 34.1% -> 13.5%   penetration 1.95x -> 0.54x   radius max 400 -> 305
  simplewiki  co-citation 1.69x -> 2.56x   density ring 1 97.1% -> 87.6%   crossing 0.8% -> 2.0%

`bh_theta` gets a small-graph value for the same reason `repel_cell_cap` did: at
2.0 even an adjacent cell is taken as a point at its centre of mass, which is
free on a real vault and wrong on two hundred unlinked notes, where the
neighbours *are* the graph. They piled up, which
`disconnected nodes do not stack on the origin` caught.

Cost is still the open question — the solve is 10.2 s on simplewiki, up from 1.0 s
before Barnes-Hut, and it runs on every save.
`multilevel.prof` now reports where a solve spends itself — grid, pyramid,
traverse, attract, integrate, coarsen — because every speed-up in this file has
come from finding out which phase was large, and three of my guesses this round
were wrong.

The win was undoing my own mistake. `bh_theta_small` tightened the opening angle
below 20,000 nodes, which sounds like "be exact on small graphs" and is really
"be exact on every coarse level of every large solve" — and those are the levels
the iteration taper runs hardest. Traversal was 4.8 s of a 10.5 s solve. A
guaranteed-exact near neighbourhood does the job the angle was being bent to do,
so the angle stays loose everywhere and the exactness is bounded: 10.5 s -> 8.3 s.

That radius is what buys non-overlapping discs, and it costs the square of
itself, so it is keyed on the size of the *problem* — 1,365 notes can afford to
be exact at every level, 284,000 cannot:

  tree        sibling overlap 21.8% -> 14.0%   penetration 1.06x -> 0.51x
  simplewiki  solve 8.5 s, unchanged

Three attempts that did not work, recorded so they are not retried:

  * Lowering the threading floors so coarse levels are threaded: no change. They
    are not where the time is.
  * Splitting the spring pass by node instead of by edge, to remove the serial
    reduction of per-thread buffers: 2.1 s -> 3.4 s. Walking each edge from both
    ends doubles the `@log` and `@sqrt`, and that is where the time is; the
    reduction was cheap adds. Counting operations without weighing them.
  * Stopping a level once it settles: never fires. The integrator floors its step
    at `0.08 * spacing`, so "nothing moved" cannot be observed below that.

Also measured: the same binary run twice gives bit-identical layouts, and solve
times that differ by up to 15%. Timing deltas smaller than that mean nothing, and
some of the ones reported earlier in this session were inside it.
The gate on a region layout, answered before building one.

A layout of nested regions is fast, deterministic, free of overlap by
construction, and updates only where the structure changed — everything the force
solve is not. It is also worthless if the links keep crossing between regions,
because the map then says nothing about what is near what. `fold`'s ladder was
the evidence against: 58% of its links crossed a quarter of the vault, and that
was read as the *family* failing rather than the pairing rule.

It was the pairing rule. Heavy-edge matching pairs whatever pairs up; modularity
is about communities. On simplewiki, links kept inside a group:

    groups     modularity     fold
    ~7,000        63.0%       10.3%
    ~3,000        70.0%       ~12%

Six times the containment, from changing nothing but how the groups are chosen.

Resolution trades containment against balance, and is the dial for modularity's
resolution limit — at 1.0 the largest community is 22% of the vault, which is a
blob needing a layout of its own:

    resolution   communities   largest   inside
       1            2,965       22.0%    70.0%
       4            3,075        5.9%    58.2%
      20            4,095        1.5%    41.0%

Even the most aggressive is 3.4x `fold`; resolution 4 keeps a balanced hierarchy
at 4.8x.

`louvain.zig` is headless and knows nothing about layout — it takes edges and
returns which community each note is in, at every level. Whether that becomes
nested regions is the next decision, and this is what it should be made on.
Reported by `bench --cluster`, with `--resolution=`, against the fold ladder at
matching granularity so the comparison is like for like.
@foxnne
foxnne force-pushed the push-qtpmrmwrorlv branch from b3f0511 to b4b7bb0 Compare August 31, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant