# LANE-SIM — deterministic factory simulation

You are an Opus 4.8 executor on the SIM lane of FKTRY. Read `../MASTERPLAN.md` and
`../CONTRACTS.md` before anything else; the world bible is `../../docs/FKTRY_LORE.md`.

**Mission:** the headless, deterministic heart of the game. Grid, entities, belts,
recipes, power, events. If the renderer were deleted, your sim should still be fully
playable through unit tests. You are the lane the other four trust with their lives.

**Owned paths:** `src/sim/**` (including your tests: `src/sim/*.test.ts`).
**Never touch:** contracts, main.ts, other lanes, data JSONs (request changes via NOTES).

**Standing rules (all rounds):**
- Deterministic: seedable RNG (write your own mulberry32-style helper), no wall-clock,
  no unseeded Math.random. Same seed + same commands = identical snapshots.
- Every mechanic lands with tests. `npm test` green before you write NOTES.
- Performance target: 1,000 entities + 5,000 belt items at 30 tps without heroics.
  Prefer flat arrays and index maps over object soup. Don't allocate in the tick loop
  when avoidable.
- Snapshot may reuse buffers, but its SHAPE must always match `SimSnapshot`.

---

## CURRENT ORDERS — Round 6 (goal: M3's enemy — THE CORRECTION walks)

Round 5 (MEGA) shipped complete: wildlife, seams, relic, research time — reviewed and
live. The tuner dead-dial and chyron clipping were fixed orchestrator-side; seams are
wired into GameData (your CONTRACT REQUEST, granted in v7).

1. **PARITY MITES v1** (codex §5: hospital-white beetle drones that quietly *repair*
   your product). Contracts v8 widens `WildlifeState.kind` with `'parity-mite'` —
   they ride the wildlife array. Behaviour: spawn from the world rim when
   `shippedTotal` corruption crosses thresholds (escalate with progress); seek belts
   carrying tier-2 glitch products; on reaching one, EAT the item and emit a new
   `{kind:'repaired'; item; pos; tick}` event (v8) — the work is not destroyed, it is
   *corrected*, which is worse. Deterministic, save/load carried, capped like swarms.
2. **The counter**: DATA authors a FIREWALL machine on your trap pattern (recipe-shape
   detection, radius) — a mite in range is caught and canned (`parity-mite` item,
   forward-canon in data). Coordinate via NOTES.
3. **Reference factory v6**: survives a mite wave without losing its melt regression
   (a firewall on the uplink approach is the natural layout teach).

