OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / b09f7c9

docs: post-mortem for the hang, the layout pass and the relation view
Three of the four pieces of work in 45d1c1a..a07003e were mis-diagnosed first, and every mis-diagnosis came from an instrument trusted without checking: a regex standing in for a selector, a stale server standing in for the code, a probe aimed off-screen standing in for a repro, and a straight-line proxy standing in for the router. Also records the two "better" layout designs that measured worse than the simple one, and the four bugs shipped during the session with how each was caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b09f7c91ea9e752806082df0da3ec37d0c48d54b
by plankatron <fleeky@gmail.com> · 2026-09-21T02:46:17.000Z

Changed files (1) +206 -0

docs/postmortem-2026-09-20-layout-routing-relations.md +206 -0
……@@ -0,0 +1,206 @@
1# Post-mortem — the hang, the layout pass, and the relation view
2
3**2026-09-20 · codetree · hyper-fleeky-dev**
4`45d1c1a..a07003e` — 5 commits, 14 files, +1813/−71. Suite 241 → 258 tests,
55204 → 5258 asserts, green throughout.
6
7Four pieces of work: a frozen tab, a layout that placed connected things far
8apart, a keyboard that was dead on two of four rungs, and a new view of how a
9node is connected. The interesting part is not the features — it is that **three
10of the four were mis-diagnosed first, and every mis-diagnosis came from an
11instrument I trusted without checking.**
12
13---
14
15## 1. The hang — a linear scan and a fixed grid
16
17**Symptom.** "Double clicking also hangs the view." Then, after a first fix,
18"its still hanging."
19
20**Cause.** Measured, not guessed: pear-git's 316-edge board took **55,256 ms** to
21route. Two independent causes.
22
23- A\* kept its open list as an array and scanned it linearly for the cheapest
24 cell, and keyed visited state by string concatenation. Both costs grow with the
25 frontier.
26- The grid was fixed at 40. Cost is `edges × cells` and `cells = area / grid²`,
27 so a fixed grid makes a big board *quadratically* more work than a small one.
28
29**Fix.** Binary min-heap; numeric state keys (`cell * 5 + direction`);
30`chooseGrid()` derived from the board's own area and edge count, clamped
31`[20, 45]`; a cell-visit budget that gives up instead of freezing.
32
33| board | edges | before | after |
34|---|---|---|---|
35| hyper-overlay | 108 | 801 ms | 335 ms |
36| favela-peer | 167 | — | 732 ms |
37| favela-web | 189 | — | 1820 ms |
38| pear-git | 316 | 55,256 ms | over budget → curves |
39
40**A second decision that mattered more than the speed.** When routing gives up it
41now gives up *whole*. Half-routed boards drew some wires orthogonally and the
42rest as curves, which reads as a rendering bug rather than as a refusal. Past 15%
43failures it returns nothing and the caller draws curves for everything.
44
45---
46
47## 2. Bringing connected things together — and two "better" ideas that lost
48
49**Ask.** "Is it possible to bring denser connected groups together to make their
50distances shorter? Imagine both circuitry and element placement in a circuit
51board." Then: "do the connectivity-aware sequencing before wrapping."
52
53**What I built first, as asked.** `sequence: 'affinity'` — before a column wraps,
54re-sequence it so items sharing a neighbour sit adjacent. Greedy runs, affinity =
55direct edge (weight 3) + one per shared neighbour.
56
57**It measured as a wash.** Against a straight-line proxy it looked decent (−3% to
58−6% wire length). Against **the actual router** it was −7% on one board, +4% on
59another, ~0% on a third. The proxy and the router disagreed, and the router is
60what the user sees.
61
62**The real gap was architectural.** Compound layout is bottom-up: a directory is
63sized before the parent decides where the directory goes. So while a group's
64members are being ordered, *nothing outside it has a position* — and inside a
65directory, nearly every wire leaves the directory. Affinity knows that two nodes
66share a neighbour but not **where that neighbour will land**, so it can pull a
67cluster tight in the wrong place.
68
69**Fix: a second layout pass** using pass one's absolute positions as the hint for
70where each member's outside neighbours will be. Two passes is a fixed point — a
71third moves nothing, measured on four real modules and on a board built to be
72hostile.
73
74| module | avg routed path | avg turns |
75|---|---|---|
76| hyper-overlay (58 cards, 108 links) | −10% | −3% |
77| favela-web (164, 189) | **−16%** | −10% |
78| favela-peer (82, 167) | **+7%** | +2% |
79
80favela-peer got *worse* and its board is byte-identical, so that is the ordering,
81not the packing. Reported rather than tuned away.
82
83**Two variants that sounded more principled and measured worse:**
84
85- Hint from **every** neighbour, internal ones included ("absolute position beats
86 rank"). It reordered favela-web enough to widen its board 4328 → 5140 px and
87 **the router refused it outright**.
88- Hint only members with **no sibling edges** ("never override the median with
89 less information"). −4% / −2% / +4%, against the plain version's −10% / −16% /
90 +7%.
91
92Affinity was kept but defaulted off; with it on, favela-web's gain drops from
93−16% to −5%, because the two mechanisms fight.
94
95> **Lesson.** I reasoned my way to two designs that were more defensible in
96> prose and worse in measurement. A strength floor on the affinity drag was the
97> same story: every value above the minimum made things worse. The simplest
98> version won three times running.
99
100---
101
102## 3. Gather → the relation view
103
104**First design (mine).** G pulls the selection's neighbourhood into one
105synthetic container. A container in `layout.js` already means "place these as one
106unit", so this needed no new layout mode and no animation. Nothing hidden:
107"filtering the rest away would remove the only thing that makes the gather
108legible — what you pulled these nodes away from."
109
110Measured: mean distance between gathered nodes fell 1146 → 330 px (−71%) for
111pear-git-v3 at one hop.
112
113**The user reversed it.** "Let's instead make g a special view that shows only
114connected, allows you to see the connection relations."
115
116That was the right call and my framing had been wrong. The question a user has
117is *"how is this connected"*, and 51 unrelated modules are the noise burying the
118answer — not the context that makes it legible. I had optimised for a property I
119valued (no information hidden) over the question actually being asked.
120
121**Second design (shipped).** Only the focus and its connected set, in columns
122that *mean* hop distance and direction — furthest dependency left, focus centre,
123furthest dependent right. What is hidden is stated: *"RELATIONS of anproto ·
124within 1 hop · 5 nodes, 7 links · hiding 51 nodes and 98 links"*, plus a per-card
125count of links leaving the view.
126
127Two design points worth keeping:
128
129- **The walk is undirected, with the first step's side remembered.** A purely
130 directional walk misses siblings: when one module supplies both the focus and
131 something else, that something else is neither upstream nor downstream, yet it
132 is plainly related. Undirected finds it at two hops and the inherited side says
133 *how*, which also keeps every node in exactly one column.
134- **Rings are placed by an explicit `rank`, new in `layout.js`.** Edge layering
135 would put that two-hop sibling wherever its own edges fell — it is not a
136 transitive supplier of the focus — and the column would stop meaning distance.
137 Deriving a column from edges when the caller already knows it is lossy.
138
139---
140
141## 4. The bugs I shipped, and how each was caught
142
143| bug | how it was caught | what it cost |
144|---|---|---|
145| `keydown` began `if (!view.model) return`, and `view.model` is null on the system and module rungs — **B, R, E, C and Esc were dead on two of four rungs** while the hint bar advertised them | reading the handler while adding to it | pre-existing, unknown duration |
146| **G did nothing on the Nodes tab, silently.** A node's head and title call `nvEnter()` + `stopPropagation()`, so the obvious click target navigates; only the body selects. G required a selection, found none, returned without a word | **the user** | shipped |
147| Neither keydown handler guarded the event target — typing "graph" into search fired gather, frames and routing on the way past | noticed while fixing the above | shipped |
148| The relation view's highlight ran through `dependencyClosure`, which walks **upstream only**, so a focus whose relations are all downstream rendered almost entirely grey — 18 of 19 cards dimmed, all 33 links cold | dumping the DOM and counting classes | caught pre-commit |
149
150The last one is the important one: the feature *worked*, and was unusable. No
151test would have found it, because every unit was correct.
152
153---
154
155## 5. Instruments that lied — the actual root cause of most lost time
156
157**A regex over a dumped DOM is not a selector.** Twice.
158
159- `<path class="nv-noodle" d="...">` matched **nothing**, because the code sets
160 `d` before `class`, so serialisation order is `d` first. I reported "0 noodle
161 paths" and treated a working renderer as a regression.
162- `class="nv-node"` as an exact match missed `class="nv-node dim"`, so one board
163 reported 0 nodes while the other reported 19 for the same query. I nearly
164 chased a cross-view divergence that did not exist.
165
166**A stale server is not the code you just wrote.** The server imports modules at
167startup; with `--no-watch` it holds the old ones. I ran a key probe against it,
168watched G do nothing, and had already begun diagnosing before noticing
169`NV.hover === undefined` — a field that only exists in the new code.
170
171**A probe aimed at nothing reports nothing, not an error.** My first CDP probe
172picked the first `.nv-node` in the DOM, which was scrolled off-screen.
173`elementFromPoint` returned null, hover never fired, and the probe "reproduced"
174the user's bug. The bug was real; the reproduction was not.
175
176**A proxy metric is not the consumer.** Straight-line crossing counts said one
177thing about the affinity pass and the real router said another.
178
179**And I killed the user's server.** I started a second instance on a different
180port and `pkill`-ed the one they were reviewing on. The only report was "server
181down".
182
183---
184
185## 6. What changed as a result
186
187- **Verify with the real mechanism.** Key handling is now verified by dispatching
188 real `Input.dispatchKeyEvent` over the DevTools protocol — the only instrument
189 here that sees a real key event. Kept in `test/manual/`, with the two ways to
190 fool it written down.
191- **Missing output is a failure, not a pass.** Carried in from earlier in the
192 session and it held: an empty probe result is a broken probe until proven
193 otherwise.
194- **Measure against the consumer.** Layout changes are measured through
195 `routeEdges`, not through a straight-line proxy.
196- **State what a view hides.** Any view that filters carries its own omission
197 counts.
198
199## 7. Still open
200
201- `.pear-git/ci.yaml`'s self-check ends in `|| true`, so it cannot fail.
202- `docs/` holds what is really a spec (`EXPRESSION-FLOOR.md`); it should be
203 `spec/`.
204- External dependencies are counted but never drawn — 684 declared vs 105 drawn
205 at system level. Whether they should become real nodes is undecided.
206- favela-peer's +7% routed path under the second layout pass is unexplained.