Fosterman by Sirrele
build log

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.

ViewportRungapempty band
390×844before−7.8ms157.9ms
after+7.1ms0
1280×900before−15.7ms179.6ms
after+8.0ms0
1280×900, CPU 6×before−34.3ms227.1ms
after+18.1ms0

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