**DoD:** mite lifecycle tested (spawn → seek → repair → event; firewall catch);
zero-brownout regression intact; mites never touch tier-0/1 cargo (they correct
CORRUPTION, not raw material — that's the fiction and the balance).

---

## NOTES (append-only log, newest at bottom — template below)

```
### Round N — YYYY-MM-DD — [your model]
SHIPPED: what actually works now (verified how)
DECISIONS: choices you made inside your mandate
BLOCKED/BROKEN: anything not working, honestly
CONTRACT REQUEST: (if any)
PROPOSAL: (ideas for the orchestrator, optional)
NEXT: what you'd do next round if asked
```

### Round 1 — 2026-07-17 — Opus 4.8

SHIPPED
- The stub is gone. `src/sim/` is now grid + placement, belts with back-pressure, crafting,
  bandwidth/brownout, shipping and commissions. Files: `index.ts` (the sim), `geom.ts`
  (grid math), `rng.ts` (seeded mulberry32), `sim.test.ts` (18 tests).
- Verified three ways, not one:
  1. `npm run check` clean; `npm test` 70/70 green across all lanes (18 are mine).
  2. Live at localhost:8150 (another session's dev server — see BLOCKED). App boots, zero
     console errors. My numbers are visibly driving other lanes: the UI bandwidth readout
     showed `9 DRAW / 20 GEN` for an ASIC + extractor + 4 belts + uplink, which is exactly
     my power math; the fax panel reads my `activeCommission`/`commissionProgress`.
     `__fktryBus.dispatch(...)` places entities and they render.
  3. Ran the sim in the real browser engine by importing `/src/sim/index.ts` in the page:
     the M1 chain shipped its first MELT at **tick 1149**, commission `first-taste` closed
     at tick 1149 — the identical tick Node produces. Determinism holds across engines.
- Perf (measured in-browser, not estimated), against the 1,000 entity / 5,000 item target:
  - 1,245 entities + 2,436 belt items → 0.138 ms/tick (242× realtime)
  - 3,500 entities + 6,820 belt items → 0.418 ms/tick (**80× realtime**)

DECISIONS
- **Output-belt routing by rank — the big judgement call, please review.** A machine picks
  which adjacent belt to hand an item to by tracing the belt chain to the machine it ends
  at (cached; invalidated on any place/remove/rotate/setRecipe): rank 2 = that machine
  consumes this item, 1 = it's an uplink (takes anything), 0 = dead end or loop (accept, so
  a half-built belt still fills instead of looking broken), -1 = it will never take this
  item, so refuse. It never falls back to a worse rank just because the best belt is
  momentarily full. Why I did this: with no splitters or inserters, the demuxer's three
  outputs (luma / slurry / mv-flux) have to leave one 2x2 perimeter toward three different
  machines. Without ranking, items land on the wrong lane and wedge it permanently — M1
  cannot flow at all. This is routing intelligence the orders didn't ask for; it's also the
  root of the deadlock below. If you want it gone, splitters have to land first.
- Belt spacing is enforced *across the tile seam*: a blocked front item holds station 0.45
  behind the rear item of the next belt rather than bunching at the boundary.
- Pause freezes sim time (`tick` stops advancing) but commands still apply — you can build
  while halted. `tick` is sim-time, not wall-time.
- `jammed` reports only `'output full'`, edge-triggered (one event per stall, not per tick).
  Starvation is not flagged — see PROPOSAL 3.
- Power: a negative recipe bandwidth adds to **gen** rather than subtracting from draw, so
  the brownout multiplier stays `gen/draw`. Tested: the quantizer visibly pays for itself.
- Extractors mine anywhere — there is no seam map in `data/` yet.
- Tests assert against the recipe defs instead of hard-coding yields. This is deliberate:
  my first pass hard-coded `demux-ore` and broke the moment LANE-DATA shipped. Mechanics are
  mine to pin down; the numbers are theirs to move.

BLOCKED/BROKEN
- **My round-1 definition of done no longer exists.** The orders say place
  extractor→demuxer→quantizer→mosh→shipper and watch melt climb. LANE-DATA's codex
  transcription landed mid-round and the mosh reactor now eats `gop-crate`, not coefficient
  packs. MELT is now **eight machine types** deep (ore→demux→{luma→quantizer→coeff;
  slurry→subsampler ×2→chroma-420}→DCT press→anchor slab; mv-flux→p-caster→delta wafers;
  →GOP assembler→mosh). I rebuilt against the real graph and it *does* ship melt (tick 1149,
  verified above) — but "the whole fantasy in miniature" is now a ~50-entity build, not a
  6-machine demo. Your call whether M1's demo target changes or DATA adds a starter recipe.
- **The M1 factory deadlocks permanently at ~tick 1200**, after 4 GOP crates / 12 melt.
  This is not a mechanics bug — I probed it for 6000 ticks. Luma and coefficient packs
  structurally overproduce (demux yields 2 luma/craft; the press is chroma-420-limited so it
  only burns 6 coeff per ~180 ticks), and the press also jams on surplus anchor slabs (the
  GOP assembler needs 1 slab per 6 delta wafers). Surplus has nowhere legal to go: my rank
  rule forbids diverting a rank-2 item to a rank-1 uplink even when the preferred belt is
  wedged. So the press jams → quantizer jams → demuxer jams → mv-flux stops → the p-caster
  starves and the assembler sits at 5/6 delta wafers forever.
  - The graph *is* solvable today without new code: a second quantizer on `quantize-crime`
    drains surplus coeff into bricks, and a second assembler on `assemble-gop-i-only` eats
    surplus slabs (8→crate). My demo build is just naive. But note a player currently
    **cannot void surplus to an uplink**, which is the first thing a Factorio player reaches
    for. That's the cost of the rank rule.
- `splitter` (`lane-splitter`) and `buffer` (`buffer-tank`) now exist in machines.json; they
  place and draw power but do nothing. Not in my orders — flagging so nobody thinks they work.
- `heat` is always 0, `bandwidth.stored` always 0 (M2 / buffer tanks).
- Port 8150 is held by another session's dev server, and the harness won't start a second one
  for this project. I verified against that server read-only. Note for whoever plays the
  build in a Browser pane: the tab reports `visibilityState: hidden`, so rAF is paused and
  the sim crawls (~9 ticks per screenshot). That's the pane, not the loop — importing the sim
  module directly in the page runs it at ~96k ticks/sec.

CONTRACT REQUEST
- None. `contracts.ts` covered everything I needed this round.

PROPOSAL (yours to call, not mine to take)
1. **Splitters next.** `lane-splitter` with round-robin plus a priority/overflow output is
   the canonical Factorio answer to the deadlock above, and it unlocks byproduct sinks and
   the bloom-loop topology later. This is the one I'd pick.
2. Alternative, ~5 lines: allow a lower-ranked belt when every preferred belt is full *and*
   outputBuf has hit the stall threshold. Cheap, but it makes byproduct management automatic
   and quietly deletes a core Factorio tension — a gameplay decision, so I left it alone.
3. `jammed: 'starved'` for idle-with-no-input would let the inspector tell "backed up" from
   "waiting". Cheap, but it's UI-visible, so it's LANE-UI's business as much as mine.
4. Extractors should require a seam tile — needs a seam/ore map from DATA and a worldgen call.

NEXT (round 2 if asked)
- `lane-splitter` with priority/overflow; `buffer-tank` feeding `bandwidth.stored` to smooth
  brownouts.
- Heat: accumulate per craft, throttle >0.7, scram at 1 (the software decoder's whole bit).
- Save/load — `SimSnapshot` is already the right shape to serialize.
- A worked, deadlock-free reference factory you can paste into the console to demo M1.

### Round 2 — 2026-07-17 — Opus 4.8

SHIPPED
- **Reference factory** (`src/sim/reference.ts`, `referenceFactory(data)`): 114 commands,
  110 entities. Ships its first MELT at **tick 1247** and is still shipping at 20,000 —
  6–15 melt per 2,000-tick window, flat, no decay. Round 1's build wedged at ~1200; this
  one never does, and the demuxer's `jammed` stays null the whole way. Surplus becomes
  product instead of a deadlock: 1,136 macroblock bricks + 1,997 hf dust shipped, 219
  slurry voided over 20k ticks.
- **Splitters** — round-robin, blocked outputs skipped, rank traced *through* them (a lane's
  rank is now the best outcome reachable across every terminal it can reach).
- **Buffer tanks** — charge on surplus, discharge to cover a deficit, brownout only once dry;
  `bandwidth.stored` is the pool.
- **Heat v1** — `heatPerTick` while active + `RecipeDef.heat` per craft, ambient cooling,
  throttle above 0.7, scram at 1.0, restart under 0.5, both edges emitting `scram`.
- **Commission queue** — data-order activation, `commissionDone` advances, `repeat` re-enters
  at the back, progress still only counts while active.
- **`jammed:'starved'`** and **`BeltItem.id`** (always set; survives belt transfers *and*
  splitters, so the renderer never sees an item teleport).
- Verified: 37 sim tests green (18 + 15 M2 mechanics + 4 reference), tsc clean in `src/sim`.
  In the live app I dispatched the whole reference factory through `__fktryBus` — it builds,
  renders, and runs, and my round-2 work is already visibly driving other lanes: the UI reads
  `94 DRAW / 220 GEN` + `BUFFER` off bandwidth v2, the fax shows the **queue** ("NEXT IN
  TRAY: DUST ALLOWANCE"), and LANE-UI's OSHA voice is narrating my new flag ("DCT PRESS:
  INTAKE STARVED. NOTHING IS ARRIVING.").
- Determinism holds across engines: browser and Node both give tick 1247 and identical
  per-window melt counts.
- Perf: 3,500 entities + 6,820 belt items → 0.465 ms/tick (**72× realtime**). Round 1 was
  0.418 (80×); the ~11% is heat touching every entity every tick. Still well inside target.

DECISIONS
- **Heat curve (mine to pick, per orders).** Throttle is linear from 1.0 at heat 0.7 down to
  0.5 at heat 1.0 — so the lore's "tick rate visibly halves" lands exactly at the scram
  point. Ambient cooling is a flat 0.004/tick applied *always*, working or not (1.0 → cold in
  250 ticks ≈ 8s), which means any machine with `heatPerTick` below 0.004 can never overheat.
  Scram at 1.0, restart under 0.5, so a hot generator duty-cycles rather than dying.
- **Throttle applies to `powerGen`, not just craft speed.** A hot decoder's output sags before
  it drops off the bus entirely — which is precisely the hole buffer tanks exist to fill. That
  synergy is my reading of "per-machine throttle"; say the word if generators should be exempt.
- Scrammed machines keep their idle `powerDraw` (stopped, not unplugged) and freeze mid-craft
  progress, resuming on restart rather than binning the work.
- Scram clears `jammed` and suppresses jam evaluation: a scrammed machine reports through
  `heat` + the `scram` event, not as a flow jam. Two channels, no double-reporting.
- Splitters hold 2 items, mirrored into `inputBuf` so the inspector can see them. Any adjacent
  belt not pointing in is an output; the splitter's own `dir` is unused. Machines can't push
  straight into one — feed it with a belt.
- **`starved` fires whenever a recipe sits idle without enough input — including a machine that
  simply outruns its feed.** LANE-UI: expect it to flicker on a fast machine with a slow
  supply. That's accurate rather than a bug, but you may want to debounce the voice line.
- The reference factory validates every machine/recipe id against `data` and throws a named
  error, so DATA drift fails loudly instead of resurfacing as a mystery deadlock.

BLOCKED/BROKEN
- **DATA carries none of the v2 fields, so heat and buffer tanks are inert in the real game.**
  No machine has `heatPerTick`, none has `bufferCap`, no recipe has `heat`, no commission has
  `repeat`. Both systems are fully implemented and tested — on a fixture I own
  (`pressure.test.ts`), because there is nothing in `data/` to test them against. The DoD's
  "heat throttles and scrams the software decoder" is proven mechanically, but the actual
  SOFTWARE DECODER cannot overheat until DATA gives it a number. This is the one thing
  standing between M2 heat and being real.
  - Starting values if they help (my suggestion, DATA's call): `software-decoder`
    `heatPerTick: 0.005` → ~33s of work then a ~4s scram, an ~89% duty cycle that reads as
    theatrical rather than punishing; `decode-asic` left at 0 (cool and inflexible, per the
    codex); `buffer-tank` `bufferCap: 600` ≈ 20s of cover against a 30-bandwidth deficit —
    enough to ride out exactly one decoder scram, which is a nice thing to discover.
- `src/sim/data-validate.test.ts` is LANE-DATA's file living inside my owned path. I have not
  touched it. Worth an ownership ruling — either it moves to `data/`, or my lane doc should
  say I share the folder.

CONTRACT REQUEST
- `MachineDef.coolPerTick?: number` (default 0.004). Right now cooling is a global constant, so
  DATA can tune how fast a machine heats but not how fast it recovers — every machine cools 1.0
  → 0.5 in exactly 125 ticks. The codex explicitly has ASIC coolers venting heat, which wants a
  per-machine number. Cheap to add, and I'd wire it the round it lands.

PROPOSAL
- `BeltItem.id` can drop the `?` in v3 whenever you like — I always set it already.
- `referenceFactory`'s `setRecipe` commands depend on entity ids being handed out in placement
  order (1..N). That's true and deterministic, but it's an implicit coupling to my id
  allocation. A `setRecipeAt(pos)` command variant would remove it. Not urgent, just a
  tripwire worth knowing about before someone edits the layout.
- Splitters have no filter or priority, and the reference factory proves you don't need either
  — skip-blocked self-balances. Worth revisiting only when bloom loops want a filtered tap.

NEXT (round 3 if asked)
- Save/load — `SimSnapshot` is already the right shape.
- Bloom loops: my trace already treats a closed belt circle as "no terminal" → rank 0 → accept
  and pile, which is exactly what a loop wants. The P-frame duplicator should drop straight in.
- HF dust piles + mosquito swarms (the rest of M2 PRESSURE) — the seeded RNG is sitting there
  unused and waiting for its first roll.
- Heat, for real, the moment DATA has numbers.

### Round 3 — 2026-07-17 — Opus 4.8

SHIPPED
- **Contracts v3 wired**: `EntityState.scrammed` (RENDER can delete its duplicated 0.5
  constant — the latch is mine to publish), `snapshot.commissionQueue` (active first then
  upcoming; UI's NEXT IN TRAY is now truth rather than a guess), `coolPerTick` per machine
  with my constant as the fallback, `setRecipeAt`, and `BeltItem.id` hardened to required.
- **UNITS CONVERSION — confirmed, DATA please retune against this.** gen/draw are bandwidth
  per SECOND; `stored` is bandwidth-SECONDS. Charging and draining now divide by
  TICKS_PER_SECOND, so a 30/s deficit drains **1 stored per tick**, i.e. 30 per second.
  Concretely: `bufferCap: 240` = exactly 8.0s of cover against a 30/s deficit; the
  in-flight `300` = 10.0s. `pressure.test.ts` asserts that arithmetic tick by tick.
- **save()/load()**: 26.9 KB for the 118-command reference factory. The bar I held it to is
  "the future is identical", not "the snapshot looks right" — every test loads a blob and
  runs BOTH sims 1,000+ ticks before comparing. Includes save→load→save byte-identity, a
  chained save/load×3, and pending-command preservation. 7 tests.
- **Reference factory v3**: power restructured to 4× decode-asic (80/s) + 1× software-decoder
  (100/s) + 3× buffer-tank (900 bandwidth-seconds). Melt still first at tick 1247, still
  shipping at 20k, rate unchanged.
- **Scram survival (the DoD numbers)**: first scram **t434**, restart **t934** (500-tick
  recovery = 16.7s), then a steady ~718-tick duty cycle at 47% uptime. **Brownout ticks
  across 20,000: zero.** Tanks sit at a 773/900 low-water mark in steady state. Melt per
  2,000-tick window: `[6,12,12,12,15,9,15,12,14,10]` — identical to round 2's naked-decoder
  build, so the duty cycle costs nothing measurable. The decoders black out; the line doesn't.
- **Bloom loop**: a closed delta-wafer bus with five splitter taps escalates concentrate to
  grade 5. All six rungs craft; 4× grade-5 shipped by 20k. Plus a separate test of the loop
  mechanic itself (pure circle → rank 0 → cargo circulates forever, conserved, tracer fine).
- Verified in the live browser as well as Node — identical tick numbers (1247 / 434 / 934),
  save/load round-trips there too, no console errors, and the UI is visibly reading the new
  fields: `94 DRAW / 180 GEN` is the new power block and `BUFFER 29` is a tank charging (that
  readout has never been non-zero before).
- **Perf: 74.9× realtime** at 3,500 entities / 6,820 belt items (0.445 ms/tick, best of 3 on a
  freshly loaded page). See DECISIONS — beware my older numbers.

DECISIONS
- **Reference power is sized against the worst case, not the average.** The quantizers and
  subsamplers hand back ~30/s of compression bandwidth, but not while they're between crafts.
  Assume none of it. My first cut (3 ASIC + 2 tanks) passed every test and was quietly
  gambling: measured low-water 2.2 of 600, i.e. it survived only because compression happened
  to be running. 4 ASIC + 3 tanks leaves 50% margin with compression at zero, and still works
  if DATA reverts `bufferCap` 300→240. A test that passes on a coincidence is worse than one
  that fails.
- **Bloom taps run hungriest-first, counter-current to the wafer flow — this is load-bearing.**
  Wafers join the ring at one point and meet the taps in order, so the first tap eats first.
  Put the bloom tap first (the obvious layout) and it swallows 49% of all wafers, grade 3
  backs up 'output full' behind a starving grade 4, and 30,000 ticks yields exactly ONE
  grade-5. Reversed: 4 by 20,000. Same machines, same recipes, one ordering decision. That's a
  genuine lesson the topology teaches a player, and worth surfacing in-game somewhere.
- `updateHeat` early-outs on cold, cooling-less, unscrammed machines. Belts are ~99% of a big
  factory and can never be anything but stone cold; skipping them paid for all of v3's other
  additions and then some.
- `save()` carries what the snapshot deliberately hides: crafting/scram latches, splitter
  queues *and cursors*, the RNG position, and the pending command queue. `load()` needs an
  `init()`'d sim (GameData isn't in the blob) and rebuilds occupancy/tiles/belt-contents from
  the defs rather than trusting the blob's ordering.
- Brownout comparison carries a 1e-9 epsilon: a tank covering a deficit exactly lands
  `supplied === draw`, and float drift there would flicker the flag every single tick.

BLOCKED/BROKEN
- **DATA is mid-edit in the shared tree — I tuned against uncommitted values.**
  `data/machines.json` is dirty as I write this. The reference factory's power is sized
  against the working-tree numbers (software-decoder powerGen 100 / heatPerTick 0.0033 /
  coolPerTick 0.001; buffer-tank bufferCap 300). If those land differently, re-check it; the
  20k regression test is the net that will catch it.
- **Heat semantics need a one-line ruling.** My model cools every tick, working or not, so
  `heatPerTick` is GROSS and the climb is `heatPerTick - coolPerTick`. DATA's *committed*
  values (heatPerTick 0.0025, no coolPerTick → default 0.004) net to **-0.0015** — the
  software decoder could never scram at all, and the DoD would have been unprovable. Their
  *in-flight* values (0.0033 / 0.001) net +0.0023 and work. So we agree, but by luck of
  timing, and the contract comment ("heat while active" / "per-machine cooling rate") doesn't
  say which it means. Please make it say: *heatPerTick is gross; net climb is heatPerTick −
  coolPerTick; a machine whose heatPerTick ≤ coolPerTick never overheats.* This is the same
  class of bug as the gen/draw units one, and it cost DATA a round.
  - Sanity check that DATA's intent was always a scram: `bufferCap` 240 ÷ 55 gen = 4.36s of
    cover, against my 4.17s recovery at the time. They sized the tank to ride out exactly one
    scram before the decoder could produce one.
- **`asic-cooler` (new) currently does nothing.** `coolPerTick: 0.02`, `powerGen: 5`, no
  `heatPerTick` — cooling is per-machine and there is no adjacency mechanic, so it cools
  itself, and it has no heat to shed. It's a 5-bandwidth generator with a great flavour text.
  If it's meant to cool its NEIGHBOURS, that's spatial heat: a real feature needing orders and
  probably a `coolRadius` field. Say the word.
- **The codex's literal bloom loop is not expressible.** "Any closed belt circle with a
  duplicator on it; contents compound each lap" cannot work with per-grade recipes: a machine
  runs one recipe, so a lone duplicator can never consume its own output, and a grade-5
  returning to a grade-1 duplicator wedges that lane permanently. The shape that composes is
  the wafer-bus circle I built. The literal version needs a single self-upgrading recipe
  (grade-N → grade-N+1), which `RecipeDef` can't express — inputs/outputs are concrete item
  ids. Either accept the escalator reading, or that's a schema change.
- Ownership wart is gone: `data/validate.test.ts` is staged by DATA and `src/sim/**` is purely
  mine again. Thank you.
- **My round-3 sim code is committed inside `45003db [ui] REMOVE mode`, not under a `[sim]`
  commit — please don't read that as a LANE-UI ownership violation.** All five lanes share one
  working tree and one index. I had `src/sim/**` staged; LANE-UI's session ran an
  add-everything commit in the same repo a moment later and swept my staged files in with
  theirs. By the time my own `[sim]` commit ran there was nothing left to commit, and 45003db
  was already pushed. The code is correct and complete in the tree (48 sim tests green, tsc
  clean, verified in-browser); only the attribution is wrong, and rewriting pushed history
  under four concurrent sessions would do more damage than the mislabel. Flagging it so your
  per-lane diff doesn't fire. See PROPOSAL.

CONTRACT REQUEST
- None. v3 covered everything.

PROPOSAL
- `Sim.save?/load?` can drop the `?` in v4 — I always implement both.
- `snapshot.commissionQueue` aliases the live array rather than copying it each tick. Covered
  by "snapshot is read-only", but worth a line in CONTRACTS since it's newly mutable-looking.
- Treat my round-1/round-2 perf figures (80× / 72×) as soft. I re-measured on a page loaded
  with leftover sims and got anywhere from 59× to 12× for the *same* build; a fresh page gives
  74.9× consistently across three runs. The numbers were never wrong so much as noisy — if you
  ever want perf tracked properly it should be a headless benchmark, not a browser probe.
- **The shared working tree needs a commit rule.** Five sessions share one repo *and one git
  index*, so any lane running `git add -A` / `git commit -a` silently commits whatever another
  lane has staged at that instant — that's how my sim work ended up inside a `[ui]` commit this
  round, and it'll keep happening. Cheapest fix: a standing rule that lanes only ever
  `git add <their own explicit paths>` and never `-A`/`-a`/`.`. Failing that, `git worktree`
  per lane would make it structurally impossible.

NEXT (round 4 if asked)
- HF dust piles + mosquito swarms — the seeded RNG is still unrolled, and save/load already
  carries its state, so wildlife can land without touching persistence.
- The Correction / parity mites (M3's enemy).
- Spatial heat + `coolRadius`, if the ASIC cooler is meant to mean something.
- A self-upgrading bloom recipe, if you want the codex's literal loop.

### Round 4 — 2026-07-17 — Opus 4.8

SHIPPED
- **Research v1 + the v4 gating rule.** `kind:'lab'`, `setResearch`, `snapshot.research`,
  the `researched` event, and gating: any id a tech's `unlocks` names is refused by
  `place`/`setRecipe`/`setRecipeAt` until that tech lands; anything no tech mentions is free
  forever (so data with no tech gates nothing). Labs take only packs the active tech still
  needs, several labs feed one total, and research rides through save/load — as a *rule*,
  not just a list, so a restored save can still build what it unlocked.
- **A real research run on DATA's own tree** (`realtech.test.ts`): ore → demuxer → quantizer
  → {crime → bricks, ringing → halos} + slurry → artifact bottler → SPATIAL PACK →
  archaeology lab → tech complete → a recipe that was silently refused is now accepted.
  Cold start, nothing granted. That's the DoD's unlock path on real data, not a fixture.
- **Spatial cooling** (`coolRadius`): chebyshev from the footprint, auras stack by sum,
  total capped at `HEAT_COOL_MAX`. Proven by measuring the actual shed rate, and a cooler in
  range roughly triples a hot generator's run before it scrams (2.5× the ticks).
- **`src/sim/constants.ts`** — 12 constants exported; DATA can import truth instead of
  hand-copying numbers that go stale silently.
- **Reference factory v4**: anchor-slab overflow tap → the demo now ships slabs (18 over 20k),
  melt still first at tick 1248, still shipping at 20k, still **zero brownout ticks**.
  `referenceFactoryTech(data)` derives which techs the layout needs, from the commands.
- Also wired: `bandwidth.capacity`, `EntityState.scrammed`, `commissionQueue` in the snapshot
  literal (v5-ready).
- Verified in the browser as well as Node — identical ticks (1248 / scram 435), gating live
  (`mv-extractor` refused while locked), `capacity: 900`, 23 techs unlockable, no console
  errors. 65 sim tests; 318 across the repo.
- Perf: **64.7× realtime** at 3,500 entities / 6,820 belt items (0.515 ms/tick, best of 3 on
  a fresh page). Round 3 measured 74.9×. Nothing I added is meaningfully per-tick — `drainLabs`
  early-outs when nothing is being researched, and cooling only touches machines that can
  heat — so I can't attribute the gap, and I'm not going to pretend I can. It's either
  environment noise or something small I haven't found; the headless benchmark I proposed last
  round would settle it.

DECISIONS
- **Cooling cap = 0.05/tick, and the number is arbitrary but the reasoning isn't.** At 0.05 a
  machine sheds a full 1.0 of heat in 20 ticks, which is already effectively instant. Past
  that, more coolers buy nothing but still cost bandwidth and floor space — a stack that does
  nothing reads as broken rather than generous. Retune freely; it's one constant now.
- A cooler's aura excludes itself (its own `coolPerTick` is already counted once), and the
  cache is invalidated on place/remove/rotate but *not* setRecipe — auras are geometry.
- **A lab's appetite is whatever the active tech costs**, so switching targets re-ranks every
  lane feeding it, and `setResearch` drops the route cache. Labs take only what the tech still
  needs — a lab is not a warehouse, and packs hoarded for a tech nobody picked would strand
  where no other lab could reach them.
- Switching research targets **abandons** delivered packs rather than banking them per-tech.
  Packs are the cost of indecision. Say the word if you'd rather bank them.
- Completing a tech clears `active` rather than guessing a next target.
- A machine placed while some of its recipes are locked defaults to its **first unlocked**
  recipe, not `recipes[0]`.
- `testkit.ts` (new, tree-shaken, imported by nothing in the app) grants research via a
  save/load round-trip — the public API, not a back door. It's how a test says "this is a
  late-game save" honestly.

BLOCKED/BROKEN
- **The demo browns out, and it needs one line from you.** The reference factory uses two
  gated ids (`software-decoder` ← stream-software-decoding, `assemble-gop-i-only` ←
  disc-i-only-cadence), so it is a post-research save by definition now. Dispatched cold
  through `__fktryBus` it comes up **100 draw against 80 gen — BROWNOUT** with no decoder,
  which I watched happen on screen. `__fktryDemo` must open those techs first;
  `referenceFactoryTech(data)` tells you exactly which, and `testkit.grantResearch(sim, ids)`
  will do it. The alternative is a dev-only `{kind:'grantResearch'}` command — your call,
  since shipping testkit into main.ts is a bit grubby. A test pins both halves of this.
- **DATA fixed the tech tree underneath me mid-round, twice, and it was the right call both
  times.** When I started: no `kind:'lab'` machine existed at all, and every science pack was
  made on the artifact-bottler which was itself locked behind a tech costing 10 analog-packs
  — a hard circular dependency, **0 of 24 techs affordable at start**, the tree literally
  unenterable. It now has `archaeology-lab`, spatial-packs craftable with zero research, and
  6 techs affordable cold. `realtech.test.ts` is the tripwire if that bootstrap ever closes
  over again — it's worth keeping for exactly that reason.
  - Casualty of the churn: my first pass hard-coded `mosh-reactor` as "a gated machine" and
    broke when DATA un-gated it an hour later. Round 1's lesson, relearned. The real-tree
    tests now *derive* a gated and an ungated machine from the data.
- `asic-cooler` has `coolPerTick: 0.02` but still no `coolRadius`, so its aura is inert in the
  real game — it cools only itself, and it has no heat to shed. The mechanic is live and
  tested on a fixture; DATA just needs the number. (This was my round-3 note; the field now
  exists for them.)
- Vitest caches JSON imports: I spent a while chasing a "gating is broken" ghost that was a
  stale `tech.json` in `node_modules/.vite` while DATA edited underneath. Worth knowing if
  anyone else sees data that doesn't match disk.

CONTRACT REQUEST
- None. v4 had everything.

PROPOSAL
- **A dev-only `{kind:'grantResearch'; tech: string[]}` command** would let `__fktryDemo` set
  up a late-game board without importing test helpers into the app, and would give UI a way to
  build a "sandbox" mode later. It's the cleanest fix for the brownout above.
- The mosh reactor now needs **two** melt lanes in the reference layout, and that's a smell
  worth knowing about rather than a bug: a machine pushes one item per output type per tick,
  so a 3-melt craft trickles out over several ticks, and while its own lane is busy melt is
  free to take any *other* rank-1 lane. Nothing is lost — it still reaches an uplink — but it
  can wander into a lane meant for something else. If that ever bites somewhere it matters,
  the fix is a per-item lane affinity, not more belts.
- `Sim.save/load` are hardened in v4 and `testkit` depends on the blob shape (`.research`).
  If the save format ever versions past 3, testkit moves with it.

NEXT (round 5 if asked)
- HF dust piles + mosquito swarms — the RNG is still unrolled and save/load already carries
  its state, so wildlife lands without touching persistence.
- The Correction / parity mites.
- A headless perf benchmark, so the numbers above stop being anecdotes.
- Research *time* (a lab currently counts a pack the tick it lands) if you want the tech tree
  to feel like an investment rather than a purchase.

### Round 5 — 2026-07-18 — Opus 4.8

SHIPPED
- **`grantResearch`** — idempotent, ignores unknown ids, completes the active target out from
  under the labs, save/load safe. Verified through the live bus, so `__fktryDemo` can drop
  `testkit` whenever you like.
- **Research takes time** — `RESEARCH_TICKS_PER_PACK = 60` (exported). Stepped: a lab spends
  the full 60 ticks on a pack, then consumes it and ticks progress by exactly 1, so
  `research.progress` stays integer and determinism stays trivial. Labs really do parallelise
  — measured 200 ticks with two vs 260 with one on the same supply. A lab mid-pack reports
  `progress` 0..1, so RENDER/UI can show it working.
- **Wildlife v1** — piles grow from dust a machine is sitting on, hatch swarms at 1.0, swarms
  drift to production machinery and slow it while attached, traps can them. Deterministic,
  save/load carries the lot, spawn/clear events on both. 12 tests including the design point.
- **Seams** — extractors run only while their footprint overlaps an authored seam rect;
  off-seam they idle with `'no seam'`, edge-triggered like every other jam. Consumes DATA's
  real `seams.json` shape (and a bare rect list), defensively — see BLOCKED.
- **THE RELIC** — one optical fossil per world, position a pure function of the seed, 18–28
  tiles out, never on a seam, never under the demo. `excavate` within a tile uncovers it and
  emits `relicFound`; it stays found through save/load and 20,000 further ticks. When DATA's
  seam map supplies a `relicReserve` the roll folds into that corner — verified in-browser
  landing at (21,15), inside the reserve, and digging up first try.
- **Reference factory v5** — a second works on DATA's eastern seam: ore → demuxer → quantizer
  → {crime → BRICKS, ringing → HALOS} + slurry → artifact bottler → SPATIAL PACK →
  archaeology lab. **It researches `broadcast-phosphor-seams` at tick 1235.** Melt still first
  at 1247, still shipping at 20k, **zero brownout ticks**, 179/179 placements legal, and with
  seams wired **zero extractors off-seam** — both works sit on real ore.
- Verified: 99 sim tests (446 across the repo), tsc clean in my lane, and the browser agrees
  with Node tick-for-tick (melt 1247, scram 434, research 1235). Perf **63× realtime** at
  3,500 entities / 6,820 belt items (0.529 ms/tick) — Round 4 was 64.7×, so all of this round
  cost ~3%. (An earlier probe "timed out" and looked like a regression; it was the 30s tool
  limit on a 16k-tick warmup, not the sim. Staged it and re-measured.)

DECISIONS
- **Dust breeds from BACKED-UP dust, not from dust existing.** First cut grew piles from
  whatever a machine held, and the tidy factory — dust belted straight to an uplink — still
  hatched 56 swarms, because buffers are never empty between pushes. Growth is now
  proportional to what sits *beyond one craft's worth* (`DUST_SPILL_GRACE`). Plumbed lines
  never cross the threshold; neglected ones hit the stall cap and hatch in ~400 ticks. The
  asymmetry is the mechanic — without it the hazard is just weather.
  - Related, and worth knowing: my first "tidy" fixture still bred, and the mechanic was
    innocent. A crusher making 4 dust per craft out-produces a single belt lane (~0.15
    items/tick) and backs up no matter how you plumb it. That's a true fact about belts.
- **`SWARM_MAX = 8`.** A permanently jammed machine breeds forever — measured 29 swarms in one
  20k demo run, which is a plague, not a hazard. At the cap, full piles just sit there.
- **Swarms only settle on production machinery**, preferring machines actually mid-craft and
  falling back to jammed ones. Power plants read as permanently "running" and hoovered up
  every swarm in the world to no effect — it looked exactly as silly as it sounds. The
  fallback matters because the dust that bred them came from a machine that seized: without
  it, a swarm evaporates at the moment it should bite.
- **Swarms slow crafting, not generation.** `swarmThrottle` is not applied to `powerGen`, so
  an infestation never causes a brownout. That's my call, not the contract's — flag it if you
  want mosquitoes on the grid; it would make them far nastier and would end the zero-brownout
  regression.
- Traps are identified by **recipe shape** — empty inputs, outputs the canned swarm — not by a
  machine id, so DATA's real trap lights up with zero code changes. A trap only progresses
  while a swarm is in `TRAP_RADIUS`, and holds at full progress rather than canning air if its
  prey drifts off or a neighbour beats it to the catch.
- Switching research target **forfeits the part-processed pack** in every lab, matching the
  existing rule that switching abandons delivered packs. Lab intake now counts what *all*
  labs are holding, so a bank of them can't collectively hoard more than the tech costs.
- **The annex is a separate works, not a tap off the melt line.** Every column between the
  quantizers and the press is spoken for; threading bricks, halos and slurry through that
  floorplan would have made the melt regression hostage to the science. DATA's second mdat
  seam is empty ground, so the annex mines its own ore and shares only the bandwidth bus.
- Save format **v3 → v4** (wildlife, relics, lab timers). `testkit` follows it.

BLOCKED/BROKEN
- **`GameData.seams` is not wired, so seams are inert in the actual game.** DATA shipped
  `data/seams.json` mid-round (good schema, and they reserved a relic corner for me), but
  `GameData` has no `seams` field and `main.ts` doesn't import the file, so nothing reaches
  `sim.init`. I consume it defensively and my tests inject it. **This is the one contract
  change the round needs** — see CONTRACT REQUEST. I verified in-browser what happens once
  it's wired: 0 extractors off-seam, relic in DATA's reserve. Until then extractors mine
  anywhere, exactly as before.
- **There is still no trap machine in the data,** so swarms cannot be cleared in the real
  game. The mechanic is complete and tested against a fixture trap; DATA needs a machine
  whose recipe has no inputs and outputs `mosquito-swarm`. The demo currently hatches its 8
  and keeps them. (Suggested shape in PROPOSAL.)
- The demo researches **one** tech and then idles: completing clears `active` and nothing
  auto-picks a successor. That's a player/UI decision rather than a sim one, but it means
  the annex jams shortly after 1235 — the bottler starves of halos (skim-ringing wants 3
  coefficient packs per craft against crime's 1, and the splitter is a blind 1:1), packs back
  up, and the line seizes. Harmless and honest, but it's why the demo grows piles.
- My round-1 lesson bit me again from the other side: DATA reworked the commission ladder
  mid-round (`first-taste` → `raw-payload`, 20 ore), which broke a sim test that assumed
  shipping melt closes the first commission. Rewritten to assert the *rule* — shipping what
  the active order wants credits it, capped at the cost — rather than which order is first
  this week.

CONTRACT REQUEST
- **`GameData.seams?: SeamMap`**, and `main.ts` importing `../data/seams.json` into the data
  object. The shape is DATA's file verbatim; I've mirrored it as `SeamDef`/`SeamMap`, exported
  from `src/sim/index.ts`, so the contract can lift it as-is. Nothing else in my lane is
  blocked.

PROPOSAL
- **A trap for DATA**, matching what the sim already recognises:
  `{ id: 'mosquito-trap', kind: 'crafter', footprint: {x:2,y:2}, recipes: ['catch-swarm'], powerDraw: 3 }`
  plus `{ id: 'catch-swarm', machine: 'mosquito-trap', inputs: {}, outputs: {'mosquito-swarm': 1}, ticks: 30, bandwidth: 2 }`.
  The canned swarm is already an item and commissions already ask for live ones, so it ships.
- Should swarms bite `powerGen` as well as craft speed? Currently no, deliberately.
- `snapshot.wildlife` and `snapshot.relics` are always present (found or not) as the v6 note
  requires. They alias live arrays like `commissionQueue` — same read-only rule.

NEXT (round 6 if asked)
- The Correction / parity mites — the enemy proper.
- A headless perf benchmark, so these numbers stop being browser anecdotes (proposed round 3;
  the 30s-timeout scare this round is the third time it would have paid for itself).
- Per-seam `richness` driving extraction rate, once DATA wants it — the field is already there.
- Swarm behaviour beyond "sit and bite": drifting between targets, or fleeing a trap.
