diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b08f2b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository layout + +Three independent surfaces of the same Turing machine concept: + +- `TurMac_1.1.py` — original single-file Python reference implementation by David Hartkop. Runs from a CLI: `python3 TurMac_1.1.py`. The control-memory tuple format and 4-element rule semantics `(read, write, dir, next_state)` defined here are the canonical model that the other surfaces mirror. +- `web-app/` — browser-based simulator with two views (`public/machine.html`, `public/cards.html`) sharing engine code under `src/core/`. +- `card-game/` — a 0-indexed variant of the cards view; its `index.html` imports modules directly from `../web-app/src/` and only adds the markup/style differences listed in `card-game/README.md`. + +## Running the web app + +ES modules are used everywhere, so `file://` URLs do not work. Serve the repo root over HTTP and open the pages from there: + +``` +python3 -m http.server 8000 +# then open: +# http://localhost:8000/web-app/public/index.html (landing) +# http://localhost:8000/web-app/public/machine.html (engineer view) +# http://localhost:8000/web-app/public/cards.html (card-game view) +# http://localhost:8000/card-game/index.html (0-indexed variant) +``` + +There is no build step, no package.json, no test runner. Edits to `.js`/`.html`/`.css` are picked up on browser reload. + +## Engine architecture (`web-app/src/core/turing.js`) + +`TuringMachine` is the single source of execution truth shared by both views. Important details that are not obvious from the file structure: + +- **Rule shape**: `[read, write, dir, nextState]` where `dir` is `0` (left) or `1` (right). A state whose rule list is empty is a halt state. These match the Python tuple format exactly. +- **State indices**: the engine itself is 0-indexed. The Python file and the machine view also surface 0-indexed states; the cards view (`src/cards/main.js`) displays 1-indexed states to match the physical card game, and the card-program format (`src/cards/compiler.js`) uses 1-indexed next-state references that get translated to 0-indexed when compiled. +- **`boundsCheck` modes**: the cards view passes `'before'` (check head bounds at the top of `step()`, so a rule that moves the head off-tape into a halt state halts cleanly). The machine view passes `'after'` (apply the rule first, then report the post-step head). Both behaviors are intentional — preserve them when modifying `step()`. +- **`step()` return discriminator**: returns `{ kind: ... }` with one of `'already-halted' | 'cycle-limit' | 'halt' | 'no-rule' | 'oob' | 'oob-after' | 'step'`. View code switches on `kind` to log; add new cases here, do not throw. +- **`reset()` is destructive**: it re-clones `initialTape`/`initialControl`. Anything constructed against the live `engine.tape` / `engine.control` will become stale after a reset — re-read from the engine. + +## Shared core utilities + +- `core/runner.js` — `setInterval`-driven autoplay loop. The interval is read fresh each tick via `getInterval()`, but changing speed mid-run still requires calling `runner.restart()` to apply (both views wire this up on the speed slider's `input` event). +- `core/logger.js` — appends `
` rows to a container; optional badge element counts lines. +- `core/dom.js` — `$(id)` shortcut plus a small `el(tag, opts)` builder. + +## Cards view specifics (`web-app/src/cards/`) + +- **Card format**: integers 0–7 only. Front face shows 0/1/2/3 (T/R/B/L); back face shows 5/6/7/4. `valueToFaceAndRotation` in `svg.js` is the source of truth. Boundary values that were `8` in the Python (`TurMac_1.1.py`) are substituted with `7` in presets because cards top out at 7. +- **Compiled program format** (`compiler.js`): a flat comma/whitespace-separated integer sequence: `0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ...`. Use `compileProgramLenient` for user input — it auto-fixes truncation, out-of-range values, and missing markers, and reports each fix back through the logger. `compileProgram` (strict) is kept as a reference but is not wired into the UI. +- **Click-to-edit**: every editable card has `data-role` plus `data-*` indices. `ROLE_HANDLERS` in `cards/main.js` is the dispatch table — add new editable cards by adding a role handler and emitting `makeCard(..., { role, data })`. Clicks rotate forward; Shift+click rotates backward. +- **Edits rebuild the engine**: any role-handler edit calls `buildEngine()` and `syncEditorFromState()`. The textarea is the serialized form, not the source of truth — the in-memory `view.states` is. + +## Card-game variant (`card-game/`) + +`card-game/index.html` is a single file that imports the cards modules directly from `../web-app/src/cards/` and re-implements just the rendering pieces that need 0-indexed semantics (state rows, head position, per-tape-cell index labels) and a single combined "#states" card. When changing shared cards code, verify this page still works because it depends on the same exports (`makeCard`, `compileProgramLenient`, `serializeStates`, `PRESETS`). The differences from `cards.html` are spelled out in `card-game/README.md`. + +## When changing rule/program semantics + +If you touch the rule tuple shape, the halt convention, or the bounds-check behavior, update **all four** places that encode it: `TurMac_1.1.py`, `web-app/src/core/turing.js`, the cards compiler (`web-app/src/cards/compiler.js`), and the card-game variant in `card-game/index.html`. The README.md at the repo root documents the original semantics and is worth updating too if behavior changes. diff --git a/card-game/README.md b/card-game/README.md new file mode 100644 index 0000000..58a69ca --- /dev/null +++ b/card-game/README.md @@ -0,0 +1,29 @@ +# Turing Machine Card Game (./index.html) + +A variant of `../web-app/public/cards.html` with a simplified, fully 0-indexed model where the tape value at the playhead directly selects the sub-state to run. + +## Differences from `cards.html` + +1. **States and sub-states are 0-indexed by position.** A state is identified by its row index; a sub-state is identified by its column index within that row. +2. **Tape value == sub-state index.** When the playhead reads value `k` from the tape, sub-state `k` of the current state runs. If the current state has fewer than `k+1` sub-states, the machine halts. +3. **Halt by out-of-range next-state.** There is no dedicated "last state is halt" convention. A `next` reference to any state index that does not exist halts the machine. A state with zero sub-states also halts when entered. +4. **No `#states` card.** Replaced with a `+ Add state` button beneath the state rows. +5. **No leading rule-count card on each state row.** Each row ends with a `+ sub-state` button to append a new sub-state. +6. **No leading `read` card on each sub-state.** The read value is implied by the sub-state's column position. Each sub-state ends with a `×` button to delete it. +7. **Direction is an arrow card.** A custom card showing a left or right arrow, sharing the same `.slot` frame as the colored-dot cards. Clicking toggles direction. +8. **No playhead-position card.** Each tape cell has a `set start` / `★ start` button below it that picks that cell as the playhead's starting position. +9. **Program text format is new.** A flat integer sequence, with no alignment card and no state-marker numbers: + ``` + ruleCount, [write, dir, next] × ruleCount (state 0) + ruleCount, [write, dir, next] × ruleCount (state 1) + ... + ``` + `dir`: 0 = L, 1 = R. State and `next` references are 0-indexed. + +## Implementation notes + +The page imports the shared engine and card renderer from `../web-app/src/` (`core/turing.js`, `core/runner.js`, `core/logger.js`, `core/dom.js`, `cards/svg.js`) but defines its own compiler, serializer, and preset programs inline — nothing in `web-app/` is modified. + +When building the engine, each view rule `[write, dir, next]` at column `r` is expanded to the engine's `[read, write, dir, next]` shape with `read = r`, so the shared `TuringMachine` keeps its rule-by-read lookup unchanged. The engine's existing behavior of returning `{ kind: 'halt' }` when `control[currentState]` is undefined gives "missing state halts" for free. + +`boundsCheck: 'before'` is used so a sub-state that moves the head off the tape into a missing state still halts cleanly on the next step. diff --git a/card-game/index.html b/card-game/index.html new file mode 100644 index 0000000..ead8b11 --- /dev/null +++ b/card-game/index.html @@ -0,0 +1,728 @@ + + + + + +Turing Machine Card Game + + + + + + +
+

Turing Machine Card Game

+

Card-game variant of the Turing Cards simulator. States and sub-states are indexed by their row and column position (0-based). The tape value at the playhead selects which sub-state of the current state runs. A next reference to a state index that does not exist halts the machine. + Click any card to rotate it forward; Shift+click to rotate backward.

+
+ +
+
+
State0
+
Cycle0
+
Playhead0
+
Read
+
StatusReady
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+ + + + + +
+ + + 500 ms +
+
+ +
+

Program Editor

