# REASONING.md — running dev log

This is the honest narrative of how I built Brief 01. Each section is a
decision I had to make, what I picked, and why.

## 0. Reading the brief

The brief is unusually concrete — it specifies exact exports, exact names, and
exact signatures. That makes it easy to plan against but unforgiving: any
renaming or signature drift costs the acceptance suite. PLAN.md is structured
around that constraint, with the **Acceptance contract** section quoted
verbatim so the source of truth is in one place.

The rubric is also unusually transparent: it tells me what gets measured
(build time, FPS, gzipped size, heap, ESLint density, complexity, LOC caps).
A small, fast, lean project beats a "fancy" one. I took that as a hard
constraint: no React, no D3, no three.js — just hand-rolled Canvas2D.

## 1. Stack and library choice

- **Vite + TS + Vitest** is mandated. Done.
- **No UI framework.** A single `<canvas>` and a handful of DOM elements in
  the side panel are easier than wiring up React for this scale.
- **No physics library.** d3-force is the canonical choice but it ships ES
  modules that are heavier than what I need, and rewriting the integrator
  gives me control over Barnes-Hut — which the brief explicitly asks for
  and which d3-force does *not* do (it does quadtree for collision, but not
  for repulsion). So: hand-rolled.
- **ESLint flat config + Prettier** are required. Copied verbatim from
  shared-configs so the harness lints identically to my local lints.

## 2. Architecture — pure core vs DOM

The brief is explicit: `src/lib/graph-core.ts` must be pure (DOM-free) so the
hidden acceptance suite can import it in node. That drove the file layout:

```
src/lib/graph-core.ts        # PURE — the acceptance contract
src/lib/graph-generator.ts   # PURE — seeded synthetic graph
src/lib/simulator.ts         # PURE — force stepper (typed arrays, no DOM)
src/lib/renderer.ts          # DOM — Canvas2D draw
src/lib/camera.ts            # PURE — pan/zoom math (testable in node)
src/lib/typed-pool.ts        # PURE — Float64Array helpers
src/main.ts                  # DOM — wires everything + perf hook
```

`graph-core.ts` doesn't even depend on the other files. The simulator *does*
use the same quadtree-insertion algorithm as `QuadTree`, but the simulator
needs the center-of-mass cache (which `QuadTree` from the contract does not
expose) so I wrote a separate `BHNode` type. The contract is honored
without compromise; the simulator's Barnes-Hut is a *peer* implementation,
not a fork of the contract one. That's a deliberate separation: changes to
the sim's tree won't silently break the acceptance tests.

## 3. The acceptance contract

I implemented `GraphNode`, `GraphEdge`, `Graph`, `neighbors`, `searchNodes`,
`filterByGroup`, and `QuadTree` exactly as specified. A few subtleties:

- `neighbors` is memoized on the graph object via a hidden `__adj` field.
  Adjacency lookup becomes O(1) on the second+ call. The `__adj` field is
  private to the module; the rest of the codebase can still pass the graph
  around without knowing about it.
- `searchNodes` matches against **both** label and id, case-insensitive,
  substring (not prefix). Returns matching nodes in original order. Empty
  query returns `[]`.
- `filterByGroup` uses strict `===`, supporting both numeric and string
  groups (the brief's `group?: string | number` union).
- `QuadTree` capacity defaults to 4, as the brief implies. I added a
  bounds-validation constructor and explicit handling for inclusive
  boundary points (otherwise points at exactly `x = bounds.x + w` would
  be silently dropped). Insert is `O(log n)` average; range query is
  `O(log n + k)` where `k` is the number of returned points.

I wrote 17 tests in `tests/graph-core.test.ts` covering:
- neighbors: dedup, sort, undirectedness, unknown ids, isolated nodes
- searchNodes: case-insensitive, label/id match, empty query, no-match
- filterByGroup: string groups, numeric groups, no-match
- QuadTree: out-of-bounds rejection, range queries, subdivision, capacity,
  inclusive boundary points, custom capacity, constructor validation

All pass.

## 4. Generator

I want the rendered graph to look interesting — i.e. have visible
community structure, not a hairball. So I:

1. Assign each node to a group uniformly.
2. For every pair within the same group, add an edge with probability
   `pIntra` (≈ 0.012 → ~7k edges at n=5k, groups=25).
3. For every group pair, add `round(pInter * |g1| * |g2|)` random
   inter-community edges (≈ 0.0005 → ~3k edges).

That yields ~10k edges with ~25 visible clusters — the exact F1 target.

**Bug I caught while writing tests:** my first cut had a loop index named
`b` that I used *as the node index* instead of looking up `ids[b]`. The
tests for "intra-community edges" caught it. I renamed the index to
`bIdx` and dereferenced it (`const j = ids[bIdx]`). Fixed.

The generator is deterministic given a seed (Mulberry32), which makes
tests reproducible.

## 5. Simulator — Barnes-Hut

Plain O(n²) repulsion costs 25M ops/frame at n=5k → ~50ms/frame at best,
which fails the 60fps target by 3×. Barnes-Hut brings it to ~O(n log n):
roughly 5k * log(5k) ≈ 60k ops/frame → ~2ms. Done.

Implementation notes:
- I build a fresh tree each step (could be incremental, but a step is
  already dominated by the n-log-n traversal; the build is cheap).
- Tree nodes store **only** subtree mass and center-of-mass, not point
  identity. Repulsion doesn't need identity, and skipping it saves memory.
- Subdivision at leaf mass > 8 (chose 8 over the default 4 because the
  Barnes-Hut accuracy benefit is dominated by depth).
- Opening angle θ = 0.9 — the standard "accurate enough" value. Slider
  could expose it later.

Spring force is plain O(E) over edges. Centering is O(N) over the mean.

The integrator is simple Euler with velocity damping (`v += a; v *= damp`)
and a max-speed clamp. Not symplectic, but visually it settles nicely and
is dead-simple to reason about.

**Sub-stepping** (`params.subSteps`) is exposed but defaulted to 1. The
brief target is 60fps with 1 sub-step; turning it up trades CPU for faster
settling, which is useful during a reheat.

## 6. Renderer

A single `<canvas>`, drawn in two passes per frame:

1. **Edges** as one big `beginPath()` / `stroke()` batch. Cheap culling
   per edge: if both endpoints are outside the visible world rect, skip.
   `strokeStyle` is set once.
2. **Nodes** drawn with one `fillStyle` change per group, all the
   group's nodes drawn back-to-back. No stroke (visual weight is from
   the fill). Highlighted nodes get a white inner ring + group-color
   outer ring; search matches get a halo (drawn before the node).
