The constraint was somewhere else
Five changes shipped. The two I expected most from returned least — I had sized the problem by the thing that was easiest to count.
What happened
Five changes went to production today.
Yesterday’s build log was written and then published, which is a small closed loop and not a story.
The trajectory graph exists as data now. visual: { cluster, importance, connections } has
been in the content model since the beginning with nothing reading it. buildSceneGraph()
reads it and returns nodes and edges. Entries reach it only through publishedNotes(),
publishedArtifacts(), publishedProjects() and publishedJourney() — getCollection is
never called there or in anything it imports. Nothing renders the graph yet and nothing
emits it into a page.
The interesting part is what it refuses. A visual.connections id that resolves to no
entry fails the build and names the file and the id. An id that matches more than one entry
fails the same way and lists the candidates. A link to a draft or a private entry is not an
error — that edge is dropped, and the drop is reported rather than silent. There is a
report, npm run graph.
Then a fix for something I broke. Two days ago I started mounting the hero scene promptly
instead of at idle, so returning visitors would see the wolf assemble rather than arrive
after it had finished. That exposed two faults. The stand-in gradient was removed the
moment createWolfGraph returned, and returning only means a renderer was constructed —
the canvas is empty for at least another frame. So the band was handed over 8 to 16ms
before the first draw and then showed nothing for 157 to 227ms, and on some drivers a
buffer that has been sized but never rendered composites bright rather than empty. Second,
the scene would size itself against a container the browser had not laid out yet:
clientWidth || 1 turned an unmeasured box into a 1×1 drawing buffer stretched across the
whole band. The scene now announces first paint through an onFirstFrame callback fired
inside the frame that drew, refuses to draw against a zero box, and takes its size from a
ResizeObserver rather than the window event.
And the particle field split into two layers: an 800-point graph layer whose positions the CPU knows, and a dust layer morphed in the vertex shader. Same public API, one renderer, one loop. No page changed.
What I noticed
The two changes I expected most from returned least.
The split was supposed to be cheaper. It shipped at parity — 0.515ms to 0.486ms at 1280,
0.463ms to 0.499ms at 1800. Instrumented per phase, the part it was aimed at did get much
cheaper: the per-point loop went from 0.087ms to 0.039ms. The link rebuild went from
0.225ms to 0.331ms and ate the gain. That is structural. MAX_LINKS is a fixed output
size, so producing the same 4400 links from a third as many points is more work per link.
Cutting the input did not cut the work, because the input was never what the work was
sized by.
The graph builder works, and its first report is the least flattering thing available
today. Three nodes. Three edges. From five published entries. The origin, foster-care
and public-service clusters are completely empty — three of six. Two published notes
carry no visual block at all, including yesterday’s log, published this morning.
What changed in my thinking
In both cases I sized the problem by whatever was easiest to count. Points, and code.
The particle field had 2300 points and the CPU touched all of them, so the point count looked like the cost. It was not; the link budget was, and the link budget does not care how many points it was built from. The map looked like a rendering problem, so I built the data layer a renderer could trust. That part is done and validated. What is missing is that almost nothing has been given a place on the map.
Yesterday I wrote about gates reading a convenient proxy for the state they were named for. This is the same substitution moved one step earlier — into the estimate, before any code is written. A proxy in a gate returns a wrong answer quietly. A proxy in an estimate sends you to work on the wrong thing entirely, and you find out at the end, holding a correct implementation of something that did not need doing.
Three of the five changes today were paying for decisions already made: a regression I introduced two days ago, and a refactor that did not deliver the speed it was meant to. The split still shipped, and I still think it was right — but its justification is what it makes possible, not what it costs. Saying that today, while the numbers are fresh, is cheaper than discovering it in six weeks.
The receipt
Five pull requests, all merged to main today.
- #8
75a56c3— draft the 2026-08-14 build log - #9
0e2ef82— build the trajectory graph from published content, and validate it - #10
c78bcdc— hand the hero over to the scene only once the scene has drawn - #11
5d0efb8— publish “Measuring the wrong thing” - #12
a64ed54— split the particle field into a graph layer and a dust layer
npm run graph, run against the content as it stands tonight:
Scene graph — 3 nodes, 3 edges from 5 published entries
Nodes per cluster
origin 0 (empty)
engineering 1
leadership 1
conduit 1
foster-care 0 (empty)
public-service 0 (empty)
Edges
artifacts/burn-the-cape-keep-the-map — projects/conduit (reciprocal)
artifacts/burn-the-cape-keep-the-map — projects/gitfitcode
projects/conduit — projects/gitfitcode
Published entries with no visual block (2)
notes/2026-08-14-measuring-the-wrong-thing
notes/2026-08-09-building-the-fosterman-system
Dropped edges (0)
none
3 clusters with nothing in them: origin, foster-care, public-service
#10 carries the measurements. flip is the moment the gradient was removed; a negative gap
means it was removed before any pixels existed.
| Viewport | Run | gap | empty band |
|---|---|---|---|
| 390×844 | before | −7.8ms | 157.9ms |
| after | +7.1ms | 0 | |
| 1280×900 | before | −15.7ms | 179.6ms |
| after | +8.0ms | 0 | |
| 1280×900, CPU 6× | before | −34.3ms | 227.1ms |
| after | +18.1ms | 0 |
After the fix the flip lands 7 to 18ms after the draw call in every configuration, which
is the duration of the first render() — both happen inside one frame and composite
together. On a throttled dev server the same fault showed 345ms of empty band.
What comes next
Three nodes is a triangle, not a constellation, and no renderer fixes that. The fix is
writing. Half the clusters are empty, and they are not the engineering ones —
origin, foster-care and public-service are the parts of this that are not code. The
data layer will draw whatever it is given. It has been given almost nothing.
The persistent canvas is in progress: one module owning the only WebGLRenderer on the
page, instead of the curtain building one and the hero building another. That is the shape
the scene needs to become the spine rather than a band above the headline. The second
context race is already closed — I shipped that yesterday, by making the curtain’s module
check whether its element is still in the document before it builds anything. The
persistent canvas is the stronger version: with one owner there is no second context to
leave open, so there is nothing to check for.
And #12 bought optionality, not speed. The blind 4400-link budget is the cost that did not move, and it stops existing when the graph layer carries named edges built from content — which is now possible, and was not this morning. Whether the rebuild cost actually comes down then is a claim I have not demonstrated. I will put the number in the log either way.
← All notes