+ + +
+
+ + +
+
+ + +
+
+ States are concatenated and indexed 0-based by row. The sub-state at index k within a state runs when the playhead reads value k. dir: 0=L, 1=R. A next reference to a state that does not exist halts the machine. The playhead's starting position is set via the button under each tape cell. +
+ + +
+
+
+
+ + + + diff --git a/web-app/.DS_Store b/web-app/.DS_Store new file mode 100644 index 0000000..175ed52 Binary files /dev/null and b/web-app/.DS_Store differ diff --git a/web-app/public/cards.html b/web-app/public/cards.html new file mode 100644 index 0000000..0665af1 --- /dev/null +++ b/web-app/public/cards.html @@ -0,0 +1,89 @@ + + + + + +Turing Cards Simulator + + + + + +
+

Turing Cards Simulator

+

Card-game-style Turing machine — based on David Hartkop's Turing-Complete-Cards. + Each card has 4 colored dots (0–3 on the front face; 4–7 on the back). Rotating the card brings a different number up. The compiled program reads top-to-bottom, with the playhead card bumped up over the tape. + Click any card to rotate it forward; Shift+click to rotate backward. Editing automatically re-validates the program and fixes stale references. ← Back to index

+
+ +
+
+
State1
+
Cycle0
+
Playhead1
+
Read
+
StatusReady
+
+ +
+
+ +
+ align / #states +
+
+
+
+
+ +
+
+
+ +
+ + + + + +
+ + + 500 ms +
+
+ +
+