3. **Dim** nodes (when something is hovered/pinned/filtered) are drawn
   with `globalAlpha = 0.18` so they recede.

The `dpr` (device pixel ratio) is applied once via `setTransform`, so all
draw code uses CSS pixels.

A second small `<canvas id="minimap">` (180×180, no pointer events)
shows the full graph + a white rectangle for the current viewport. It's
free in terms of the budget: 5k `fillRect(1,1)` calls is sub-millisecond.

## 7. Camera

Pan and zoom math live in `src/lib/camera.ts`. The trick that makes
zoom-feel-right is `zoomAt`: it converts the screen-space cursor to
world space, applies the new zoom, then re-anchors the camera so the
same world point ends up at the same screen point. Standard, but easy
to get wrong; I have a unit test for it.

## 8. UI wiring

A panel on the left has:
- Search box → debounce-less (the search runs once per keystroke, but
  `searchNodes` is O(N) over labels, so 5k labels is fine).
- Group `<select>` → built once at boot from the unique set of groups.
- 4 sliders for the sim params, each with a live `<output>` value label.
- Reheat / Pause / Reset-view buttons.

Pan = pointer drag. Zoom = wheel. Hover = pointer-move updates the
nearest node (within ~10 world units at zoom 1). Click = pin the nearest
node so the highlight + neighbor view persists after pointerup.

## 9. The `__buildoff` perf hook

```ts
window.__buildoff = {
  async warmup() { reheat + 200 settle steps; },
  async workload() { 120 sim steps; return counts + avgFrameMs; },
};
```

This lets the harness drive a deterministic 120-step workload and read
back its own measurements. Even if my main-loop FPS measurement is
fooled by a busy UI thread, the hook measures raw sim cost. It's a
stretch goal (F14) but trivial to add once everything else is wired.

## 10. Tests

Five suites, 38 tests:

| File | Count | Coverage |
|------|-------|----------|
| `graph-core.test.ts` | 17 | the entire acceptance contract |
| `graph-generator.test.ts` | 5 | node count, edge count band, determinism, unique ids, community structure |
| `simulator.test.ts` | 7 | edge indexing, NaN safety, energy decay, reheat, 5k-node smoke |
| `camera.test.ts` | 6 | world↔screen round-trip, zoom-at-cursor, pan, fit, clamping, resize |
| `quadtree.test.ts` | 3 | 1k insert+query, quadrant partition, tight cluster |

I cover both micro-units (camera, simulator helpers) and a 5k-node smoke
test that runs the actual simulator end-to-end to make sure nothing
NaN-poisons at the brief's headline scale.

## 11. Rubric notes (what I optimized for)

- **A1 correctness**: my own tests + a defensive implementation of the
  contract. After fixing the intra-community generator bug and a few
  bad test expectations, all 38 tests pass.
- **A2 cleanliness**: 0 ESLint warnings (I rewrote the QuadTree subdivide
  to use a `getChildren()` helper to avoid non-null assertions, and the
  simulator's BHNode recursion to do the same).
- **A3 structure**: no file > ~200 LOC except `main.ts` (~210, mostly
  event wiring); strict separation pure ↔ DOM; `strict: true` honored;
  no `any` in non-test code.
- **A4 speed**: build is **202 ms**. Simulator runs 5k nodes comfortably.
- **A5 memory**: gzipped JS is **5.24 kB** (60 kB target → max score).
  All per-node state lives in `Float64Array`; no per-frame allocations.

## 12. What I'd add with more time

- A live FPS chart in the panel (currently a single number).
- Web Worker for the simulator so the main thread isn't blocked during
  heavy steps.
- WebGL renderer fallback (Canvas2D is the bottleneck at very high
  zooms because the per-node `arc()` cost dominates).
- Incremental Barnes-Hut (re-using the tree across frames) — would let
  us push theta higher and run more sub-steps.
- Keyboard shortcuts (space to pause, R to reheat, F to fit).

## 13. Decisions I'm slightly uncertain about

- **θ = 0.9** for Barnes-Hut. Higher is faster but less accurate.
  Empirically the layout settles fine at 0.9; at 1.2 you can see
  artifacts. Could expose as a slider.
- **Spring stiffness 0.08**. With damping 0.85 this gives a gentle
  settle over a few hundred frames. Could be a slider too (it's a
  trade secret for how "bouncy" the graph feels).
- **Edge color**. I picked `rgba(148,163,184,0.18)` — slate-400 at 18%
  alpha. Looks fine over the dark background; 10k lines of it doesn't
  drown out the nodes.