measured-change · git:20260831.7d13b8f · 2026-08-31 · sha256 44157e5ad24d5ef2

measured-change git:20260831.7d13b8fA

Immutable. This exact content never changes and is served at /api/v1/blob/44157e5ad24d5ef2.

---
name: measured-change
description: Build the measuring instrument before changing anything whose effect you cannot see by reading the diff — quality scoreboards (pinned aggregate metrics) and performance benchmarks (pnpm bench, interleaved runs). Use for optimisation, layout/routing quality, heuristic tuning, or any change where "did that help?" is a real question.
---

# Measure before you change it

Some changes announce themselves in the diff. A heuristic, a cost model, a
search, an optimisation does not: it is correct-looking code whose worth is
entirely in numbers nobody has taken. For those, **the instrument comes
first, in its own commit, and the change is judged by it.**

This is not a preference. Across one routing/performance batch the
instrument rejected **three separate changes that were obviously right**:

- per-blocker detours — fixed 10 more defects, added 16 crossings; crossings
  are a higher tier, so it was a bad trade in the project's own currency
- a per-group placement cache — measured *slower* in all three rounds; the
  cached groups held two or three items and the key cost more than the work
- an excess-length penalty tier — a new cost term that argued well and moved
  no debt metric the corpus pins

Every one of them would have shipped on argument alone.

**And the instrument is not a veto — it prices.** An aligned re-score was
rejected in its first shape (a bespoke repair loop scoring whole
configurations, 13x layout time — it targeted 9 layouts in 400 and would have
charged every layout an extra pass), rejected again in its second (the same
loop reusing unchanged paths, 4.5x), and SHIPPED in a third that reused the search's
own incremental trial machinery, at 2x — `assignEdgeAnchors` now hands the
settled configuration back through the same search with `align: true`, and it
buys own-endpoint violations 35 -> 14 and crossings 647 -> 500. A measurement
that kills the first shape of an idea has not killed the idea.

That distinction is worth the words because this list used to end with the
re-score's FIRST verdict, as though it were the final one, while the third
shape was already in `spatial-edges.ts`.

## Quality: a scoreboard

For "does this look better", one canvas cannot answer — it says the fix
worked, never whether the failure moved somewhere nobody looked.

Build a corpus plus an aggregate. `packages/canvas-render/src/layout/edges/edge-routing-quality.test.ts`
is the worked example.

- **Metrics live in an independent oracle.** `test-utils/routing-metrics.ts`
  computes geometry directly and never calls the production scorer, so a
  broken rule cannot satisfy a test by agreeing with itself. Same contract as
  `test-utils/reversal-count.ts`.
- **Corpus = every reported defect, plus generated cases.** Generate DENSE:
  the defects only appear when things crowd, and a generator that spreads
  them out reports a clean score while drawing the same broken pictures.
- **Pin the numbers EXACTLY, not as a ceiling.** An improvement must be as
  loud as a regression — the point is that the number moves and someone says
  why. It is not a golden to regenerate.
- **Separate DEBT from PRICE.** Debt metrics (violations, ink through a body)
  target zero. Price metrics (bends, length, crossings) have no target and
  exist so a change that buys less of one harm with more of another cannot do
  it silently.

For a behaviour-preserving change, **the scoreboard not moving is the
proof**: an optimisation that leaves every pinned count identical, with no
expectation edited, has demonstrated it changed nothing but speed.

## Performance: `pnpm bench`

`vitest bench` (`packages/canvas-render/src/layout/edges/spatial-edges.bench.ts`),
never a hand-rolled `performance.now()` loop — you need the variance and the
sample count to know whether you measured anything.

### Interleave, or you are measuring the machine

Between-run drift on a loaded dev machine routinely exceeds the effect. Run
the versions alternately and compare paired rounds:

```bash
run() { pnpm bench 2>&1 | grep -aE "<the bench name>" | awk '{printf "%s ", $(NF-8)}'; }  # min
for i in 1 2 3; do
  echo -n "AFTER  r$i: "; run; echo
  git stash push -q <changed file>
  echo -n "BEFORE r$i: "; run; echo
  git stash pop -q
done
```

Report "faster in every paired round, median X → Y". A single before/after
pair is not evidence.

### A second bench in the same process is not a control

Benching a primitive alongside the thing you changed feels like a control. It
is not: they share a process, so **JIT warm-up couples them**. A change that
made the search call `routeEdge` 854 → 558 times left it colder when the
primitives bench ran, and it measured ~40% *slower* with its own code
untouched. Treat any movement in an untouched bench as a warning that the
comparison is contaminated, not as a result.

### Profile before hypothesising

The prediction was that 104 full-edge-set anchor recomputations were the
bulk. Measured: **16ms of 290ms**. The real cost was elsewhere entirely, and
a large refactor was nearly built on the wrong target. Time the phases first,
even crudely.

Counting is often better than timing. `routeEdge` calls: 854, of which 296
were byte-identical repeats — that number said "memoize" far more clearly
than any flame graph.

## Deciding a trade

When a change buys one metric with another, the project's declared ordering
decides it, not the size of the win. `PENALTY_RULES`' tiers are that
ordering: paying tier-2 debt for a tier-3 gain is a bad trade however good
the ratio looks.

When the ordering does not settle it — the currencies are genuinely
different, or the declared order is itself in question — **that is a human
decision**, and it is worth interrupting for. State both columns and the
concrete counts, and recommend one. ("42% less ink through a box, 8% more
crossings" was decided by a human in ten seconds; guessing it would have
been a coin flip.)

## Before a big rewrite, ask whether the target is reachable

Cheapest experiment in the batch: for every route still cutting through a
node, does a clean path exist at all? A throwaway BFS answered **67 of 68**,
which said the remaining debt was a candidate-generation problem and not a
cost-model one — redirecting the work and retiring a planned task outright.

Ask "is this even fixable, and by what class of fix" before building the fix.