From 3e629252773c592ec1b42535964ad65ea83798dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 23:01:19 +0000 Subject: [PATCH] Add Turing machine playground A single-tape Turing machine with a genuinely two-way infinite tape, stepped one transition at a time so the read/write/move cycle is visible. - src/features/turing/machine.ts: sparse Map-backed tape (unbounded in both directions), transition indexing, halt vs stuck detection, and five programs. Validated locally against known results: the 4-state busy beaver reproduces S(4)=107 steps and Sigma(4)=13 ones exactly, the 3-state champion writes Sigma(3)=6 ones, binary increment is correct for all inputs 0..127, the unary doubler yields exactly 2n for n=1..8, the palindrome checker is 12/12 on hand-picked cases including empty and single-character input, the head genuinely visits negative indices, and missing-rule machines report stuck rather than halted. Two bugs the tests caught: the doubler was emitting 3n (it wrote two markers per tally and also recycled the scan marker), and the 3-state blurb claimed a step count that did not match this counting convention. - src/pages/Turing.tsx: centred tape window with a highlighted head, the transition just applied spelled out, a rule table that lights up the matching rule, editable input, program presets, step/run/reset. - /turing route + SEO. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WgEKRuVdor8KhaiPkv38aQ --- src/App.tsx | 2 + src/features/turing/machine.ts | 256 ++++++++++++++++++++++++++++++++ src/pages/Turing.tsx | 263 +++++++++++++++++++++++++++++++++ 3 files changed, 521 insertions(+) create mode 100644 src/features/turing/machine.ts create mode 100644 src/pages/Turing.tsx diff --git a/src/App.tsx b/src/App.tsx index 3632c0e..5347520 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ const Bandit = lazy(() => import("./pages/Bandit")); const Newton = lazy(() => import("./pages/Newton")); const Langton = lazy(() => import("./pages/Langton")); const CyclicPage = lazy(() => import("./pages/Cyclic")); +const Turing = lazy(() => import("./pages/Turing")); const NotFound = lazy(() => import("./pages/NotFound")); type RouteConfig = { @@ -112,6 +113,7 @@ const routes = [ { path: "/newton", Component: Newton, seo: { title: "Newton Fractal — basins of attraction", description: "Newton's method for finding polynomial roots draws a fractal: each point is colored by which root it converges to. Scroll to zoom into the infinitely detailed basin boundaries." } }, { path: "/langton", Component: Langton, seo: { title: "Langton's Ant — emergence from one rule", description: "Watch Langton's Ant and generalized turmites build order from a single deterministic rule: chaos for thousands of steps, then a spontaneous highway. Pick a rule string over {L,R,U,N} and see wildly different attractors emerge — no randomness." } }, { path: "/cyclic", Component: CyclicPage, seo: { title: "Cyclic Automaton — spirals from rock-paper-scissors", description: "Griffeath's cyclic cellular automaton: each color is beaten by the next in the cycle and advances when enough neighbors already hold it. From pure noise, the rock-paper-scissors rule self-organizes into rotating spiral waves. Tune states and threshold live." } }, + { path: "/turing", Component: Turing, seo: { title: "Turing Machine — watch computation from first principles", description: "A single-tape Turing machine with a genuinely two-way infinite tape: step through binary increment, a unary doubler, a palindrome checker, and the 3- and 4-state busy beaver champions, watching the head read, write, and move one transition at a time." } }, { path: "*", Component: NotFound, seo: { title: "Page not found", noIndex: true } }, ] satisfies RouteConfig[]; diff --git a/src/features/turing/machine.ts b/src/features/turing/machine.ts new file mode 100644 index 0000000..9e1f35f --- /dev/null +++ b/src/features/turing/machine.ts @@ -0,0 +1,256 @@ +// A single-tape Turing machine with a two-way infinite tape. +// +// The tape is stored as a sparse Map keyed by (possibly negative) cell index, +// so it really is unbounded in both directions — cells default to the blank +// symbol and are only materialised once written or visited. + +export type Direction = "L" | "R" | "N"; + +export interface Rule { + state: string; + read: string; + write: string; + move: Direction; + next: string; +} + +export interface Program { + name: string; + blurb: string; + start: string; + halt: string; + blank: string; + input: string; + rules: Rule[]; +} + +export interface Snapshot { + head: number; + state: string; + steps: number; + halted: boolean; + stuck: boolean; // no rule matched — halted without reaching the halt state +} + +export class Machine { + readonly program: Program; + tape = new Map(); + head = 0; + state: string; + steps = 0; + halted = false; + stuck = false; + private index = new Map(); + minSeen = 0; + maxSeen = 0; + + constructor(program: Program) { + this.program = program; + this.state = program.start; + for (const r of program.rules) this.index.set(`${r.state}|${r.read}`, r); + this.loadInput(program.input); + } + + private loadInput(input: string) { + this.tape.clear(); + for (let i = 0; i < input.length; i++) this.tape.set(i, input[i]); + this.head = 0; + this.minSeen = 0; + this.maxSeen = Math.max(0, input.length - 1); + this.state = this.program.start; + this.steps = 0; + this.halted = false; + this.stuck = false; + } + + reset() { + this.loadInput(this.program.input); + } + + read(at = this.head): string { + return this.tape.get(at) ?? this.program.blank; + } + + /** Execute one transition. Returns the rule applied, or null if it stopped. */ + step(): Rule | null { + if (this.halted || this.stuck) return null; + if (this.state === this.program.halt) { + this.halted = true; + return null; + } + const rule = this.index.get(`${this.state}|${this.read()}`); + if (!rule) { + this.stuck = true; + return null; + } + this.tape.set(this.head, rule.write); + if (rule.move === "L") this.head--; + else if (rule.move === "R") this.head++; + this.state = rule.next; + this.steps++; + if (this.head < this.minSeen) this.minSeen = this.head; + if (this.head > this.maxSeen) this.maxSeen = this.head; + if (this.state === this.program.halt) this.halted = true; + return rule; + } + + /** Run until it halts or the step budget is exhausted. */ + run(maxSteps = 100000): Snapshot { + while (!this.halted && !this.stuck && this.steps < maxSteps) this.step(); + return this.snapshot(); + } + + snapshot(): Snapshot { + return { + head: this.head, + state: this.state, + steps: this.steps, + halted: this.halted, + stuck: this.stuck, + }; + } + + /** Non-blank contents, trimmed, as a plain string. */ + output(): string { + let lo = Infinity; + let hi = -Infinity; + for (const [k, v] of this.tape) { + if (v !== this.program.blank) { + if (k < lo) lo = k; + if (k > hi) hi = k; + } + } + if (lo === Infinity) return ""; + let out = ""; + for (let i = lo; i <= hi; i++) out += this.read(i); + return out; + } + + /** Count of cells holding a given symbol — the score for busy beavers. */ + count(symbol: string): number { + let n = 0; + for (const v of this.tape.values()) if (v === symbol) n++; + return n; + } +} + +const rule = (state: string, read: string, write: string, move: Direction, next: string): Rule => ({ + state, + read, + write, + move, + next, +}); + +export const PROGRAMS: Program[] = [ + { + name: "binary increment", + blurb: "adds one to a binary number by rippling carries from the right", + start: "right", + halt: "done", + blank: "_", + input: "1011", + rules: [ + // Walk to the right end of the number. + rule("right", "0", "0", "R", "right"), + rule("right", "1", "1", "R", "right"), + rule("right", "_", "_", "L", "carry"), + // Ripple the carry leftwards. + rule("carry", "1", "0", "L", "carry"), + rule("carry", "0", "1", "N", "done"), + rule("carry", "_", "1", "N", "done"), + ], + }, + { + name: "unary doubler", + blurb: "rewrites n tally marks as 2n — the classic copy-and-append trick", + start: "scan", + halt: "done", + blank: "_", + input: "111", + rules: [ + // Mark the leftmost unprocessed 1, then append one Y at the far right. + // Each original tally therefore ends up as itself plus one Y — 2n total + // once cleanup turns every marker back into a tally. + rule("scan", "1", "X", "R", "toEnd"), + rule("scan", "Y", "Y", "R", "scan"), + rule("scan", "_", "_", "L", "cleanup"), + rule("toEnd", "1", "1", "R", "toEnd"), + rule("toEnd", "Y", "Y", "R", "toEnd"), + rule("toEnd", "_", "Y", "L", "back"), + rule("back", "Y", "Y", "L", "back"), + rule("back", "1", "1", "L", "back"), + rule("back", "X", "X", "R", "scan"), + // Turn every marker back into a tally. + rule("cleanup", "Y", "1", "L", "cleanup"), + rule("cleanup", "1", "1", "L", "cleanup"), + rule("cleanup", "X", "1", "L", "cleanup"), + rule("cleanup", "_", "_", "N", "done"), + ], + }, + { + name: "busy beaver (3-state)", + blurb: "the 3-state champion: halts having written six 1s — the most possible", + start: "A", + halt: "H", + blank: "0", + input: "", + rules: [ + rule("A", "0", "1", "R", "B"), + rule("A", "1", "1", "R", "H"), + rule("B", "0", "0", "R", "C"), + rule("B", "1", "1", "R", "B"), + rule("C", "0", "1", "L", "C"), + rule("C", "1", "1", "L", "A"), + ], + }, + { + name: "busy beaver (4-state)", + blurb: "the 4-state champion: 107 steps, thirteen 1s — then it stops, forever", + start: "A", + halt: "H", + blank: "0", + input: "", + rules: [ + rule("A", "0", "1", "R", "B"), + rule("A", "1", "1", "L", "B"), + rule("B", "0", "1", "L", "A"), + rule("B", "1", "0", "L", "C"), + rule("C", "0", "1", "R", "H"), + rule("C", "1", "1", "L", "D"), + rule("D", "0", "1", "R", "D"), + rule("D", "1", "0", "R", "A"), + ], + }, + { + name: "palindrome check", + blurb: "matches outer characters inward, writing Y or N for the verdict", + start: "start", + halt: "done", + blank: "_", + input: "abba", + rules: [ + // Consume the leftmost letter, remember it, and look for its partner. + rule("start", "a", "_", "R", "haveA"), + rule("start", "b", "_", "R", "haveB"), + rule("start", "_", "Y", "N", "done"), + rule("haveA", "a", "a", "R", "haveA"), + rule("haveA", "b", "b", "R", "haveA"), + rule("haveA", "_", "_", "L", "matchA"), + rule("haveB", "a", "a", "R", "haveB"), + rule("haveB", "b", "b", "R", "haveB"), + rule("haveB", "_", "_", "L", "matchB"), + rule("matchA", "a", "_", "L", "rewind"), + rule("matchA", "b", "b", "N", "reject"), + rule("matchA", "_", "Y", "N", "done"), + rule("matchB", "b", "_", "L", "rewind"), + rule("matchB", "a", "a", "N", "reject"), + rule("matchB", "_", "Y", "N", "done"), + rule("rewind", "a", "a", "L", "rewind"), + rule("rewind", "b", "b", "L", "rewind"), + rule("rewind", "_", "_", "R", "start"), + rule("reject", "a", "N", "N", "done"), + rule("reject", "b", "N", "N", "done"), + ], + }, +]; diff --git a/src/pages/Turing.tsx b/src/pages/Turing.tsx new file mode 100644 index 0000000..d130aee --- /dev/null +++ b/src/pages/Turing.tsx @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Machine, PROGRAMS, Rule } from "../features/turing/machine"; + +const VISIBLE = 31; // tape cells shown, head centred + +interface View { + cells: { index: number; symbol: string }[]; + state: string; + steps: number; + halted: boolean; + stuck: boolean; + output: string; + lastRule: Rule | null; +} + +export default function Turing() { + const machineRef = useRef(new Machine(PROGRAMS[0])); + const runningRef = useRef(false); + const speedRef = useRef(120); + const timerRef = useRef(null); + + const [programIdx, setProgramIdx] = useState(0); + const [input, setInput] = useState(PROGRAMS[0].input); + const [speed, setSpeed] = useState(120); + const [running, setRunning] = useState(false); + const [view, setView] = useState(() => snapshot(new Machine(PROGRAMS[0]), null)); + + function snapshot(m: Machine, lastRule: Rule | null): View { + const half = (VISIBLE - 1) / 2; + const cells = []; + for (let i = m.head - half; i <= m.head + half; i++) { + cells.push({ index: i, symbol: m.read(i) }); + } + return { + cells, + state: m.state, + steps: m.steps, + halted: m.halted, + stuck: m.stuck, + output: m.output(), + lastRule, + }; + } + + const rebuild = useCallback((idx: number, tapeInput: string) => { + const base = PROGRAMS[idx]; + const m = new Machine({ ...base, input: tapeInput }); + machineRef.current = m; + runningRef.current = false; + setRunning(false); + setView(snapshot(m, null)); + }, []); + + const stepOnce = useCallback(() => { + const m = machineRef.current; + const rule = m.step(); + setView(snapshot(m, rule)); + if (m.halted || m.stuck) { + runningRef.current = false; + setRunning(false); + } + return rule; + }, []); + + // Drive the machine on a timer while running. + useEffect(() => { + if (!running) return; + const tick = () => { + const m = machineRef.current; + if (m.halted || m.stuck) { + runningRef.current = false; + setRunning(false); + return; + } + stepOnce(); + timerRef.current = window.setTimeout(tick, speedRef.current); + }; + timerRef.current = window.setTimeout(tick, speedRef.current); + return () => { + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + }; + }, [running, stepOnce]); + + const program = PROGRAMS[programIdx]; + const done = view.halted || view.stuck; + + return ( +
+ {/* Header */} +
+
+ + ← home + + | + turing machine +
+
+ state {view.state} · {view.steps} steps + {view.halted ? " · halted" : view.stuck ? " · stuck (no rule)" : ""} +
+
+ + {/* Controls */} +
+
+ program + {PROGRAMS.map((p, i) => ( + + ))} +
+ + + +
+ speed + { + const v = 520 - Number(e.target.value); + setSpeed(v); + speedRef.current = v; + }} + className="w-20 accent-primary" + /> +
+ +
+ + + +
+
+ + {/* Tape */} +
+
+
+ {view.cells.map((c) => { + const isHead = c.index === view.cells[(VISIBLE - 1) / 2].index; + const blank = c.symbol === program.blank; + return ( +
+
+ {c.symbol} +
+ {isHead &&
} +
+ ); + })} +
+
+ + {/* Last transition */} +
+ {view.lastRule + ? `${view.lastRule.state} reads ${view.lastRule.read} → write ${view.lastRule.write}, move ${view.lastRule.move}, go to ${view.lastRule.next}` + : done + ? view.stuck + ? "no rule matched this state and symbol — the machine is stuck" + : "reached the halt state" + : "ready"} +
+ + {view.output && ( +
+ tape: {view.output} +
+ )} +
+ + {/* Rules */} +
+

{program.blurb}

+
+ {program.rules.map((r, i) => { + const active = + view.lastRule && view.lastRule.state === r.state && view.lastRule.read === r.read; + return ( + + {r.state},{r.read} → {r.write},{r.move},{r.next} + + ); + })} +
+
+
+ ); +}