Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/skills/simulation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,21 @@ Measured at the default `maxTmSteps` of 10,000: **921 MB**, on default settings.
- **The window rule is duplicated from `Tape.snapshot()` and must not drift**, so [tests/tape-log.test.js](tests/tape-log.test.js) checks the rebuild against `snapshot()` itself across the bounded, two-way and right-bounded cases rather than against its own expectations. It also pins that read order does not matter (forward, backward and scattered access agree), and — the guard against the whole thing coming back — that `tape`, `head` and `view` are **not own properties** of a step.
- The spread is gone from the window rule (`Math.min(head, ...keys)`): on a tape this module exists to make large, that is an argument list with an engine limit on it.

### The fast lane

**A TM step cost ~300ns, and none of it was the machine.** Profiled on BB(5): the generator yielding a step object (35%), the cursor pulling it (15%), the Map tape and the loop fingerprint (12%), three calls into the log and the step columns (22%), the transition lookup by name (6%), and `App.accepts.has` through the reactive Set (3%). The same steps as table lookups over a byte array take ~8ns. [js/machines/fast-tm.js](js/machines/fast-tm.js) is that loop, recording into the *same* columns, so every reader of a run — the player, the scrubber, the trace log, the space-time diagram, StateMate — is untouched.

- **It takes over when the loop detector stops looking**, which is what makes it exact rather than approximate: `makeLoopTracker` gives up after `LOOP_TRACK_MAX` (5,000) configurations, and is off when the reader turned it off, so from there the slow loop does nothing the fast one does not. `loop.verifying` is the hand-over test. Short runs, where loops are actually caught, never leave the slow loop. It is `TM`/`ITM` only (both go through `streamTM`); LBA and MTM could follow on the same terms.
- **The log's own copy of the tape is the tape.** `log.appender()` and `cols.appender()` expose the columns, the interners and `live` (the tape as codes) to a producer that records thousands of steps in one loop; it must leave exactly what `begin`/`noteWrite`/`step` would, including the checkpoints and the undo exception for an explicit blank. The step columns are filled from local buffers a block at a time (`pushMany`).
- **A batch is as many steps as the cursor asked for.** `iter.next(n)` says how many (a generator reads it as the value of `yield`), and a producer answers with `batch(count, last, at)` from [js/machines/run.js](js/machines/run.js). The drain asks for its 500-step slices, so a block boundary is found at the same granularity as before; `playEagerly` and `for…of` ask for nothing and get one step per `next()`. The last step of a batch is handed over *before* its write, as the slow loop yields before writing.
- **A single step skips the table.** Playback pulls one step at a time, and a per-step re-check of the table made playback at Max 2.8× slower before this path existed. A batch of one asks `fires` and `App.accepts` directly, as the slow loop does.
- **Edits to a paused run are obeyed, and only the ones the slow loop obeys.** `singleTapeLookup` fixes *which* transition fires per state for the life of a run, so the table fills an entry once; but the slow loop reads that transition's `to`/`write`/`dir` and `App.accepts` every step, so before every multi-step batch the table compares its copies against the objects and the accepting marks and starts over if anything moved.
- **`pull()` returns the step `take` added, not `steps[length - 1]`**: the columnar `steps` is a Proxy, and those two traps were a third of a one-step pull. And a batch is told apart by `instanceof Batch`, not a Symbol key — asking a dozen shapes of step object for a key they lack showed up on Moore.

Measured on BB(5) to its halt (47,176,870 steps, Node 24, Ryzen 7 7435HS), against the slow loop: `traceMachine` **14.3 s → 1.43 s**; the drain's 500-step slices **13.6 s → 1.46 s**; with the space-time diagram built as well **15.5 s → 4.3 s** (the diagram's own index and overview, ~3 s, are unchanged). One-step pulls **0.29 → 0.25 µs**. Memory is unchanged at ~6 bytes a step — the same columns. `npm run bench -- player` shows the TM cases 84–90% faster and every other machine within its noise.

[tests/fast-tm.test.js](tests/fast-tm.test.js) holds it to the slow loop (`setFastLane(false)`, reset by the harness) on every field a reader can ask — state, transition, note, verdict, every journal row with its symbol codes, the checkpoints, and the tape read forwards, backwards and scattered — across BB(5), 160 random machines (wildcard reads and writes, missing transitions, stay-put moves, both tape models, loop detection on and off, explicit blanks in the input) pulled one step, 500 steps and odd sizes at a time, a run edited while paused, `simTM`, and a trailing explicit blank stepped back over one write at a time. Removing the undo exception, the re-check, or reading the transition live on resume each fails it.

### What a step remembers, when it is not a tape

**The same fact was true of every machine that is not a tape machine, and it was the largest single cost in the app.** [js/machines/step-log.js](js/machines/step-log.js) is the companion to `tape-log.js`, and the shape of the bug was identical: a step held a copy of an array that grows with the run, so a run was quadratic in the word.
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ js/machines/
what a checkpoint copies.
zipper-tape.js the NDTM search's tape: two interned stacks and the
cell under the head, so a fork is O(1).
fast-tm.js the rest of a TM run as a table and a loop, once the
loop detector has stopped looking — see the simulation
notes, *The fast lane*.
tm-behaviour.js whether a TM halts or never halts, and which method
proved it (simulation, cycler, translated cycler,
backward reasoning) — or "unknown". See the simulation notes.
Expand Down
48 changes: 48 additions & 0 deletions js/machines/columns.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ export class CodeColumn {
this.max = Type === Uint16Array ? 0xffff : 0xffffffff;
}

/**
* Append `src[0..count)` — what `count` pushes would leave, a block copy at
* a time, for a producer that fills a buffer of its own first
* (js/machines/fast-tm.js).
*/
pushMany(src, count) {
let top = 0;
for (let k = 0; k < count; k++) if (src[k] > top) top = src[k];
if (top > this.max) this.widen(top);
let k = 0;
while (k < count) {
const i = this.length;
const c = this.chunks[i >>> BITS] || newChunk(this.Type, this.chunks);
const n = Math.min(count - k, SIZE - (i & MASK));
c.set(src.subarray(k, k + n), i & MASK);
this.length += n;
k += n;
}
}

/** What the column holds, in bytes — for the tests and the benchmark. */
bytes() { return this.chunks.length * SIZE * this.Type.BYTES_PER_ELEMENT; }
}
Expand Down Expand Up @@ -120,6 +140,34 @@ export class HeadColumn {
this.last = v;
}

/** Append `src[0..count)`, as `count` pushes would — the same anchors, the
* same jumps — with the fields kept in locals rather than read per row. */
pushMany(src, count) {
let k = 0;
let last = this.last;
while (k < count) {
let i = this.length;
const dc = this.deltas[i >>> BITS] || newChunk(Int8Array, this.deltas);
const end = k + Math.min(count - k, SIZE - (i & MASK));
for (; k < end; k++, i++) {
const v = src[k];
if (i % SPAN === 0) {
const a = this.nAnchors++;
(this.anchors[a >>> BITS] || newChunk(Int32Array, this.anchors))[a & MASK] = v;
}
const d = i === 0 ? 0 : v - last;
if (d > JUMP && d <= 127) dc[i & MASK] = d;
else {
dc[i & MASK] = JUMP;
(this.jumps ??= new Map()).set(i, v);
}
last = v;
}
this.length = i;
}
this.last = last;
}

anchor(k) {
const a = k / SPAN;
return this.anchors[a >>> BITS][a & MASK];
Expand Down
Loading
Loading