Program Editor

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Format: 0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ... + The halt state has rule count 0. dir: 0=L, 1=R. Next-state references are 1-indexed (state #4 = halt in a 4-state program). +
+ + +
+
+
+
+ + + + diff --git a/web-app/public/css/cards.css b/web-app/public/css/cards.css new file mode 100644 index 0000000..af5a217 --- /dev/null +++ b/web-app/public/css/cards.css @@ -0,0 +1,394 @@ +:root { + --table-light: #6b4220; + --table-mid: #4a2c10; + --table-dark: #2c1a08; + --card-bg: #fafaf5; + --card-stroke: #b0aaa0; + --c0: #1a1a1a; --c1: #d83a2c; --c2: #f3c422; --c3: #1f6cd6; + --c4: #6e3aa4; --c5: #e87a1c; --c6: #1e9a52; --c7: #ec6ea9; + --accent: #fbbf24; + --hot: #f97316; + --text: #f5f1ea; + --muted: #b4a896; + --panel: rgba(0, 0, 0, 0.42); + --border: rgba(255, 255, 255, 0.10); +} + +html, body { + background: + radial-gradient(ellipse 1200px 600px at 50% 0%, rgba(255,200,140,0.10), transparent 70%), + linear-gradient(135deg, var(--table-light), var(--table-mid) 55%, var(--table-dark)); + background-attachment: fixed; + color: var(--text); +} + +header { + padding: 22px 28px 16px; + border-bottom: 1px solid var(--border); + background: rgba(0,0,0,0.32); +} +h1 { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.01em; +} +header p { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; + max-width: 720px; +} +header a { + color: var(--accent); + text-decoration: none; +} +header a:hover { text-decoration: underline; } + +main { + max-width: 1500px; + margin: 0 auto; + padding: 20px 24px 48px; + display: flex; + flex-direction: column; + gap: 18px; +} + +/* Status bar */ +.status-bar { + display: flex; + gap: 28px; + flex-wrap: wrap; + padding: 14px 18px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + align-items: center; +} +.status-item { display: flex; flex-direction: column; } +.status-item .lbl { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--muted); +} +.status-item .val { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 20px; + color: var(--accent); + margin-top: 2px; + min-width: 28px; +} +.status-item .val.halted { color: #86efac; } +.status-item .val.running { color: #fde047; } +.status-item .val.error { color: #fca5a5; } + +/* Board */ +.board { + background: + radial-gradient(ellipse 100% 80% at 50% 50%, rgba(255,255,255,0.04), transparent 70%), + repeating-linear-gradient(92deg, + rgba(255,220,180,0.04) 0 2px, + transparent 2px 6px, + rgba(255,220,180,0.06) 6px 8px, + transparent 8px 14px), + linear-gradient(135deg, #7a4a24, #4a2c10 50%, #2c1a08); + border: 2px solid rgba(0,0,0,0.4); + border-radius: 14px; + padding: 22px 24px 26px; + box-shadow: + inset 0 0 80px rgba(0,0,0,0.55), + inset 0 0 0 1px rgba(255,255,255,0.05), + 0 8px 28px rgba(0,0,0,0.4); + overflow-x: auto; +} +.section-label { + font-family: 'Georgia', 'Times New Roman', serif; + font-style: italic; + font-size: 13px; + color: rgba(255, 240, 220, 0.7); + margin-bottom: 8px; + padding-left: 4px; +} +.program-section { margin-bottom: 20px; } + +/* Rows */ +.prog-row { + display: flex; + gap: 4px; + align-items: center; + padding: 4px 8px; + border-radius: 8px; + margin-bottom: 4px; + transition: background 0.25s, box-shadow 0.25s; + min-width: max-content; +} +.prog-row.active { + background: rgba(251, 191, 36, 0.16); + box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.55); +} +.prog-row.halt-row { opacity: 0.55; } +.prog-row.header-row { margin-bottom: 8px; } + +.row-label { + font-family: 'Georgia', serif; + font-style: italic; + font-size: 11px; + color: rgba(255, 240, 220, 0.55); + width: 96px; + flex: 0 0 96px; + padding-right: 6px; + text-align: right; +} +.prog-row.active .row-label { color: var(--accent); font-weight: 600; } + +.card-cluster { + display: flex; + gap: 3px; + padding: 4px; + border-radius: 6px; + transition: background 0.2s, box-shadow 0.2s; +} +.card-cluster.state-head { background: rgba(255,255,255,0.05); } +.rule-cluster.matched { + background: rgba(251, 191, 36, 0.32); + box-shadow: 0 0 0 2px var(--accent), 0 0 12px rgba(251, 191, 36, 0.4); +} + +/* Tape */ +.tape-section { margin-top: 14px; } +.tape { + display: flex; + gap: 3px; + padding: 28px 10px 12px; + background: + linear-gradient(180deg, rgba(0,0,0,0.18), rgba(0,0,0,0.05)), + rgba(0,0,0,0.18); + border-radius: 8px; + position: relative; + align-items: flex-end; + min-width: max-content; +} +.tape::before { + content: 'TAPE'; + position: absolute; + top: 8px; left: 14px; + font-family: 'Georgia', serif; + font-style: italic; + font-size: 11px; + color: rgba(255, 240, 220, 0.4); + letter-spacing: 0.1em; +} + +/* Playhead card row */ +.head-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px 6px 102px; + margin-top: 8px; +} +.head-row .lbl { + font-family: 'Georgia', serif; + font-style: italic; + font-size: 11px; + color: rgba(255, 240, 220, 0.55); +} + +/* Cards */ +.slot { + flex: 0 0 auto; + width: 56px; + height: 56px; + position: relative; + transition: transform 0.3s ease; + will-change: transform; +} +.slot.small { width: 44px; height: 44px; } +.slot.bumped { transform: translateY(-14px); } +.rotor { + width: 100%; + height: 100%; + transition: transform 0.35s cubic-bezier(0.5, 0.0, 0.3, 1.2); + will-change: transform; +} +.rotor svg { + display: block; + width: 100%; + height: 100%; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45)); +} +.slot.bumped .rotor svg { + filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55)); +} +.slot.read-cell .rotor svg { + filter: drop-shadow(0 5px 7px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 6px rgba(251, 191, 36, 0.8)); +} +.slot.clickable { cursor: pointer; } +.slot.clickable:hover { + outline: 2px solid rgba(255, 230, 180, 0.7); + outline-offset: 3px; + border-radius: 11px; +} +.slot.clickable:hover .rotor svg { + filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.5)) brightness(1.05); +} +.slot.clickable.bumped:hover .rotor svg { + filter: drop-shadow(0 6px 9px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 6px rgba(251, 191, 36, 0.8)) brightness(1.05); +} +.tape-bump-indicator { + position: absolute; + bottom: -22px; + left: 50%; + transform: translateX(-50%); + color: var(--accent); + font-size: 10px; + letter-spacing: 0.06em; + font-family: ui-monospace, monospace; + text-shadow: 0 1px 2px rgba(0,0,0,0.6); +} + +/* Controls */ +.controls { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + padding: 14px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; +} +button { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 18px; + font-size: 14px; + cursor: pointer; + font-weight: 500; + transition: background 0.15s, border-color 0.15s, transform 0.05s; +} +button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} +button:active:not(:disabled) { transform: translateY(1px); } +button:disabled { opacity: 0.4; cursor: not-allowed; } +button.primary { + background: var(--accent); + color: #1a1a1a; + border-color: var(--accent); +} +button.primary:hover:not(:disabled) { background: #f59e0b; } +button.danger { color: #fca5a5; border-color: rgba(252, 165, 165, 0.4); } +button.danger:hover:not(:disabled) { background: rgba(252, 165, 165, 0.1); } + +.speed-control { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} +.speed-control label { font-size: 12px; color: var(--muted); } +.speed-control input[type="range"] { width: 140px; accent-color: var(--accent); } +.speed-control span { + font-family: ui-monospace, monospace; + font-size: 12px; + color: var(--accent); + min-width: 60px; + text-align: right; +} + +/* Presets */ +select { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 12px; + font-size: 13px; + cursor: pointer; +} + +/* Editor */ +.editor { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 18px; +} +.editor h2 { + margin: 0 0 14px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.10em; + color: var(--muted); + font-weight: 600; +} +.editor label { + display: block; + font-size: 12px; + color: var(--muted); + margin: 10px 0 4px; +} +.editor textarea, +.editor input[type="text"], +.editor input[type="number"] { + width: 100%; + background: rgba(0, 0, 0, 0.32); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-family: ui-monospace, monospace; + font-size: 12px; +} +.editor textarea { + min-height: 110px; + resize: vertical; + line-height: 1.5; +} +.editor-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + align-items: end; +} +.editor small { + display: block; + margin-top: 10px; + color: var(--muted); + font-size: 11px; + line-height: 1.6; +} +.editor-buttons { + display: flex; + gap: 8px; + margin-top: 12px; +} +.editor-buttons button { flex: 1; } + +.log { + margin-top: 14px; + font-family: ui-monospace, monospace; + font-size: 11px; + background: rgba(0, 0, 0, 0.4); + border-radius: 6px; + border: 1px solid var(--border); + padding: 10px 12px; + max-height: 180px; + overflow-y: auto; + line-height: 1.5; + white-space: pre; +} +.log-line { color: var(--muted); } +.log-line.halt { color: var(--accent); font-weight: 700; } +.log-line.err { color: #fca5a5; } + +@media (max-width: 720px) { + .editor-grid { grid-template-columns: 1fr; } + .row-label { width: 64px; flex: 0 0 64px; font-size: 10px; } + .head-row { padding-left: 70px; } +} diff --git a/web-app/public/css/common.css b/web-app/public/css/common.css new file mode 100644 index 0000000..071b423 --- /dev/null +++ b/web-app/public/css/common.css @@ -0,0 +1,8 @@ +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; +} diff --git a/web-app/public/css/index.css b/web-app/public/css/index.css new file mode 100644 index 0000000..539753b --- /dev/null +++ b/web-app/public/css/index.css @@ -0,0 +1,202 @@ +:root { + --bg-1: #0f172a; + --bg-2: #1e293b; + --panel: rgba(30, 41, 59, 0.7); + --panel-hover: rgba(51, 65, 85, 0.85); + --border: rgba(148, 163, 184, 0.18); + --border-hover: rgba(56, 189, 248, 0.6); + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #38bdf8; + --accent-2: #fbbf24; + --link: #7dd3fc; +} + +html, body { + background: + radial-gradient(ellipse 1100px 600px at 50% -10%, rgba(56, 189, 248, 0.12), transparent 70%), + radial-gradient(ellipse 900px 500px at 90% 100%, rgba(251, 191, 36, 0.07), transparent 70%), + linear-gradient(135deg, var(--bg-1), var(--bg-2)); + background-attachment: fixed; + color: var(--text); +} + +.wrap { + max-width: 980px; + margin: 0 auto; + padding: 80px 28px 64px; +} + +.eyebrow { + display: inline-block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--accent); + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.25); + padding: 5px 10px; + border-radius: 999px; + margin-bottom: 18px; +} + +h1 { + font-size: clamp(32px, 5vw, 48px); + margin: 0 0 12px; + letter-spacing: -0.02em; + line-height: 1.05; + font-weight: 700; +} + +.lede { + font-size: 17px; + line-height: 1.65; + color: var(--muted); + max-width: 720px; + margin: 0 0 48px; +} +.lede a { color: var(--link); text-decoration: none; } +.lede a:hover { text-decoration: underline; } + +.sims { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 18px; + margin-bottom: 56px; +} + +.sim { + display: flex; + flex-direction: column; + gap: 14px; + padding: 26px 24px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + text-decoration: none; + color: inherit; + transition: background 0.18s, border-color 0.18s, transform 0.18s; + position: relative; + overflow: hidden; +} +.sim:hover { + background: var(--panel-hover); + border-color: var(--border-hover); + transform: translateY(-2px); +} + +.sim-thumb { + height: 140px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; +} +.sim-thumb.machine { + background: + radial-gradient(ellipse at 30% 30%, rgba(244, 114, 182, 0.18), transparent 60%), + linear-gradient(135deg, #0f172a, #273449); + border: 1px solid #334155; +} +.sim-thumb.cards { + background: + radial-gradient(ellipse 100% 80% at 50% 50%, rgba(255,255,255,0.05), transparent 70%), + linear-gradient(135deg, #7a4a24, #4a2c10 60%, #2c1a08); + border: 1px solid rgba(0,0,0,0.4); + box-shadow: inset 0 0 40px rgba(0,0,0,0.45); +} + +.thumb-tape { + display: flex; + gap: 5px; +} +.thumb-cell { + width: 22px; height: 22px; + border-radius: 4px; + border: 1px solid #334155; + background: #273449; + display: flex; align-items: center; justify-content: center; + font-family: ui-monospace, monospace; + font-size: 12px; + color: #94a3b8; +} +.thumb-cell.b { color: #38bdf8; border-color: #38bdf8; background: rgba(56,189,248,0.08); } +.thumb-cell.h { + color: #f472b6; border-color: #f472b6; + background: rgba(244,114,182,0.1); + box-shadow: 0 0 0 2px rgba(244,114,182,0.2); +} + +.thumb-cards { + display: flex; gap: 6px; +} +.thumb-card { + width: 30px; height: 30px; + border-radius: 5px; + background: #fafaf5; + border: 1px solid #b0aaa0; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + padding: 3px; + gap: 2px; + box-shadow: 0 2px 4px rgba(0,0,0,0.45); +} +.thumb-card span { + border-radius: 50%; + align-self: center; + justify-self: center; + width: 8px; height: 8px; +} + +.sim-name { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.01em; + display: flex; + align-items: center; + justify-content: space-between; +} +.sim-name .arrow { + color: var(--accent); + font-size: 14px; + transition: transform 0.18s; +} +.sim:hover .sim-name .arrow { transform: translateX(3px); } + +.sim-desc { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.55; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: auto; +} +.tag { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 3px 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + color: var(--muted); + border: 1px solid rgba(148, 163, 184, 0.18); + font-family: ui-monospace, monospace; +} + +footer { + border-top: 1px solid var(--border); + padding-top: 28px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +footer a { color: var(--link); text-decoration: none; } +footer a:hover { text-decoration: underline; } diff --git a/web-app/public/css/machine.css b/web-app/public/css/machine.css new file mode 100644 index 0000000..f4684d0 --- /dev/null +++ b/web-app/public/css/machine.css @@ -0,0 +1,322 @@ +:root { + --bg: #0f172a; + --panel: #1e293b; + --panel-2: #273449; + --border: #334155; + --text: #e2e8f0; + --muted: #94a3b8; + --accent: #38bdf8; + --accent-2: #818cf8; + --success: #4ade80; + --warn: #fbbf24; + --danger: #f87171; + --head: #f472b6; +} + +html, body { + background: var(--bg); + color: var(--text); +} + +header { + padding: 28px 32px 16px; + border-bottom: 1px solid var(--border); +} +h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + letter-spacing: -0.01em; +} +header p { + margin: 6px 0 0; + color: var(--muted); + font-size: 14px; +} +header a { + color: var(--accent); + text-decoration: none; +} +header a:hover { text-decoration: underline; } + +main { + padding: 24px 32px 48px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: 20px; + max-width: 1400px; + margin: 0 auto; +} + +.card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.card-title { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.card-badge { + font-size: 11px; + padding: 3px 8px; + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + border: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.card.full { grid-column: 1 / -1; } + +/* Tape */ +.tape { + display: flex; + overflow-x: auto; + gap: 6px; + padding: 8px 4px 16px; + position: relative; +} +.cell { + flex: 0 0 auto; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-2); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 16px; + position: relative; +} +.cell.head { + border-color: var(--head); + box-shadow: 0 0 0 2px rgba(244, 114, 182, 0.25); + color: var(--head); + font-weight: 700; +} +.cell.head::after { + content: "▲"; + position: absolute; + bottom: -20px; + left: 50%; + transform: translateX(-50%); + color: var(--head); + font-size: 12px; +} +.cell.boundary { + background: rgba(56, 189, 248, 0.12); + border-color: var(--accent); + color: var(--accent); +} + +/* Stats */ +.stats { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} +.stat { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; +} +.stat-label { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.stat-value { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 20px; + margin-top: 4px; + color: var(--text); +} +.stat-value.halted { color: var(--success); } +.stat-value.running { color: var(--accent); } + +/* Buttons */ +.controls { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; +} +button { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 14px; + font-size: 14px; + cursor: pointer; + font-weight: 500; + transition: background 0.15s, border-color 0.15s; +} +button:hover:not(:disabled) { + background: #334155; + border-color: var(--accent); +} +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +button.primary { + background: var(--accent); + color: #0f172a; + border-color: var(--accent); +} +button.primary:hover:not(:disabled) { + background: #7dd3fc; +} +button.danger { + background: transparent; + color: var(--danger); + border-color: var(--danger); +} +.speed { + display: flex; + align-items: center; + gap: 10px; + grid-column: 1 / -1; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; +} +.speed label { + font-size: 12px; + color: var(--muted); +} +.speed input { flex: 1; } +.speed-value { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--accent); + min-width: 60px; + text-align: right; +} + +/* Control memory */ +.rules { + display: flex; + flex-direction: column; + gap: 10px; + max-height: 360px; + overflow-y: auto; +} +.state-block { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; +} +.state-block.active { + border-color: var(--accent-2); + box-shadow: 0 0 0 1px var(--accent-2); +} +.state-block.halt { border-color: var(--success); } +.state-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.state-name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + color: var(--accent-2); + font-weight: 600; +} +.state-block.halt .state-name { color: var(--success); } +.state-tag { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.rule { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--muted); + padding: 4px 6px; + border-radius: 4px; + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} +.rule.active { + background: rgba(129, 140, 248, 0.15); + color: var(--text); +} +.rule-arrow { color: var(--accent); } +.rule-dir { color: var(--warn); } + +/* Log */ +.log { + background: #0b1220; + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + max-height: 280px; + overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.6; + white-space: pre; +} +.log-line { color: var(--muted); } +.log-line.head { color: var(--head); } +.log-line.halt { color: var(--success); font-weight: 600; } + +/* Editor */ +.editor { + display: flex; + flex-direction: column; + gap: 10px; +} +.editor label { + font-size: 12px; + color: var(--muted); +} +.editor input, +.editor textarea { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + width: 100%; +} +.editor textarea { + min-height: 100px; + resize: vertical; +} +.editor .row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.editor small { + color: var(--muted); + font-size: 11px; + line-height: 1.5; +} +.editor button { width: 100%; } diff --git a/web-app/public/index.html b/web-app/public/index.html new file mode 100644 index 0000000..457eadf --- /dev/null +++ b/web-app/public/index.html @@ -0,0 +1,81 @@ + + + + + +Compact Turing Machine Simulator + + + + +
+ A small computer in a small space +

Compact Turing Machine Simulator

+

+ Two interactive visualizations of the same idea: a tape, a head, a tiny set of rules, and the + surprising amount of computation that emerges. Inspired by David Hartkop's + Turing-Complete-Cards and the original TurMac_1.1.py reference. +

+ + + + +
+ + diff --git a/web-app/public/machine.html b/web-app/public/machine.html new file mode 100644 index 0000000..1156077 --- /dev/null +++ b/web-app/public/machine.html @@ -0,0 +1,122 @@ + + + + + +Compact Turing Machine Simulator + + + + +
+

Compact Turing Machine Simulator

+

Number Counting Zig-Zag — writes increasing numbers between a pair of 8s, halts at 4. ← Back to index

+
+ +
+
+
+ Tape Memory + 15 cells +
+
+
+ +
+
+ Machine State + Ready +
+
+
+
Current State
+
0
+
+
+
Cycle
+
0
+
+
+
Head Position
+
1
+
+
+
Read Value
+
0
+
+
+
+ +
+
+ Controls + limit: 100 +
+
+ + + + +
+ + + 300 ms +
+
+
+ +
+
+ Control Memory + read → write, dir, next +
+
+
+ +
+
+ Execution Log + 0 lines +
+
+
+ +
+
+ Program Editor + JSON +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ Default program: tape [8,0,...,0,8], head at 1, three states. State 2 is empty (halt). Edit and press Apply to load a new program. + +
+
+
+ + + + diff --git a/web-app/src/cards/compiler.js b/web-app/src/cards/compiler.js new file mode 100644 index 0000000..66b4b1f --- /dev/null +++ b/web-app/src/cards/compiler.js @@ -0,0 +1,148 @@ +// Card-program parsing and compilation. +// +// A program is a flat sequence of integers: +// 0, numStates, state#, ruleCount, [read,write,dir,next]×ruleCount, ... +// Each state's rules use 1-indexed next-state references. +// A halt state has rule count 0. + +export function parseSequence(text) { + return text + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => { + const n = parseInt(s, 10); + if (Number.isNaN(n)) throw new Error(`Not a number: "${s}"`); + return n; + }); +} + +// Strict compile — throws on any malformed input. Useful as a reference. +export function compileProgram(seq) { + let i = 0; + if (seq.length < 2) throw new Error('Program too short'); + if (seq[i++] !== 0) throw new Error('Program must start with a 0 (alignment) card'); + const numStates = seq[i++]; + if (numStates < 1) throw new Error('Need at least one state'); + const states = []; + for (let s = 1; s <= numStates; s++) { + if (i >= seq.length) throw new Error(`Ran out of cards while parsing state ${s}`); + const stateNum = seq[i++]; + if (stateNum !== s) { + throw new Error(`Expected state #${s} marker, got ${stateNum} (card index ${i - 1})`); + } + if (i >= seq.length) throw new Error(`Missing rule count for state ${s}`); + const ruleCount = seq[i++]; + const rules = []; + for (let r = 0; r < ruleCount; r++) { + if (i + 4 > seq.length) { + throw new Error(`Rule ${r + 1} of state ${s} is truncated`); + } + const read = seq[i++], write = seq[i++], dir = seq[i++], next = seq[i++]; + if (dir !== 0 && dir !== 1) throw new Error(`Bad direction ${dir} in state ${s} rule ${r + 1}`); + if (next < 1 || next > numStates) { + throw new Error(`Bad next-state ${next} in state ${s} rule ${r + 1}`); + } + rules.push([read, write, dir, next - 1]); + } + states.push(rules); + } + return { states, used: i, leftover: seq.slice(i) }; +} + +// Lenient compile — auto-fixes common mistakes and reports them. +// Returns { states, fixes, normalized, leftover }. +export function compileProgramLenient(seq) { + const fixes = []; + const out = seq.slice(); + let i = 0; + if (out.length === 0) { + fixes.push('Empty program — inserted alignment + 1-state halt skeleton.'); + out.push(0, 1, 1, 0); + } + if (out[0] !== 0) { + fixes.push(`First card was ${out[0]}, expected 0 (alignment) — prepended an alignment card.`); + out.unshift(0); + } + i = 1; + if (i >= out.length) { + out.push(1); + fixes.push('Missing state-count card — defaulted to 1 state.'); + } + let numStates = out[i]; + if (!Number.isInteger(numStates) || numStates < 1) { + fixes.push(`State-count ${numStates} invalid — set to 1.`); + numStates = 1; out[i] = 1; + } + if (numStates > 8) { + fixes.push(`State-count ${numStates} > 8 — clamped to 8.`); + numStates = 8; out[i] = 8; + } + i++; + const states = []; + for (let s = 1; s <= numStates; s++) { + if (i >= out.length) { + out.push(s, 0); + fixes.push(`State ${s} missing — appended as halt state.`); + } + if (out[i] !== s) { + fixes.push(`Expected state marker #${s}, got ${out[i]} — relabeled.`); + out[i] = s; + } + i++; + if (i >= out.length) { + out.push(0); + fixes.push(`State ${s} missing rule count — defaulted to 0 (halt).`); + } + let ruleCount = out[i]; + if (!Number.isInteger(ruleCount) || ruleCount < 0) { + fixes.push(`State ${s} rule count ${ruleCount} invalid — set to 0.`); + ruleCount = 0; out[i] = 0; + } + if (ruleCount > 8) { + fixes.push(`State ${s} rule count ${ruleCount} > 8 — clamped to 8.`); + ruleCount = 8; out[i] = 8; + } + i++; + const rules = []; + for (let r = 0; r < ruleCount; r++) { + while (i + 4 > out.length) { + out.push(0); + fixes.push(`State ${s} rule ${r + 1} truncated — padded with 0.`); + } + let read = out[i], write = out[i + 1], dir = out[i + 2], next = out[i + 3]; + if (!Number.isInteger(read) || read < 0 || read > 7) { + fixes.push(`State ${s} rule ${r + 1}: read ${read} clamped to 0–7.`); + read = Math.max(0, Math.min(7, read | 0)); out[i] = read; + } + if (!Number.isInteger(write) || write < 0 || write > 7) { + fixes.push(`State ${s} rule ${r + 1}: write ${write} clamped to 0–7.`); + write = Math.max(0, Math.min(7, write | 0)); out[i + 1] = write; + } + if (dir !== 0 && dir !== 1) { + fixes.push(`State ${s} rule ${r + 1}: direction ${dir} invalid — set to R (1).`); + dir = 1; out[i + 2] = 1; + } + if (!Number.isInteger(next) || next < 1 || next > numStates) { + fixes.push(`State ${s} rule ${r + 1}: next-state #${next} out of range — clamped to halt (#${numStates}).`); + next = numStates; out[i + 3] = numStates; + } + rules.push([read, write, dir, next - 1]); + i += 4; + } + states.push(rules); + } + return { states, fixes, normalized: out.slice(0, i), leftover: out.slice(i) }; +} + +// Serialize a states array back to the flat card sequence (1-indexed next refs). +export function serializeStates(states) { + const parts = [0, states.length]; + states.forEach((rules, sIdx) => { + parts.push(sIdx + 1, rules.length); + rules.forEach((rule) => { + parts.push(rule[0], rule[1], rule[2], rule[3] + 1); + }); + }); + return parts.join(','); +} diff --git a/web-app/src/cards/main.js b/web-app/src/cards/main.js new file mode 100644 index 0000000..1ed3e12 --- /dev/null +++ b/web-app/src/cards/main.js @@ -0,0 +1,441 @@ +import { $ } from '../core/dom.js'; +import { Logger } from '../core/logger.js'; +import { Runner } from '../core/runner.js'; +import { TuringMachine } from '../core/turing.js'; +import { makeCard } from './svg.js'; +import { parseSequence, compileProgramLenient, serializeStates } from './compiler.js'; +import { PRESETS } from './presets.js'; + +const view = { + states: [], + tape: [], + headStart: 0, + cycles: 500, + error: null, +}; + +let engine = null; +let matchedThisStep = false; + +const logger = new Logger($('log')); + +const runner = new Runner({ + tick: () => step(), + getInterval: () => speed, + onChange: () => updateButtons(), +}); + +let speed = 500; + +// ---- View helpers ---- + +function setStatus(text, cls = '') { + const el = $('s-status'); + el.textContent = text; + el.className = 'val ' + cls; +} + +function updateButtons() { + const haltedOrError = !engine || engine.halted || view.error; + $('b-run').disabled = haltedOrError || runner.running; + $('b-step').disabled = haltedOrError || runner.running; + $('b-pause').disabled = !runner.running; +} + +function renderProgram() { + const header = $('program-header'); + header.querySelectorAll('.slot,.card-cluster').forEach((n) => n.remove()); + const alignCluster = document.createElement('div'); + alignCluster.className = 'card-cluster'; + alignCluster.appendChild(makeCard(0, { title: 'Alignment (always 0)' })); + alignCluster.appendChild(makeCard(view.states.length, { + title: `Total states: ${view.states.length} (click to change)`, + role: 'numStates', + })); + header.appendChild(alignCluster); + + const stateRows = $('state-rows'); + stateRows.innerHTML = ''; + view.states.forEach((rules, sIdx) => { + const row = document.createElement('div'); + row.className = 'prog-row'; + row.dataset.state = sIdx; + if (rules.length === 0) row.classList.add('halt-row'); + + const label = document.createElement('span'); + label.className = 'row-label'; + label.textContent = `State ${sIdx + 1}${rules.length === 0 ? ' · HALT' : ''}`; + row.appendChild(label); + + const head = document.createElement('div'); + head.className = 'card-cluster state-head'; + head.appendChild(makeCard(sIdx + 1, { title: `State #${sIdx + 1}` })); + head.appendChild(makeCard(rules.length, { + title: `${rules.length} rules (click to add/remove)`, + role: 'ruleCount', + data: { stateIdx: sIdx }, + })); + row.appendChild(head); + + rules.forEach((rule, rIdx) => { + const cluster = document.createElement('div'); + cluster.className = 'card-cluster rule-cluster'; + cluster.dataset.rule = rIdx; + const dirName = rule[2] === 0 ? 'Left' : 'Right'; + cluster.title = `Rule ${rIdx + 1}: read ${rule[0]} → write ${rule[1]}, move ${dirName}, go to state #${rule[3] + 1}`; + cluster.appendChild(makeCard(rule[0], { + title: `Read: ${rule[0]} (click to change)`, + role: 'ruleRead', + data: { stateIdx: sIdx, ruleIdx: rIdx }, + })); + cluster.appendChild(makeCard(rule[1], { + title: `Write: ${rule[1]} (click to change)`, + role: 'ruleWrite', + data: { stateIdx: sIdx, ruleIdx: rIdx }, + })); + cluster.appendChild(makeCard(rule[2], { + title: `Direction: ${dirName} (click to toggle)`, + role: 'ruleDir', + data: { stateIdx: sIdx, ruleIdx: rIdx }, + })); + cluster.appendChild(makeCard(rule[3] + 1, { + title: `Next state: #${rule[3] + 1} (click to change)`, + role: 'ruleNext', + data: { stateIdx: sIdx, ruleIdx: rIdx }, + })); + row.appendChild(cluster); + }); + + stateRows.appendChild(row); + }); + + const headRow = $('head-row'); + headRow.innerHTML = ''; + const headLbl = document.createElement('span'); + headLbl.className = 'lbl'; + headLbl.textContent = `Playhead position card (start = ${view.headStart + 1}):`; + headRow.appendChild(headLbl); + const cardValue = view.headStart + 1; + if (cardValue >= 0 && cardValue <= 7) { + headRow.appendChild(makeCard(cardValue, { + title: `Start at tape position ${cardValue} (click to change)`, + role: 'headPos', + })); + } else { + const note = document.createElement('span'); + note.className = 'lbl'; + note.style.color = 'var(--muted)'; + note.textContent = `(position ${cardValue} is beyond a single card — encoded numerically)`; + headRow.appendChild(note); + } +} + +function renderTape() { + const tapeEl = $('tape'); + tapeEl.innerHTML = ''; + const tape = engine ? engine.tape : view.tape; + const headIdx = engine ? engine.head : view.headStart; + tape.forEach((v, i) => { + const isHead = i === headIdx; + let card; + try { + card = makeCard(v, { + bumped: isHead, + title: `Tape[${i + 1}] = ${v} (click to change)`, + role: 'tapeCell', + data: { tapeIdx: i }, + }); + } catch (e) { + card = document.createElement('div'); + card.className = 'slot'; + card.textContent = v; + card.style.color = 'var(--accent)'; + card.style.display = 'flex'; + card.style.alignItems = 'center'; + card.style.justifyContent = 'center'; + card.style.background = 'rgba(0,0,0,0.3)'; + card.style.border = '1px dashed var(--accent)'; + card.style.borderRadius = '8px'; + card.style.fontFamily = 'ui-monospace,monospace'; + } + if (isHead) { + card.classList.add('read-cell'); + const bump = document.createElement('span'); + bump.className = 'tape-bump-indicator'; + bump.textContent = 'PLAYHEAD'; + card.appendChild(bump); + } + tapeEl.appendChild(card); + }); +} + +function updateStatus() { + if (!engine) { + $('s-state').textContent = '—'; + $('s-cycle').textContent = '—'; + $('s-head').textContent = '—'; + $('s-read').textContent = '—'; + } else { + $('s-state').textContent = engine.currentState + 1; + $('s-cycle').textContent = engine.cycle; + $('s-head').textContent = engine.head + 1; + const read = engine.tape[engine.head]; + $('s-read').textContent = (read === undefined ? '—' : read); + } + if (view.error) setStatus(view.error, 'error'); + else if (engine && engine.halted) setStatus('Halted', 'halted'); + else if (runner.running) setStatus('Running', 'running'); + else setStatus('Ready'); + updateButtons(); +} + +function highlightActive() { + document.querySelectorAll('.prog-row').forEach((r) => r.classList.remove('active')); + document.querySelectorAll('.rule-cluster').forEach((r) => r.classList.remove('matched')); + if (!engine || engine.halted || view.error) return; + const row = document.querySelector(`.prog-row[data-state="${engine.currentState}"]`); + if (row) row.classList.add('active'); + if (matchedThisStep && engine.activeRule >= 0 && row) { + const cluster = row.querySelector(`.rule-cluster[data-rule="${engine.activeRule}"]`); + if (cluster) cluster.classList.add('matched'); + } +} + +function fullRender() { + renderProgram(); + renderTape(); + updateStatus(); + highlightActive(); +} + +// ---- Engine wiring ---- + +function buildEngine() { + engine = new TuringMachine({ + tape: view.tape, + head: view.headStart, + control: view.states, + startState: 0, + cycleLimit: view.cycles, + boundsCheck: 'before', + }); + matchedThisStep = false; +} + +function loadFromInputs() { + const progText = $('prog').value; + const headCard = parseInt($('head-card').value, 10); + const tapeText = $('tape-input').value; + const cycles = parseInt($('cycles').value, 10) || 500; + + const seq = parseSequence(progText); + const compiled = compileProgramLenient(seq); + let tape = parseSequence(tapeText); + const tapeFixes = []; + if (tape.length === 0) { + tape = [0]; + tapeFixes.push('Tape was empty — inserted a single 0 cell.'); + } + tape = tape.map((v, idx) => { + if (!Number.isInteger(v) || v < 0 || v > 7) { + tapeFixes.push(`Tape[${idx + 1}] = ${v} clamped to 0–7.`); + return Math.max(0, Math.min(7, v | 0)); + } + return v; + }); + let headStart = headCard - 1; + if (!Number.isInteger(headCard) || headCard < 1 || headCard > tape.length) { + headStart = Math.max(0, Math.min(tape.length - 1, (headCard | 0) - 1)); + tapeFixes.push(`Head card ${headCard} out of range 1–${tape.length} — set to ${headStart + 1}.`); + } + + view.states = compiled.states; + view.tape = tape.slice(); + view.headStart = headStart; + view.cycles = cycles; + view.error = null; + + buildEngine(); + logger.clear(); + logger.line(`Loaded program with ${compiled.states.length} states. Tape length ${tape.length}, head at position ${headStart + 1}.`); + compiled.fixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err')); + tapeFixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err')); + if (compiled.leftover.length) { + logger.line(`Note: ${compiled.leftover.length} extra card(s) after the program were ignored: ${compiled.leftover.join(',')}`); + } + if (compiled.fixes.length || tapeFixes.length) { + syncEditorFromState(); + } +} + +function step() { + if (!engine || engine.halted || view.error) return false; + matchedThisStep = false; + + const result = engine.step(); + switch (result.kind) { + case 'cycle-limit': + logger.line('CYCLE LIMIT REACHED', 'halt'); + fullRender(); + return false; + case 'halt': + logger.line('HALT CONDITION REACHED', 'halt'); + fullRender(); + return false; + case 'oob': + logger.line(`Playhead at invalid position ${result.position + 1} in non-halt state. Halting.`, 'halt'); + fullRender(); + return false; + case 'no-rule': + logger.line(`No rule matches read=${result.read} in state ${engine.currentState + 1}. Halting.`, 'halt'); + fullRender(); + return false; + case 'step': { + matchedThisStep = true; + const r = result.rule; + logger.line(`[cycle ${engine.cycle - 1}] state ${result.prevState + 1}, head@${result.prevHead + 1}, read ${result.read} → write ${r[1]}, move ${r[2] === 0 ? 'L' : 'R'}, next state ${r[3] + 1}`); + fullRender(); + return true; + } + default: + return false; + } +} + +function reset() { + runner.stop(); + try { + loadFromInputs(); + fullRender(); + } catch (e) { + view.error = e.message; + engine = null; + logger.line('Error: ' + e.message, 'err'); + updateStatus(); + } +} + +function loadPreset(key) { + const p = PRESETS[key]; + if (!p) return; + $('prog').value = p.program; + $('head-card').value = p.head; + $('tape-input').value = p.tape; + $('cycles').value = p.cycles; + reset(); +} + +// ---- Click-to-edit ---- + +function cycleVal(v, dir, lo, hi) { + const size = hi - lo + 1; + return ((v - lo + dir) % size + size) % size + lo; +} + +function autofixNextStateRefs() { + const n = view.states.length; + if (n === 0) return []; + const fixes = []; + view.states.forEach((rules, sIdx) => { + rules.forEach((rule, rIdx) => { + if (rule[3] < 0 || rule[3] >= n) { + const old = rule[3]; + rule[3] = n - 1; + fixes.push(`State ${sIdx + 1} rule ${rIdx + 1}: next-state #${old + 1} → #${rule[3] + 1}`); + } + }); + }); + return fixes; +} + +function syncEditorFromState() { + $('prog').value = serializeStates(view.states); + $('head-card').value = view.headStart + 1; + $('tape-input').value = view.tape.join(','); +} + +const ROLE_HANDLERS = { + numStates(slot, dir) { + const cur = view.states.length; + const next = cycleVal(cur, dir, 1, 8); + if (next > cur) { + for (let i = cur; i < next; i++) view.states.push([]); + } else { + view.states.length = next; + } + const fixes = autofixNextStateRefs(); + fixes.forEach((f) => logger.line('Auto-fix: ' + f, 'err')); + logger.line(`Number of states: ${cur} → ${next}`); + }, + ruleCount(slot, dir) { + const sIdx = parseInt(slot.dataset.stateIdx, 10); + const rules = view.states[sIdx]; + const cur = rules.length; + const next = cycleVal(cur, dir, 0, 8); + if (next > cur) { + for (let i = cur; i < next; i++) rules.push([0, 0, 1, 0]); + } else { + rules.length = next; + } + logger.line(`State ${sIdx + 1} rules: ${cur} → ${next}`); + }, + ruleRead(slot, dir) { + const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx]; + rule[0] = cycleVal(rule[0], dir, 0, 7); + }, + ruleWrite(slot, dir) { + const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx]; + rule[1] = cycleVal(rule[1], dir, 0, 7); + }, + ruleDir(slot) { + const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx]; + rule[2] = rule[2] === 0 ? 1 : 0; + }, + ruleNext(slot, dir) { + const rule = view.states[+slot.dataset.stateIdx][+slot.dataset.ruleIdx]; + const n = view.states.length; + rule[3] = cycleVal(rule[3], dir, 0, n - 1); + }, + headPos(slot, dir) { + const hi = Math.min(view.tape.length, 7) - 1; + view.headStart = cycleVal(view.headStart, dir, 0, hi); + }, + tapeCell(slot, dir) { + const i = +slot.dataset.tapeIdx; + view.tape[i] = cycleVal(view.tape[i], dir, 0, 7); + }, +}; + +function handleCardClick(slot, shiftKey) { + const handler = ROLE_HANDLERS[slot.dataset.role]; + if (!handler) return; + runner.stop(); + handler(slot, shiftKey ? -1 : 1); + buildEngine(); + syncEditorFromState(); + fullRender(); +} + +// ---- Wire up ---- + +$('b-step').addEventListener('click', () => { if (!runner.running) step(); }); +$('b-run').addEventListener('click', () => runner.start()); +$('b-pause').addEventListener('click', () => runner.stop()); +$('b-reset').addEventListener('click', reset); +$('b-apply').addEventListener('click', reset); +$('b-load-default').addEventListener('click', () => loadPreset($('preset').value)); +$('preset').addEventListener('change', (e) => loadPreset(e.target.value)); + +$('board').addEventListener('click', (e) => { + const slot = e.target.closest('.slot.clickable'); + if (!slot) return; + handleCardClick(slot, e.shiftKey); +}); + +$('speed').addEventListener('input', (e) => { + speed = parseInt(e.target.value, 10); + $('speed-v').textContent = speed + ' ms'; + runner.restart(); +}); + +loadPreset('unary'); diff --git a/web-app/src/cards/presets.js b/web-app/src/cards/presets.js new file mode 100644 index 0000000..2a77a40 --- /dev/null +++ b/web-app/src/cards/presets.js @@ -0,0 +1,20 @@ +// Preset programs from the Turing Cards setup material. +// Card values are 0–7; the original Python uses 8 as a boundary marker, which +// we substitute with 7 here. + +export const PRESETS = { + unary: { + name: 'Unary → Binary Converter', + program: '0,4, 1,4,1,3,0,2,2,2,1,1,3,3,1,1,5,5,0,3, 2,4,3,3,0,2,4,2,0,2,5,4,1,1,2,4,1,1, 3,4,2,0,0,3,3,3,0,3,4,1,0,3,5,5,0,4, 4,0', + head: 5, + tape: '5,5,5,5,1,1,1,1,5,4', + cycles: 500, + }, + zigzag: { + name: 'Number Counting Zig-Zag (from TurMac_1.1.py)', + program: '0,3, 1,4,0,1,1,1,7,7,0,2,2,3,1,1,4,4,0,3, 2,3,1,2,0,2,7,7,1,1,3,4,0,2, 3,0', + head: 2, + tape: '7,0,0,0,0,0,0,0,0,0,0,0,0,0,7', + cycles: 500, + }, +}; diff --git a/web-app/src/cards/svg.js b/web-app/src/cards/svg.js new file mode 100644 index 0000000..bd4726f --- /dev/null +++ b/web-app/src/cards/svg.js @@ -0,0 +1,67 @@ +// Card SVG rendering. +// Front face shows 0/1/2/3 (black/red/yellow/blue clockwise from top). +// Back face shows 5/6/7/4 (orange/green/pink/purple clockwise from top). +// Each dot's number is rotated so that, when the whole card is turned to put +// that number on top, the digit reads upright. + +export const COLORS = [ + 'var(--c0)', 'var(--c1)', 'var(--c2)', 'var(--c3)', + 'var(--c4)', 'var(--c5)', 'var(--c6)', 'var(--c7)', +]; + +export function svgFace(face) { + const nums = face === 'front' ? [0, 1, 2, 3] : [5, 6, 7, 4]; + const [nT, nR, nB, nL] = nums; + const dot = (cx, cy, rot, num) => ` + + + ${num} + `; + return ` + + + ${dot(50, 22, 0, nT)} + ${dot(78, 50, 90, nR)} + ${dot(50, 78, 180, nB)} + ${dot(22, 50, -90, nL)} + `; +} + +export function valueToFaceAndRotation(value) { + if (value < 0 || value > 7 || !Number.isInteger(value)) { + throw new Error(`Card value must be an integer 0–7 (got ${value})`); + } + if (value < 4) { + return { face: 'front', rotation: -value * 90 }; + } + const idx = [5, 6, 7, 4].indexOf(value); + return { face: 'back', rotation: -idx * 90 }; +} + +export function makeCard(value, opts = {}) { + const slot = document.createElement('div'); + slot.className = 'slot'; + if (opts.small) slot.classList.add('small'); + if (opts.bumped) slot.classList.add('bumped'); + if (opts.title) slot.title = opts.title; + if (opts.role) { + slot.classList.add('clickable'); + slot.dataset.role = opts.role; + if (opts.data) { + for (const k of Object.keys(opts.data)) { + slot.dataset[k] = opts.data[k]; + } + } + } + const rotor = document.createElement('div'); + rotor.className = 'rotor'; + const { face, rotation } = valueToFaceAndRotation(value); + rotor.style.transform = `rotate(${rotation}deg)`; + rotor.innerHTML = svgFace(face); + slot.appendChild(rotor); + slot.dataset.value = value; + return slot; +} diff --git a/web-app/src/core/dom.js b/web-app/src/core/dom.js new file mode 100644 index 0000000..09a0b63 --- /dev/null +++ b/web-app/src/core/dom.js @@ -0,0 +1,18 @@ +export const $ = (id) => document.getElementById(id); + +export function el(tag, opts = {}) { + const node = document.createElement(tag); + if (opts.className) node.className = opts.className; + if (opts.text != null) node.textContent = opts.text; + if (opts.html != null) node.innerHTML = opts.html; + if (opts.attrs) { + for (const [k, v] of Object.entries(opts.attrs)) node.setAttribute(k, v); + } + if (opts.dataset) { + for (const [k, v] of Object.entries(opts.dataset)) node.dataset[k] = v; + } + if (opts.children) { + for (const c of opts.children) node.appendChild(c); + } + return node; +} diff --git a/web-app/src/core/logger.js b/web-app/src/core/logger.js new file mode 100644 index 0000000..6e847b6 --- /dev/null +++ b/web-app/src/core/logger.js @@ -0,0 +1,26 @@ +export class Logger { + constructor(el, badgeEl = null) { + this.el = el; + this.badgeEl = badgeEl; + } + + line(text, cls = '') { + const div = document.createElement('div'); + div.className = ('log-line ' + cls).trim(); + div.textContent = text; + this.el.appendChild(div); + this.el.scrollTop = this.el.scrollHeight; + this._updateBadge(); + } + + clear() { + this.el.innerHTML = ''; + this._updateBadge(); + } + + _updateBadge() { + if (!this.badgeEl) return; + const n = this.el.children.length; + this.badgeEl.textContent = `${n} line${n === 1 ? '' : 's'}`; + } +} diff --git a/web-app/src/core/runner.js b/web-app/src/core/runner.js new file mode 100644 index 0000000..840ea4a --- /dev/null +++ b/web-app/src/core/runner.js @@ -0,0 +1,32 @@ +export class Runner { + constructor({ tick, getInterval, onChange }) { + this.tick = tick; + this.getInterval = getInterval; + this.onChange = onChange || (() => {}); + this.handle = null; + } + + get running() { return this.handle !== null; } + + start() { + if (this.handle) return; + this.handle = setInterval(() => { + const ok = this.tick(); + if (!ok) this.stop(); + }, this.getInterval()); + this.onChange(true); + } + + stop() { + if (!this.handle) return; + clearInterval(this.handle); + this.handle = null; + this.onChange(false); + } + + restart() { + if (!this.handle) return; + this.stop(); + this.start(); + } +} diff --git a/web-app/src/core/turing.js b/web-app/src/core/turing.js new file mode 100644 index 0000000..431896e --- /dev/null +++ b/web-app/src/core/turing.js @@ -0,0 +1,102 @@ +// Compact Turing machine engine. +// +// Control format: control[stateIdx] = array of rules. +// Each rule = [read, write, dir, nextStateIdx]; dir 0=L, 1=R. +// A state with zero rules is a halt state. +// +// boundsCheck: +// 'before' — check head bounds at the start of each step (cards.html semantics: +// transitions that move the head off-tape into a halt state are +// treated as a clean halt because the halt check runs first). +// 'after' — apply the rule first, then check bounds (machine.html semantics: +// moving off the tape is reported with the post-step head position). + +export class TuringMachine { + constructor({ + tape, + head, + control, + startState = 0, + cycleLimit = 100, + boundsCheck = 'after', + }) { + this.initialTape = tape.slice(); + this.initialHead = head; + this.initialControl = control.map((rules) => rules.map((r) => r.slice())); + this.startState = startState; + this.cycleLimit = cycleLimit; + this.boundsCheck = boundsCheck; + this.reset(); + } + + reset() { + this.tape = this.initialTape.slice(); + this.head = this.initialHead; + this.control = this.initialControl.map((rules) => rules.map((r) => r.slice())); + this.currentState = this.startState; + this.cycle = 0; + this.halted = false; + this.activeRule = -1; + } + + get readValue() { + return this.tape[this.head]; + } + + // Returns one of: + // { kind: 'already-halted' } + // { kind: 'cycle-limit' } + // { kind: 'halt' } // empty rule list + // { kind: 'no-rule', read } // no rule matches + // { kind: 'oob', position } // boundsCheck: 'before' + // { kind: 'oob-after', position, ...stepInfo } // boundsCheck: 'after' + // { kind: 'step', read, rule, ruleIdx, prevHead, prevState } // normal step + step() { + if (this.halted) return { kind: 'already-halted' }; + if (this.cycle >= this.cycleLimit) { + this.halted = true; + this.activeRule = -1; + return { kind: 'cycle-limit' }; + } + + const rules = this.control[this.currentState]; + if (!rules || rules.length === 0) { + this.halted = true; + this.activeRule = -1; + return { kind: 'halt' }; + } + + if (this.boundsCheck === 'before' && (this.head < 0 || this.head >= this.tape.length)) { + this.halted = true; + this.activeRule = -1; + return { kind: 'oob', position: this.head }; + } + + const read = this.tape[this.head]; + let ruleIdx = -1; + for (let i = 0; i < rules.length; i++) { + if (rules[i][0] === read) { ruleIdx = i; break; } + } + if (ruleIdx === -1) { + this.halted = true; + this.activeRule = -1; + return { kind: 'no-rule', read }; + } + + const rule = rules[ruleIdx]; + const prevHead = this.head; + const prevState = this.currentState; + this.activeRule = ruleIdx; + this.tape[this.head] = rule[1]; + this.head += rule[2] === 0 ? -1 : 1; + this.currentState = rule[3]; + this.cycle += 1; + + if (this.boundsCheck === 'after' && (this.head < 0 || this.head >= this.tape.length)) { + this.halted = true; + return { kind: 'oob-after', position: this.head, read, rule, ruleIdx, prevHead, prevState }; + } + + return { kind: 'step', read, rule, ruleIdx, prevHead, prevState }; + } +} diff --git a/web-app/src/machine/main.js b/web-app/src/machine/main.js new file mode 100644 index 0000000..7dedb7f --- /dev/null +++ b/web-app/src/machine/main.js @@ -0,0 +1,220 @@ +import { $ } from '../core/dom.js'; +import { Logger } from '../core/logger.js'; +import { Runner } from '../core/runner.js'; +import { TuringMachine } from '../core/turing.js'; + +const defaultProgram = { + tape: [8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], + head: 1, + control: [ + [[0, 1, 1, 0], [8, 8, 0, 1], [2, 3, 1, 0], [4, 4, 0, 2]], + [[1, 2, 0, 1], [8, 8, 1, 0], [3, 4, 0, 1]], + [], + ], + startState: 0, + cycles: 100, +}; + +let engine = null; +let speed = 300; + +const logger = new Logger($('log'), $('log-badge')); + +const runner = new Runner({ + tick: () => step(), + getInterval: () => speed, + onChange: () => updateButtons(), +}); + +function updateButtons() { + const halted = !engine || engine.halted; + $('btn-run').disabled = halted || runner.running; + $('btn-step').disabled = halted || runner.running; + $('btn-pause').disabled = !runner.running; +} + +function loadProgram(p) { + engine = new TuringMachine({ + tape: p.tape, + head: p.head, + control: p.control, + startState: p.startState, + cycleLimit: p.cycles, + boundsCheck: 'after', + }); + logger.clear(); + render(); +} + +function render() { + renderTape(); + renderStats(); + renderRules(); + $('cycle-limit-badge').textContent = `limit: ${engine.cycleLimit}`; + $('status-badge').textContent = engine.halted + ? 'Halted' + : (runner.running ? 'Running' : 'Ready'); + updateButtons(); +} + +function renderTape() { + const tape = $('tape'); + tape.innerHTML = ''; + $('tape-badge').textContent = `${engine.tape.length} cells`; + engine.tape.forEach((v, i) => { + const cell = document.createElement('div'); + cell.className = 'cell'; + if (v === 8) cell.classList.add('boundary'); + if (i === engine.head) cell.classList.add('head'); + cell.textContent = v; + tape.appendChild(cell); + }); +} + +function renderStats() { + $('stat-state').textContent = engine.currentState; + $('stat-cycle').textContent = engine.cycle; + $('stat-head').textContent = engine.head; + const read = engine.tape[engine.head]; + $('stat-read').textContent = read === undefined ? '—' : read; + const stateEl = $('stat-state'); + stateEl.classList.toggle('halted', engine.halted); + stateEl.classList.toggle('running', !engine.halted && runner.running); +} + +function renderRules() { + const container = $('rules'); + container.innerHTML = ''; + engine.control.forEach((rules, sIdx) => { + const block = document.createElement('div'); + block.className = 'state-block'; + const isHalt = rules.length === 0; + if (sIdx === engine.currentState) block.classList.add('active'); + if (isHalt) block.classList.add('halt'); + + const header = document.createElement('div'); + header.className = 'state-header'; + const name = document.createElement('span'); + name.className = 'state-name'; + name.textContent = `State ${sIdx}`; + const tag = document.createElement('span'); + tag.className = 'state-tag'; + tag.textContent = isHalt + ? 'halt' + : (sIdx === engine.currentState ? 'active' : `${rules.length} rules`); + header.appendChild(name); + header.appendChild(tag); + block.appendChild(header); + + rules.forEach((rule, rIdx) => { + const r = document.createElement('div'); + r.className = 'rule'; + if (sIdx === engine.currentState && rIdx === engine.activeRule) { + r.classList.add('active'); + } + const dir = rule[2] === 0 ? 'L' : 'R'; + r.innerHTML = `read ${rule[0]} write ${rule[1]}, move ${dir}, next state ${rule[3]}`; + block.appendChild(r); + }); + + container.appendChild(block); + }); +} + +function logCurrent() { + const tapeStr = '[' + engine.tape.join(', ') + ']'; + logger.line(`${tapeStr} state: ${engine.currentState} cycle: ${engine.cycle}`); + const pointer = ' '.repeat(engine.head * 3 + 1) + '^'; + logger.line(pointer, 'head'); +} + +function step() { + if (!engine || engine.halted) return false; + + // Cycle-limit check is done first so the limit message isn't preceded by a + // configuration dump (matches the original machine.html log order). + if (engine.cycle >= engine.cycleLimit) { + engine.step(); // mutates halted + activeRule + logger.line('CYCLE LIMIT REACHED', 'halt'); + render(); + return false; + } + + logCurrent(); + + const result = engine.step(); + switch (result.kind) { + case 'cycle-limit': + logger.line('CYCLE LIMIT REACHED', 'halt'); + render(); + return false; + case 'halt': + logger.line('HALT CONDITION REACHED', 'halt'); + render(); + return false; + case 'no-rule': + logger.line(`No matching rule for read=${result.read} in state ${engine.currentState}. Halting.`, 'halt'); + render(); + return false; + case 'oob-after': + logger.line(`Head moved out of bounds at position ${result.position}. Halting.`, 'halt'); + render(); + return false; + case 'step': + render(); + return true; + default: + return false; + } +} + +function reset() { + runner.stop(); + try { + const program = readEditor(); + loadProgram(program); + } catch (e) { + loadProgram(defaultProgram); + } +} + +function readEditor() { + const tape = JSON.parse($('edit-tape').value); + const head = parseInt($('edit-head').value, 10); + const control = JSON.parse($('edit-control').value); + const startState = parseInt($('edit-start').value, 10); + const cycles = parseInt($('edit-cycles').value, 10); + if (!Array.isArray(tape)) throw new Error('Tape must be an array'); + if (!Array.isArray(control)) throw new Error('Control must be an array'); + return { tape, head, control, startState, cycles }; +} + +function writeEditor(p) { + $('edit-tape').value = JSON.stringify(p.tape); + $('edit-head').value = p.head; + $('edit-control').value = JSON.stringify(p.control, null, 2); + $('edit-start').value = p.startState; + $('edit-cycles').value = p.cycles; +} + +$('btn-step').addEventListener('click', () => { if (!runner.running) step(); }); +$('btn-run').addEventListener('click', () => runner.start()); +$('btn-pause').addEventListener('click', () => runner.stop()); +$('btn-reset').addEventListener('click', reset); +$('btn-apply').addEventListener('click', () => { + try { + const p = readEditor(); + loadProgram(p); + logger.line('Program loaded.', 'halt'); + } catch (e) { + logger.line(`Error: ${e.message}`, 'halt'); + } +}); +$('speed').addEventListener('input', (e) => { + speed = parseInt(e.target.value, 10); + $('speed-value').textContent = `${speed} ms`; + runner.restart(); +}); + +writeEditor(defaultProgram); +loadProgram(defaultProgram);