| … | … | @@ -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, |
| 5 | 5204 → 5258 asserts, green throughout. |
| 6 | |
| 7 | Four pieces of work: a frozen tab, a layout that placed connected things far |
| 8 | apart, a keyboard that was dead on two of four rungs, and a new view of how a |
| 9 | node is connected. The interesting part is not the features — it is that **three |
| 10 | of the four were mis-diagnosed first, and every mis-diagnosis came from an |
| 11 | instrument 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 |
| 21 | route. 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 |
| 41 | now gives up *whole*. Half-routed boards drew some wires orthogonally and the |
| 42 | rest as curves, which reads as a rendering bug rather than as a refusal. Past 15% |
| 43 | failures 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 |
| 50 | distances shorter? Imagine both circuitry and element placement in a circuit |
| 51 | board." Then: "do the connectivity-aware sequencing before wrapping." |
| 52 | |
| 53 | **What I built first, as asked.** `sequence: 'affinity'` — before a column wraps, |
| 54 | re-sequence it so items sharing a neighbour sit adjacent. Greedy runs, affinity = |
| 55 | direct 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 |
| 59 | another, ~0% on a third. The proxy and the router disagreed, and the router is |
| 60 | what the user sees. |
| 61 | |
| 62 | **The real gap was architectural.** Compound layout is bottom-up: a directory is |
| 63 | sized before the parent decides where the directory goes. So while a group's |
| 64 | members are being ordered, *nothing outside it has a position* — and inside a |
| 65 | directory, nearly every wire leaves the directory. Affinity knows that two nodes |
| 66 | share a neighbour but not **where that neighbour will land**, so it can pull a |
| 67 | cluster tight in the wrong place. |
| 68 | |
| 69 | **Fix: a second layout pass** using pass one's absolute positions as the hint for |
| 70 | where each member's outside neighbours will be. Two passes is a fixed point — a |
| 71 | third moves nothing, measured on four real modules and on a board built to be |
| 72 | hostile. |
| 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 | |
| 80 | favela-peer got *worse* and its board is byte-identical, so that is the ordering, |
| 81 | not 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 | |
| 92 | Affinity 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 |
| 105 | synthetic container. A container in `layout.js` already means "place these as one |
| 106 | unit", 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 |
| 108 | legible — what you pulled these nodes away from." |
| 109 | |
| 110 | Measured: mean distance between gathered nodes fell 1146 → 330 px (−71%) for |
| 111 | pear-git-v3 at one hop. |
| 112 | |
| 113 | **The user reversed it.** "Let's instead make g a special view that shows only |
| 114 | connected, allows you to see the connection relations." |
| 115 | |
| 116 | That was the right call and my framing had been wrong. The question a user has |
| 117 | is *"how is this connected"*, and 51 unrelated modules are the noise burying the |
| 118 | answer — not the context that makes it legible. I had optimised for a property I |
| 119 | valued (no information hidden) over the question actually being asked. |
| 120 | |
| 121 | **Second design (shipped).** Only the focus and its connected set, in columns |
| 122 | that *mean* hop distance and direction — furthest dependency left, focus centre, |
| 123 | furthest dependent right. What is hidden is stated: *"RELATIONS of anproto · |
| 124 | within 1 hop · 5 nodes, 7 links · hiding 51 nodes and 98 links"*, plus a per-card |
| 125 | count of links leaving the view. |
| 126 | |
| 127 | Two 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 | |
| 150 | The last one is the important one: the feature *worked*, and was unusable. No |
| 151 | test 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 |
| 167 | startup; with `--no-watch` it holds the old ones. I ran a key probe against it, |
| 168 | watched 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 |
| 172 | picked the first `.nv-node` in the DOM, which was scrolled off-screen. |
| 173 | `elementFromPoint` returned null, hover never fired, and the probe "reproduced" |
| 174 | the 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 |
| 177 | thing 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 |
| 180 | port and `pkill`-ed the one they were reviewing on. The only report was "server |
| 181 | down". |
| 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. |