Skip to content

Design decisions

This page records the questions about the library's behaviour, its learning procedure and the project that only the project owner could settle, what was decided, and why. Each entry says what changed in the code, where it lives, and which page explains it to readers. It is for contributors who want to know why the library behaves as it does before they change it; if you want to reopen a decision, add it to the "Open" section below with the context, the options and a recommendation.

Open

Add new questions here in the same shape as the decided entries: what the code does today, the options, a recommendation, and a Decision line for the owner.

Decided

Library behaviour

Decided: add a sixth check, support, with its minimum learned like the other settings. The other evidence checks measure how informative the related cases are, never which outcome they point to, so a clean card inside a confirmed fraud ring used to act pass. The new check reads EvidenceSnapshot.support, the share of related cases whose label is the one the proposed action implies, and passes when it is at least Limits.min_support (0.5). When support is not measured (None) it passes, unless Limits.require_support is set, so existing evidence keeps working. It is a counting rule with an explicit constant, so its trust level is always operational. Code: evidence/support.py (support_report), snapshot_from_graph(..., supporting_label=), ChannelName.SUPPORT, Health.support, the sixth entry in Module.decide; the learner's grid gains minimum_supports (0 = off, 0.5 to 0.9). fraud_review and clinical_triage measure it, and the fraud ring's clean row is now held. Explained on Do the related cases support this action? and The six checks.

D2. The default neighbour-agreement measure stays uniform

Decided: keep agreement_baseline="uniform" as the default, and document when to use "label_mix". The recommendation had been to switch; implementation showed why not. uniform is the estimator the Kesten–Stigum model behind the check assumes (labels in equal proportions), which keeps the check consistent with its model. label_mix measures agreement relative to the graph's own label mix, which corrects random links among mostly-clean accounts but misjudges a homogeneous ring: a ring that is nearly all fraud is strong evidence, yet has little variation inside it and scores near zero. The question it was meant to answer, which way the related cases point, now belongs to the support check (D1), and the error rate of the whole pipeline is what learning certifies. Code: the snapshot_from_graph docstring in evidence/graph.py. Explained on Do the related cases agree? and Build evidence.

D3. A meaningful progress check, and no dead states

Decided: make the progress check able to fail for real workflow mistakes, and stop out-of-order events from stranding entities. Three changes. First, the compiled chart leaves out the automaton's dead state: an event the policy does not expect in the current state is "not handled" (the entity stays, nothing is proposed), exactly like an unknown event, so no entity can fall into a state that proposes nothing forever. Second, the game no longer gives every state a "stay put" loop; only final states (no way out) loop on themselves. Third, text policies get priorities from their shape, and charts can mark states the service does not control with environment=True, which the solver treats as owned by the opponent. Refinement made during implementation: the spec said priority 0 for action and final states and 1 elsewhere, which is wrong under the solver's max-parity convention (the largest priority seen infinitely often decides, and 1 > 0 would make every play lose); the code uses 2 for action and final states and 1 for the rest. A policy that can loop forever without proposing anything, such as (test(ping))*, now fails; every shipped example still passes. Code: dfa_to_statechart in statechart/machine.py (_PROGRESS = 2, _NO_PROGRESS = 1, DFA.dead), compile_game in statechart/game.py, Statechart.state(..., environment=). Explained on Can the workflow keep going? and Workflows that keep going.

D4. A hold beats a fallback when combining modules

Decided: in compose_parent and Pipeline, any child abstain makes the parent abstain; otherwise any degrade makes it degrade; all act gives act; no children gives abstain. A held child means part of the decision has no support at all, and a conservative combination must never turn a hold into an automatic action, even a reduced one. Code: compose_parent in runtime/module.py. Explained on Composing modules.

D5. Two actions after one event are a compile error

Decided: a policy in which one event (or the start) can lead to two different actions, such as test(e) ; (act(a) | act(b)), raises CompileError naming the event, the state and the actions, for example Policy is ambiguous: after event 'e' (state s1) it could propose 'a' or 'b'; give each action its own event, for example test(ok) ; act(a) | test(bad) ; act(b). Previously one action was silently unreachable, which is a policy bug. The check is skipped inside not(...), which drops actions anyway. Code: _compile in grammar/dfa.py. Explained on Write a policy and Grammar compile.

D6. Every size cap is enforced; timing budgets are tested

Decided: enforce the three caps that were reported but never read, and turn the two timing budgets into a test rather than a timer. max_karp_vertices now bounds the mean-payoff solver (it raises GameTooLarge and the progress check fails closed); max_table_cells_exact bounds sample_two_row and max_switch_steps bounds sample_switch (both raise ValueError). decide_ms_budget (50 ms) and compile_ms_budget (200 ms) stay targets: tests/test_performance_budget.py builds fraud_review and checks the median of 200 decides and 20 builds against them. Code: statechart/mean_payoff.py, compare/tables.py, the Limits docstring in performance.py. Explained on Defaults and limits and Limits and performance.

D7. A prophet rule that earns its 1/2

Decided: add the rule that has the guarantee, and report the factor only where it holds. single_item_max_of_samples(values, samples) sets the bar at the largest of one past sample per arrival and reports factor_claimed=0.5: in expectation it earns at least half of the best value in hindsight, when values are independent and non-negative and each sample is drawn like its arrival (Rubinstein, Wang and Weinberg, 2020; the proof is in the module docstring). The scalar-bar rule single_item_one_sample now reports factor_claimed=None, and both results carry a guarantee sentence. cache_admission and mkt_fut_roll use the new rule. Code: compare/prophet.py. Explained on One slot, no take-backs, Cache admission and Compare outcomes and features.

D8. The fpras label is refused

Decided: a subgoal sent with Envelope.FPRAS returns ok=False with the detail no FPRAS handler is implemented; send the subgoal with its own envelope, like undec and exptime_trap. Before, some handlers answered and relabelled the result, which overstated what had run. Code: the top of dispatch in router/dispatch.py. Explained on Questions the library refuses.

D9. The certificate check reports what it certifies

Decided: keep the certificate check on every record, but replace its duplicated progress metrics with the compile limits it checks: star_height, dfa_states and max_dfa_states, with a summary such as star-height 0 ≤ 3; 4 states ≤ 256. A policy that breaks a limit is never built, so the check always passes inside a record; keeping it shows every record the limits its policy was checked against. Code: Module._certificate_channel in runtime/module.py. Explained on The decision record and Is the policy well-formed?.

Parameter learning

D10. Fixed-sequence testing is the default

Decided: learn_parameters(..., procedure=) takes "fixed_sequence" (the default) or "bonferroni". The fixed sequence orders the grid using the calibration split only, tests each combination on the risk split at the full significance and stops at the first failure; it needs no division by the grid size, so it certifies settings that act more often under the same guarantee. Refinement made during implementation: the spec ordered the grid by act rate (strictest first), which is valid but fragile, because strict combinations act on so few cases that a safe one fails for lack of trials and stops the sequence (6.9% of fresh cases acted on, against 15.7% for Bonferroni, on the tests' 3,000-case history). The code orders by the p-value each combination would get if the risk split looked like the calibration split, most clearly safe first. Measured gain on the same history: 18.2% acted on with a true error of 0.93%, against 15.7% and 0.91% for Bonferroni; across 60 histories the miss rate stays within the allowed 10%. The procedure is recorded in the provenance and printed by explain(); the command line takes --procedure. Code: learn_parameters and _testing_sequence in learn/calibrate.py. Explained on Learning settings with a guarantee, Learn parameters from your history and Command line.

D11. The learner warns about missing outcomes for held decisions

Decided: HistoricalCase.was_acted_on (optional) records whether a past decision was carried out. When every case with a known value was acted on, learn_parameters warns that outcomes of held decisions are missing and the certificate describes a filtered population; when no case records it, a softer warning says it cannot tell. The reference service's GET /history.jsonl sets the field from its audit log (enforced and routed to execute or execute_degraded). Requiring held outcomes was rejected: many teams cannot supply them at first, and a warning keeps the certificate honest without blocking them. Code: HistoricalCase and _selective_label_warnings in learn/, the history export in examples/reference_service.py. Explained on Learn parameters from your history and Build a decision service.

Project and release

D12. Theory pages keep the dual framing

Decided: keep presenting results that are open problems in the wider literature (generalised star height, Courtade–Kumar, Hilbert's tenth problem over the rationals) in two parts: what the literature says, and separately what this project's catalog claims and how it is checked. No code change. Explained on Where each result stands and How to read these pages.

D13. Catalog references name no paths

Decided: the docs refer to "the companion math catalog (kept outside this repository)" and never to its directories. The catalog is not part of this repository, so paths into it were dead ends for readers. Code: none; certificates.py already holds the references as plain strings. Explained on How to read these pages and the glossary.

D14. Installing: from a checkout or the locally built wheel

Decided: the package is not published to any package index. It is installed from a checkout (uv add /path/to/grounded_decision) or from the wheel make build writes to dist/, which we copy where it is needed. Nothing is published or deployed remotely. Explained on Install, the README and Development and automation.

D15. Semantic versioning, with migration notes before 1.0

Decided: the project follows semantic versioning. Before 1.0, a minor release may change behaviour; every such change is listed in the changelog with migration notes. Renamed public names keep an alias for one minor release when that is cheap. Explained on Stability and versioning.

D16. .python-version is committed

Decided: commit .python-version (3.12) so every contributor's uv uses the same interpreter. The repository's .gitignore contains !.python-version, because repository patterns override a global excludes file that would otherwise leave it out. Explained on Development and automation.

The web showcase

These record the choices behind the web plan. D17 to D24 are the plan's "Decisions already made"; D25 to D27 were made while building its phase 0; D28 to D33 while building phase 1; D34 to D37 while building phase 2's replays, D38 to D42 while building its board renderer and gallery, D43 to D50 while building phase 3, the live engine in the browser, D51 while making the snake's frames small for it, and D52 to D58 while building phase 4, the showcase site. The plan explains each in context.

D17. The browser runs the Python package itself

Decided: the rules, agents, gates and solvers run in the browser as the unchanged Python package inside Pyodide (CPython compiled to WebAssembly) in a Web Worker; our own service (phase 9) runs the same package on our servers. Options were a JavaScript reimplementation of the games, a server for every interaction, or Pyodide. A reimplementation would need its own tests and could disagree with the proofs; a server costs money and uptime for every visitor. Pyodide keeps one implementation, so the tests and proofs cover what visitors see; its only dependencies, numpy and lark, both load in Pyodide. The price is a download of about 10 MB, which recorded replays hide (the plan's phase 2). Code: from phase 3, web/packages/engine. Explained on Web showcase.

D18. SvelteKit with the static adapter, TypeScript in strict mode

Decided: the site is a SvelteKit app prerendered to static files by @sveltejs/adapter-static, written in TypeScript with strict, noUncheckedIndexedAccess and exactOptionalPropertyTypes. Options were React (Next.js or Vite), Astro, plain web components, or SvelteKit. Svelte compiles components away, so the runtime is small, and its transitions suit animation-heavy pages; the static adapter makes every page plain files (D23). Code: web/apps/site/, web/tsconfig.base.json. Explained on Development and automation.

D19. Boards are drawn with Canvas 2D

Decided: layered 2D canvases with dirty-rectangle redraws, and a WebGL path only if a measured budget fails. Options were SVG or DOM cells, a scene graph such as PixiJS, WebGL, or Canvas 2D. Grid boards need no scene graph, PixiJS adds about 450 KB, and a 320 × 240 snake is 76,800 cells, which is one ImageData write per frame. Code: from phase 2, web/packages/board. Explained on Web showcase.

D20. Embeds are standard web components, with an iframe fallback

Decided: <gd-game>, <gd-replay>, <gd-ambient> and <gd-demo> are custom elements with Shadow DOM, plus an iframe embed for hosts that forbid scripts. Options were framework components (React or Svelte only), iframes only, or custom elements. Custom elements work in any site and framework, and Shadow DOM stops styles leaking in either direction. Code: from phase 7, web/packages/elements. Explained on Web showcase.

D21. One protocol: JSON Schema from Python, TypeScript from the schema

Decided: the messages between the page and the engine are Python dataclasses; a make target writes their JSON Schema, and the TypeScript types are generated from the schema. A test fails when they drift. Options were handwritten types on both sides, or a separate IDL such as Protocol Buffers. Generating from Python keeps one source of truth beside the code that produces the frames, and a schema diff makes breaking changes visible. Code: from phase 1, src/grounded_decision/session/ and web/packages/protocol. Explained on Web showcase.

D22. One token file for colours and glyphs

Decided: the colours and glyphs of every cell kind are generated from views.py into one token file that both the terminal and the CSS use. Options were a separate web palette or hand-copied values. One source keeps the command line and the site looking like the same product, and a check that generated files are current catches drift, as it does for docs/reference/agents.md. Code: from phase 1, the protocol make target. Explained on Web showcase.

D23. Static files on our own server

Decided: the site is a static, content-hashed build in a local directory that the owner copies to their own site and service; no third-party host, vendor CDN, deploy step, account or secret is part of the project. "CDN" in these pages means the owner's own server serving the versioned build output. Options were a host-specific platform with server functions, a self-run server, or static files. Static files need no servers to run, scale or secure, and plain files work on any web server. Pull-request previews are the built site uploaded as a CI artifact. Code: web/apps/site/svelte.config.js (strict: true fails the build if any page cannot be prerendered). Explained on Development and automation.

D24. No cross-origin isolation

Decided: the site does not require the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. They are needed only for SharedArrayBuffer, which the engine does not use: a computation is stopped by terminating its worker. Requiring them would rule out hosts that cannot set headers and break embedding inside other sites. Code: from phase 3, web/packages/engine. Explained on Web showcase.

D25. Web tooling: pnpm, exact pins, ESLint and Prettier for web/ only

Decided: web/ is a pnpm workspace with Node 26 and pnpm 10.29.2 pinned (packageManager, engines, engineStrict, .node-version). Every direct dependency is an exact version at least two weeks old when chosen, and minimumReleaseAge makes pnpm refuse any version under a week old, which blunts compromised releases. Tests are Vitest (units) and Playwright on Chromium (against the built files, served by a small static server rather than a development server). ESLint uses typescript-eslint's strict type-checked rules plus the architecture standard's lintable parts (no module-level let); Prettier formats web/ and never Markdown, because it breaks MathJax formulas (.prettierignore at the root and in web/). Python keeps ruff and pyright. Options were npm or Yarn, version ranges, and Jest; pnpm's workspaces and strict dependency layout catch undeclared imports, and Vitest shares Vite's configuration. TypeScript stays on 6.0 until SvelteKit, svelte-check and typescript-eslint support 7. Code: web/package.json, web/pnpm-workspace.yaml, web/eslint.config.js, web/README.md. Explained on Development and automation.

D26. make check runs the web checks once web/ is installed

Decided: make check adds web-check when web/node_modules exists; WEB=1 forces it and WEB=0 skips it. CI runs the Python checks with WEB=0 and the web checks in a separate web job that always runs. Options were always requiring Node (forces every Python contributor to install it), never including the web checks locally (lets a web contributor push red), or this. Installing the workspace is the signal that someone works on the web code, and CI never skips either half. make clean also removes web build output but keeps node_modules, as it keeps .venv. Code: the web section of the Makefile, .github/workflows/ci.yml. Explained on Development and automation.

D27. Dependencies are updated by hand

Decided: no update bot or other remote service runs against the repository. Every dependency is pinned exactly (uv.lock, web/pnpm-lock.yaml), and pnpm refuses releases younger than seven days (minimumReleaseAge); an update is a deliberate local change that must pass make check WEB=1. Options were Dependabot or Renovate; both are remote services, which the project does not use. Explained on Development and automation.

D28. One match loop, and a pure session layer on it

Decided: engine.Match is the only place a move is decided, checked and applied; play_match (tournaments, audits, examples), the session layer's Session.step and the command line's Session.play all step it. The session layer (grounded_decision.session) does no I/O, starts no threads, handles no signals and reads no clock; a clock is injected by the caller that wants timings (the command line passes time.perf_counter). Options were a second loop for sessions beside play_match, or a session layer that times moves itself. One loop means the browser, the server and the terminal apply the rules identically, and no wall clock means a session runs in Pyodide and its frames are reproducible. A test runs every game with signal, subprocess and multiprocessing blocked and the clock and Thread.start replaced by failing functions; threading itself stays importable because the standard library imports it (Pyodide has it). Code: src/grounded_decision/examples/games/engine.py, src/grounded_decision/session/. Explained on Session protocol.

D29. Specs are validated by the command line's own code

Decided: loading, configuring and seating a game moved from play.py into GameModule (games/registry.py); play's functions delegate to it and SessionSpec.__post_init__ calls it, building the game and the agents and checking the world against their regimes. Options were a separate validator for the protocol, or a schema with per-game enumerations. One code path means a spec is refused exactly when play refuses the same options, with the same message, which a test checks for every kind of mistake. Code: src/grounded_decision/examples/games/registry.py, src/grounded_decision/session/spec.py. Explained on Session protocol.

D30. --view json keeps its format, as a documented projection of frames

Decided: play --view json prints the same lines as before, and each step line is defined as a projection of the session frame after that move (StepEvent.of_frame); a golden test compares the command line with the session layer byte for byte for every game on several seeds. Options were printing whole frames (every board, every move) or keeping the JSON view separate from frames. The projection keeps existing scripts working and the output small, while the test makes the two paths provably one. Code: src/grounded_decision/session/jsonlines.py. Explained on Session protocol.

D31. A small in-repo schema generator, and a small in-repo TypeScript generator

Decided: the JSON Schema is generated by session/wire.py (a few hundred lines, the subset the protocol uses: scalars, string enumerations, literals, tuple[X, ...], Mapping[str, X], unions with None, frozen dataclasses), and the TypeScript types by web/packages/protocol/src/codegen.ts, run by Node's own TypeScript support; a runtime validator interprets the same subset. Options were pydantic (a heavy dependency the package does not otherwise need, and one more thing to load in Pyodide) and json-schema-to-typescript or Ajv (well known, but a new dependency tree for a schema this small). Both generators refuse anything outside the subset, so they cannot drift silently, and tests check the generated files are current. Code: src/grounded_decision/session/wire.py, web/packages/protocol/src/. Explained on Session protocol.

D32. Protocol versions are checked against a recorded baseline

Decided: the protocol has its own semver, PROTOCOL_VERSION; protocol.baseline.schema.json records the last released schema, and a test sorts every difference into breaking (needs a major bump) or additive (needs a minor bump), ignoring descriptions. Readers must ignore unknown fields, which is what makes additions minor. Options were comparing against git history (needs history in CI) or a hand-kept changelog of the schema. A committed baseline works in any checkout. Code: src/grounded_decision/session/compatibility.py. Explained on Session protocol.

D33. Light theme tokens are derived by contrast rules; move notation is derived and verified

Decided: the design tokens' dark theme is the terminal palette as is; the light theme darkens each foreground until it reaches 3:1 contrast with the light board (WCAG's non-text minimum) and lightens dark backgrounds, so no colour is chosen twice. A legal move's notation is the game's optional move_notation hook, else its description or a part of it, accepted only if the game's parse_move turns it back into that very move. Options were a hand-made light palette and a new required notation hook in every game. Rules keep one source of truth, and the round-trip check means a notation can never name the wrong move (a test submits one for every game). Code: src/grounded_decision/session/tokens.py, src/grounded_decision/session/notation.py. Explained on Session protocol.

D34. Replays are gzipped JSON lines: keyframes, then one line of changes per part of the frame

Decided: a .gdr file (format 2) is gzip of UTF-8 text: a header line, the keyframes (a whole frame every 256 frames by default), one line per channel holding every change to one part of the frame (such as board.cells or decision.metrics) as [gap, operation], and a line of transition pictures. An operation sets a value, adds to a whole number, or splices an array; a frame's index is never stored. Options were a binary format (smaller still, but a second encoder and decoder to keep in step with the protocol), whole frames under gzip (simple, but 1.2 to 4 times larger, and more on big boards, whose previous frame lies beyond gzip's 32 KB window), a line per frame of set operations (format 1: every shifted list rewritten whole, every count a new number, and each frame's unlike changes side by side, so a complete 24 × 16 snake game took 2.1 MB), or channels. Grouping like changes is what deflate rewards: the same frames take 270 KB for the gallery instead of 730 KB, and 380 KB for that snake game. Operations stay generic, so a new frame field needs no format change; decoded frames share every unchanged part, so a renderer can skip unchanged rows by identity; keyframes bound seeking to 255 frames of changes; replay-info --bytes shows what each channel costs. Format 1 was never released, so readers read format 2 only. Code: src/grounded_decision/session/replay.py (ReplayBody, Channel), web/packages/replay/ (body.ts). Explained on Replay format.

D35. The content hash is of the canonical frames, and checked by re-recording

Decided: the header's content_hash is the SHA-256 of every frame's protocol JSON with sorted keys and no spaces, one per line, so it is independent of the keyframe interval, the deltas and the compression; replay-info --verify records the header's spec again and compares hashes, and a test does it for every game. The hash is computed in Python only, because JavaScript's JSON.parse cannot tell 1 from 1.0; the browser reader validates every frame against the protocol schema instead. Options were a hash of the file's bytes (changes with zlib builds and encoder choices) or no hash. Code: grounded_decision.session.replay.FrameStream, cli.py (replay-info). Explained on Replay format.

D36. The curated replays are committed, and checked to be current

Decided: make replays records the site's gallery (one replay per game, about 280 KB with its manifest) into web/apps/site/static/replays/ and the decoder's golden files into web/packages/replay/fixtures/, and both are committed; a Python test records each again and compares the decompressed content, ignoring package_version, and fails until make replays is run after a rule or agent changes. Options were generating them in the web build (the site's build and the web CI job would need Python and uv) or committing without a check (stale replays would show games the package no longer plays). Comparing text rather than bytes keeps the check independent of the zlib build. Every gallery game plays to its end, never cut short by --max-steps: a visitor sees how it finishes. Each stays under 128 KB (the complete snake and Breakout games are the largest, about 35 to 86 KB). Code: src/grounded_decision/session/gallery.py, tests/test_session_replay.py. Explained on Replay format.

D37. The replay player is DOM-free, on an injected scheduler

Decided: ReplayPlayer holds playback state only (index, speed, playing) and emits each frame with the previous one, its cause and an animation duration (0 for a cut); timers come from a Scheduler (setTimeout by default), and gzip from DecompressionStream, which browsers and Node both have, with an injectable inflater. The board renderer subscribes to it and does all drawing and motion, including prefers-reduced-motion. Options were a player driven by requestAnimationFrame or one that owns a canvas. Keeping time, decoding and drawing apart (the architecture standard's rule 3) lets Vitest test playback with a fake clock and lets the gallery, the <gd-replay> element and the inspector share one player. The bundle, validator and schema included, is 11.5 KB gzipped against a 40 KB budget, checked by pnpm run build. Code: web/packages/replay/src/. Explained in web/packages/replay/README.md.

D38. Three canvases redrawn by cell, and one pixel per cell for huge boards

Decided: BoardRenderer stacks three 2D canvases (the board's ground and highlight fills, the pieces, the outlines and arrows) at device resolution. Each cell has a signature (kind, detail, highlight); a new frame redraws, clipped, only the rectangles of the cells whose signature changed and of the running effects, and pieces are stamped from sprites drawn once per look, colour and size (on an OffscreenCanvas where there is one). A board of more than 16,384 square cells (a 320 × 240 snake is 76,800) is drawn as one pixel per cell into an ImageData, only changed pixels rewritten, scaled up without smoothing. Geometry works in units of a cell's inscribed diameter: squares, pointy hexagons for pointy boards and for square boards with offsets (Chinese checkers sends that), flat hexagons for flat; a hex board's extent comes from its on-board cells, a square board is always its full rectangle, so a game that shows or hides off-board cells (Tetris's preview) never changes size. Options were SVG, one canvas redrawn whole, or WebGL (D19). Measured on the benchmark page in headless Chromium: 0.8 ms median, 1.0 ms 95th percentile per frame on the 320 × 240 snake, against the 4 ms budget; the bundle is 23 KB gzipped. Code: web/packages/board/src/renderer.ts, geometry.ts, raster.ts, sprites.ts. Explained in web/README.md.

D39. 2048 tiles are drawn square, one cell per tile

Decided: a board made only of tile and tile_empty cells whose tiles span several cells (the protocol's 2048 tile is three text-shaped cells wide, with gap cells between, because the terminal draws it so) is compacted when it is indexed (BoardPicture.of): each tile becomes one cell of a grid of tile rows and columns, keeping its number, colour level and highlight. Options were drawing the cells as sent (wide, flat tiles), or changing the Python cells (which would change the terminal, the protocol fixtures and every replay). The compaction is a drawing concern, so it lives in the renderer. Code: web/packages/board/src/picture.ts. Explained in web/README.md.

D40. A move animates from what the frame says, and is cut, never queued

Decided: MoveAnimation builds a timeline from the picture on screen and the next frame: the frame's transition stills share 60 % of the move (the terminal's TRANSITION_SHARE, by their weights), tweening between stills when a piece moved by whole cells (a Tetris drop) and flipping pieces marked converting (Hexxagon); without stills, the piece at the end of move_cells slides along them (jump paths included), new 2048 tiles pop in and merged ones swell, and anything else that changed cross-fades; highlight cells burst (Tetris rows clearing). A new frame during an animation snaps the old one to its end first, so input is never queued behind motion. Reduced motion (prefers-reduced-motion, or the player's checkbox), a seek, a step back or a changed board shape make a cut. Options were per-game animation code or tweening every cell. Deriving motion from the protocol's own fields keeps "a new game needs no front-end code" true. Code: web/packages/board/src/animation.ts. Explained in web/README.md.

D41. Meaning never rests on colour alone

Decided: every highlight has an outline style as well as a colour (the terminal's zoomed boxes: heavy for where a move went, dashed for jumps and rejected proposals, double for pieces changing hands, light for the rest); a colour-blind-safe palette swaps the pairs that carry meaning by hue (one- and two-step targets, rejected and chosen moves, the two players' pieces and marks, the apple) for Okabe–Ito colours, and a test simulates protanopia, deuteranopia and tritanopia (Machado et al., 2009) and requires a CIE76 difference above 20 for each pair; a glyph mode adds the token file's seat shapes, target digits and Tetris letters. Each frame also has words: the board's role="img" name lists each side's pieces, and the player's live region reads the move, the gate's verdict, the event and the outcome when playback pauses or steps. Options were a single palette or a separate text-only view. Code: web/packages/board/src/theme.ts, looks.ts, describe.ts. Explained in web/README.md.

D42. Budgets and screenshots are checked in headless Chromium, with baselines per platform

Decided: /bench (prerendered, not linked) draws a synthetic 320 × 240 snake and an animated 24 × 24 one and reports each draw's main-thread time; tests/e2e/bench.spec.ts also records a Chromium trace of the page and checks generous bounds (under 16 ms, one frame at 60 fps) so a slow CI machine does not fail a sound build, printing the real numbers. Screenshot baselines (one frame of every replay, light and dark, reduced motion, one device pixel per CSS pixel) are kept per platform, because font rasterising differs; on a platform with none, the test writes them and passes, and CI uploads them for a person to review and commit. Options were a tolerance large enough to share baselines across platforms (it would hide real changes) or Docker for identical rendering (not available everywhere). Code: web/tests/e2e/, web/apps/site/src/routes/bench/. Explained in web/README.md.

D43. Pyodide 314.0.6, served from the site's own files

Decided: the engine runs on Pyodide 314.0.6 (CPython 3.14, numpy 2.4.6), pinned as the pyodide npm package. make web-engine copies the runtime from that package (its integrity is in pnpm-lock.yaml), downloads lark from PyPI (the file and SHA-256 uv.lock pins) and numpy from the Pyodide release (the SHA-256 in its pyodide-lock.json), copies the wheel make build made, and writes them with manifest.json into web/apps/site/static/engine/; downloads are cached in web/packages/engine/.cache/. The files are build output, never committed, and wheels sit under a directory named by their hash so a new build is never served stale. Options were loading Pyodide from its CDN at run time (a third-party host in the page's security policy and on every visit), committing the binaries (about 17 MB in the repository's history per Pyodide release), or vendoring at build time. The release was chosen at least six weeks old (published 25 August 2026); 314.0.7 was three weeks old. Code: web/packages/engine/scripts/, manifest.ts. Explained on The engine in the browser.

D44. The worker passes JSON text; one transport interface; every failure is a protocol error

Decided: the worker hands each request string to Endpoint.handle_text and posts the reply string back with the engine's own time for it, so only strings cross between JavaScript and Python. The page's Engine client validates each request with the protocol's validator before sending it and each reply on the way in (for long steps, optionally only the last frame); it speaks over a Transport (WorkerTransport now, a WebSocket to our own service in phase 9), starts the engine lazily with progress events, cancels by closing the transport (terminating the worker), and turns every failure into an EngineError carrying a protocol ErrorReply. Options were Pyodide proxies of Python objects (fast to call, but leaks and lifetimes to manage across the worker boundary), Comlink-style remote objects (a dependency and a second interface beside the protocol), or text. Text keeps the protocol the only interface, which our own service will speak too. Code: web/packages/engine/src/. Explained on The engine in the browser.

D45. Seeds hash the same on every platform, checked byte for byte

Decided: agents that randomise per position seed random.Random with portable_hash, which computes what 64-bit CPython's hash() gives for an integer tuple, in pure Python. hash() of integers depends on the word size (64-bit CPython reduces modulo 2^61 − 1, Pyodide's 32-bit WebAssembly modulo 2^31 − 1), so the browser played other Checkers, Connect Four and Go games than the command line; the first run of the golden check found it. Options were seeding from a string (platform-independent, but every recorded game, snapshot and replay would change) or emulating the 64-bit hash (nothing changes on 64-bit machines). make web-pyodide-golden plays every game on the phase-1 golden seeds in CPython and in Pyodide, through the browser's own code path, and compares the replies byte for byte. Code: src/grounded_decision/examples/games/hashing.py, web/packages/engine/scripts/golden_frames.py, golden.ts. Explained on The engine in the browser.

D46. Content Security Policy: the site's own scripts and 'wasm-unsafe-eval'

Decided: every page carries a <meta> Content Security Policy that SvelteKit writes with the hashes of its inline script (kit.csp, mode hash): default-src 'self', scripts from the site plus 'wasm-unsafe-eval' (Pyodide compiles WebAssembly), connect-src and worker-src 'self', no objects. Styles also allow inline style attributes, because SvelteKit's route announcer sets one; the hash of that attribute would change with SvelteKit releases. The worker's script gets the script policy as a response header listed in static/_headers, a plain list of paths and headers that the owner's web server applies (the end-to-end tests' static server applies it too, so Pyodide is tested under it). Options were a header-only policy (cannot carry the per-build hash of the inline script on a static host) or no policy until phase 10. Code: web/apps/site/svelte.config.js, web/apps/site/static/_headers, web/tests/e2e/serve.ts. Explained on The engine in the browser.

D47. Names are imported on first use, so the engine starts without numpy

Decided: grounded_decision/__init__.py and grounded_decision/learn/__init__.py keep their public names but import them on first use (PEP 562 __getattr__, with the imports repeated under TYPE_CHECKING for the type checker), so importing the session layer imports neither numpy nor the modules that need it; a test plays every game with numpy unimported. The engine starts without numpy (hosted beside it for phase 6), which takes 0.15 s and 2.8 MB gzipped off its start, and the session layer's import fell from 0.42 s to 0.09 s in Pyodide (51 ms to 29 ms in CPython). Options were eager imports (every page load pays for numpy and the whole package) or moving the session layer out of the package. Code: those two __init__.py files, tests/test_session_protocol.py. Explained on The engine in the browser.

Decided: protocol 1.1.0 adds select (a cell) and its selection reply (the piece, its highlights, one legal move per target): games with select and cell_name hooks (Hexxagon) mark their own targets, as the terminal prompt does, and any other game's targets are the legal moves that start on the cell. The page turns clicks, taps and keys into moves from the cells each legal move lists (MoveTargets, PlacementCursor for Tetris, DirectionKeys for Snake and 2048), with no game-specific code. Options were computing Hexxagon's clone and jump targets in TypeScript (a second implementation of its geometry) or a selection mode per game in the page. The addition is minor under the protocol's rules (D32): a new message, nothing removed. Code: src/grounded_decision/session/session.py (Session.select), protocol.py, web/apps/site/src/lib/moves.ts. Explained on Session protocol.

D49. The wire encoder compiles one function per protocol class

Decided: WireEncoder builds, once per dataclass, a function that is a single dictionary display: fields whose values are already JSON (scalars, string enumerations, unions of those) are copied, tuples and mappings of such values are copied in one call, nested arrays become comprehensions, and only records are encoded recursively. The function is compiled from the field names, as dataclasses compiles __init__. Encoding a 20 × 20 snake frame got about three times faster in CPython, and the engine's snake throughput in Pyodide rose from 1,100 to 2,700 moves a second with every frame on the wire. Options were the generic recursive encoder (a type test per value) or hand-written encoders per class (drift from the dataclasses). Code: src/grounded_decision/session/wire.py. Explained on The engine in the browser.

D50. The test suite runs in Pyodide under Node, and the budgets on an emulated network

Decided: make web-pyodide-test runs the session, game and learning tests inside Pyodide in Node, against the wheel the browser loads, with Pyodide's own pytest; tests that need a terminal, subprocesses, threads or the wall clock are marked needs_native and skipped there. make web-pyodide-golden checks determinism the same way, and CI runs both in a pyodide job. The live budgets are measured in headless Chromium against the built site: the first live move on a fresh cache over an emulated 40 Mbit/s connection with 20 ms of latency and again on the warm cache, the engine's throughput, latency and memory on /bench/engine, and the sizes of the page's scripts and of the download; generous bounds fail a broken build without failing a slow CI machine, and the numbers are printed. Options were a browser-based test runner (slower, and the same Pyodide) or measuring over localhost only (a cold cache would then cost nothing). Code: web/packages/engine/scripts/pytest.ts, web/tests/e2e/engine.spec.ts. Explained on The engine in the browser.

D51. A snake's body is one ordered path on the wire; front ends derive its notes

Decided: protocol 1.2.0 adds Board.paths: ordered cell lists with a semantic kind (today body, head first). A game's optional cell_paths(state) hook returns CellPath values; the terminal views expand them into per-cell details (CellPath.details: order i/(n-1), connections to neighbours along the path, the head's heading), byte for byte what the snake's cell_details gave, and the frame sends the path instead, with no details for its cells. The web board derives the same notes (pathNotes, checked against the old frames of a whole replay); a listed detail still wins. A snake move changes the path at its two ends, where a note per cell changed every body cell's order: the snake's frame building became O(length) at C speed (no Python per cell), the wire encoder writes nested records as inline dictionary displays, and the engine's snake throughput in the browser's worker rose from 2,700 to 4,100 moves a second with every frame on the wire (Pyodide under Node 2,900 to 4,400, CPython 4,700 to 7,500); a full 10 × 10 snake replay fell from 1,365,513 to 360,255 bytes, and the gallery's snake excerpt grew from 400 to 1,200 moves within its 100 KB budget (the whole game would need 360 KB). Options were keeping details and recomputing them faster (the replay size stays), a compact per-cell order encoding (still every cell changes per move), or a new splice operation in the replay format (format version 2, and still O(length) frames for live play). Additive under D32: an optional field and a new enumeration; a 1.1 reader ignores paths and draws the body without its gradient. Code: views.py (CellPath, board_image), snake.py (cell_paths), session/frame.py (BoardPath), session/wire.py, web/packages/board/src/picture.ts, raster.ts. Explained on Session protocol.

D52. One design system in one CSS file, with the system's fonts

Decided: web/apps/site/src/app.css holds the site's tokens (colours for light and dark, a type scale, a 4 px spacing grid, radii, shadows, the 44 px least target size) and the few shared components (buttons, fields, cards, tags, verdict badges, callouts, disclosures), written with :where() so a component's own style always wins; components use the tokens only. Text uses the system's font stack. Options were a CSS framework (Tailwind: a build step and class soup across Svelte files), per-component styles only (the play and replay pages had drifted apart), or a web font (a download on every first visit, a flash of other text, and a Lighthouse cost). One file keeps light, dark and every page one product, and the board's colours still come from the token file of D22. Code: web/apps/site/src/app.css. Explained in web/README.md.

Decided: the colour scheme, colour-blind-safe colours, glyphs and motion are a DisplaySettings owned by the root layout and shared through Svelte's context, stored in localStorage (each access in a try, so a refusing browser just keeps them for the page view), and written as data-theme and data-motion on <html>. A small script in app.html applies the stored scheme before the first paint; the Content Security Policy allows it by its hash, which svelte.config.js computes from app.html itself. Options were URL parameters (a shared link would force the sender's colours or motion on the recipient, which is wrong for accessibility settings) or per-page checkboxes (the phase 2 replay page had them; nothing carried over). Code: web/apps/site/src/lib/settings.svelte.ts, SettingsDialog.svelte, app.html, svelte.config.js. Explained in web/README.md.

D54. The decision inspector is a DOM-free package, derived from the protocol

Decided: web/packages/inspector turns a frame into a DecisionInspection (proposal, move played, verdict tone, fallback, reason, every check with the catalogue's description, regime against world) and a game into a DecisionTimeline (one mark per position, overrides indexed, bins for a track of any width); the Svelte components only draw them. When an override's frame does not mark the rejected proposal and the fallback itself, the inspection derives rejected and chosen highlights from the frame's proposal_cells and move_cells, the outline styles of D41. The regime check is honest by construction: a probabilistic agent whose frame carries the world's caveat reads "Assumption not met", with the caveat's own words. Options were inspector logic inside the components (untestable without a browser) or per-game override highlights in Python (every game, fixture and replay would change). Code: web/packages/inspector/. Explained in web/README.md.

D55. The regimes join the catalogue (protocol 1.2.0)

Decided: describe's catalogue gains regimes, each name, label and description from Regime, the words play GAME --explain AGENT prints, so the site's explain panes and the command line cannot disagree; agents, checks and worlds already came from AGENT_DETAILS, CHECK_DESCRIPTIONS and WORLDS through the catalogue. A new required reply field is additive under D32; it shares protocol 1.2.0 with the snake's paths (D51), and the gallery replays were recorded again (their headers name the protocol version). Options were copying the three sentences into TypeScript (two sources) or a separate generated file beside the design tokens (a second channel for what describe is for). Code: src/grounded_decision/session/catalogue.py (RegimeInfo). Explained on Session protocol.

Decided: a play page's URL holds, beyond play's flags, moves (the person's moves so far, in the game's notation, comma-separated) and frame (the position on show). Opening such a link starts the engine at once and LiveSession.open replays the moves (the agents answer as before, because the seed fixes them) or, when watching, plays on to the position, paused; a game that can no longer be replayed says so and keeps what it reached. The copied command carries only the flags, since play has no way to take scripted moves. Options were links to the settings only (a shared override could not be shown), or encoding whole frames (long URLs, and frames the engine did not make). Code: web/apps/site/src/lib/play.ts (PlaySpec.moves, frame), live.ts (OpenOptions), history.ts. Explained in web/README.md.

D57. Moves are announced in a log, summarised at speed

Decided: the play page announces each move in a visually hidden role="log" region (each announcement a new line, read once); the replay page keeps its atomic live region. MoveAnnouncer passes every move through while paused, stepping or playing against a person, and while a game plays on its own it summarises at most once every four seconds ("212 moves, 3 overrides. Latest: …"), flushing the rest when play stops. Options were announcing only on pause (a person hears nothing while watching) or every move (hundreds a second at top speed flood a screen reader). Code: web/packages/inspector/src/announcer.ts, PlayView.svelte. Explained in web/README.md.

D58. Lighthouse CI on the replay-first pages, pinned, in Playwright's Chromium

Decided: make web-lighthouse runs @lhci/cli 0.15.1 (an exact pin) against the built site, served by the end-to-end tests' server with the site's headers, in the Chromium that Playwright already installs: the home page, the gallery, a replay page and a play page before Python loads, three runs each with Lighthouse's default mobile emulation, failing below 95 in any category. To stay fast the pages take their catalogue data from build-time server loads (so the describe catalogue, about 14 KB gzipped, is no page's script) and the home page's example imports the player and renderer after the first paint. Options were Lighthouse on a hosted preview (needs the preview host decided above) or PageSpeed Insights (a third-party service and network in CI). Measured: performance 99 to 100, accessibility, best practices and SEO 100. Code: web/tests/lighthouse/lighthouserc.cjs, the Makefile, .github/workflows/ci.yml. Explained in web/README.md.

D59. Compare and audit are session operations; Python tallies every table (protocol 1.3)

Decided: the protocol gains compare and audit requests that run the command line's own code, refactored so both paths share it: Tournament (compare.py) plans a comparison's games by index and plays any one of them purely (no clock, no I/O), and Tournament.table tallies any set of finished games in index order, so a table depends only on which games are in it; AuditSetup and AuditRun (audit.py) resolve audit's options and play the match a move at a time, judging before each move is applied (Match.decide, then Match.play). A compare request names games to play and carries results other engines finished; its reply holds the new games and the table of every game the engine knows. An audit request plays a chunk of moves and returns the judged decisions and the report so far. Options were tallying in TypeScript (a second Clopper–Pearson implementation, and numbers the tests could not tie to the command line) or one request per whole comparison (no table until the end, and no parallelism). Golden tests play comparisons split over two endpoints in shuffled order and audits in chunks and compare every number with compare --json and audit --view json. Code: src/grounded_decision/session/tournament.py, auditing.py, examples/games/compare.py, audit.py, tests/test_session_compare_audit.py. Explained on Session protocol.

D60. A comparison runs on a pool of engines, one table whatever the order

Decided: the compare page runs an EnginePool (one Pyodide worker per engine, default one per core but one, at most four; a setting up to eight) and a TournamentRun hands out games as engines free up, a batch that doubles while games are quick. Each request carries the results that engine has not yet seen; the page shows the table that covers the most games, and when every game is in, asks for the table of all of them. Because Python tallies in index order, the final table is identical however the games finished, which a Vitest test checks over shuffled completions. Cancel stops handing out games and terminates the workers (D24: no shared memory, so terminating is the only stop). Options were one worker (a 40-game comparison takes minutes) or a SharedArrayBuffer work queue (needs cross-origin isolation). Code: web/packages/engine/src/pool.ts, tournament.ts, web/apps/site/src/lib/components/CompareView.svelte. Explained in web/README.md.

D61. An audit keeps a position only for its disagreements

Decided: every judged decision travels with its values and regret, but only a disagreement (positive regret) carries its position: the board before the move (taken while it is current, since some games' states go stale), the cells of the move played and of the oracle's move, and the frame after the move, whose decision the inspector reads. The audit page lists disagreements as buttons; one opens the position, with the move played (dashed) and the oracle's move (heavy outline) marked, and the inspector. Options were a frame per decision (a snake audit's report is 2.6 MB already without frames) or reopening the match on the play page (the audit seeds its agents as audit does, not as play does, so the game would differ). Code: auditing.py (AuditedMove), web/apps/site/src/lib/audit.ts, AuditView.svelte.

D62. Teaching pages are replay-first: lessons recorded by the package, analyses live

Decided: each game's teaching page (/learn/GAME) explains one idea on positions the package itself played and analysed. An analysis (Analysis: numbers on cells, tags, routes through cells, facts) comes from the game's lesson module in session/lessons/, which calls the game's own solver; make lessons records each game's scenes (real sessions, some with scripted moves) into web/apps/site/static/lessons/GAME.json, and a test keeps the files current. The page fetches its lesson (no Python), draws each step's analysis over the board, and where interaction helps (Minesweeper, Connect Four) a live board asks the engine's new analyse request after every move. The lesson type is in the protocol schema as a document, so TypeScript validates it with the generated types. Options were computing analyses in TypeScript (a second implementation) or live Python only (a 7 MB download before the first picture). Code: src/grounded_decision/session/lessons/, web/apps/site/src/lib/learn/, LessonPage.svelte, Scene.svelte, BoardView.svelte, AnalysisLayer.svelte. Explained in web/README.md.

D63. Every claim names its test; a checker and the build enforce it

Decided: a teaching page states each theorem or guarantee inside <Claim test="tests/FILE.py::test_name">, optionally with its paper. tests/test_site_claims.py reads every marker in the site's source and fails unless the test exists and runs in the default suite (not skipped, not expected to fail, not marked slow), so a claim is checked whenever make test is green; it also fails when a game has no teaching page or a page makes no claim. make claims records each test's file and line (claims.generated.json) and its source (for the site's own /source/FILE pages; VITE_SOURCE_URL points the links at a repository host instead); a claim whose test is not recorded fails the build. Options were links written by hand (they rot when a test moves) or linking to a repository host now (the host is not decided, see Open). Code: tests/test_site_claims.py, web/apps/site/src/lib/learn/claims.ts, Claim.svelte, routes/source/. Explained in web/README.md.

D64. The demos beyond games are stateless session operations (protocol 1.4)

Decided: protocol 1.4.0 adds three requests, learn, monitor and runtime (replies learned, monitored, decisions), answered by grounded_decision.session.demos. Each request carries everything it needs and the reply is complete (every combination's test, the monitor after every outcome, one step per event), so a front end replays a change by sending the request again. The numbers come from the code the command line runs: learning_run is the body of learn_parameters (which now returns its learned), the drift is parameter_learning.LiveTraffic (which the example's report also uses), and the stepper runs the reference service's pure core, moved into examples/reference_core.py (the versions, DecisionEngine, routing, the device-neighbourhood walk; re-exported by reference_service) over the demo rows in memory, because the browser has no SQLite or HTTP server. Golden tests compare the replies with grounded-decision learn, grounded-decision examples parameter_learning, DecisionService on SQLite and the numbers the docs quote. Options were sessions holding state between requests (as games do; nothing here needs it), or a JavaScript reimplementation of routing and the monitor (two sources). Code: src/grounded_decision/session/demos.py, learn/calibrate.py (learning_run, CandidateTest), examples/reference_core.py, examples/parameter_learning.py (LiveTraffic, how_to_history). Explained on Session protocol.

D65. numpy is loaded on demand, by the pages that ask for it

Decided: WorkerTransport takes packages (optional Pyodide packages the manifest hosts); the worker's boot message carries them and EngineHost.boot loads them with lark and the wheel, in the progress bar's packages phase. Only /parameters, /monitor and /runtime ask for numpy (the reference service's evidence imports it through integrate); the game pages never download it. Options were loading numpy on the first demo request inside Python (a second, unreported wait) or always (2.8 MB gzipped for every play page). Code: web/packages/engine/src/transport.ts, messages.ts, host.ts, worker.ts. Explained on The engine in the browser.

D66. Demo pages are preview-first: the engine's own replies, recorded

Decided: make protocol records each demo page's reply for the documented example's settings into web/apps/site/static/demos/ (grounded_decision.session.demo_artefacts; a test keeps them current), and the pages read them at build time, so the first paint shows the numbers the docs quote with no Python; the first change starts the engine. Floats in these files and the demo fixtures are written to ten significant digits, so they are the same on every platform that generates them. The how-to's 3,000-case history is shipped as a file too and sent as cases: numpy's uniform fuses a multiply and an add on Apple silicon and not in WebAssembly, so drawing it in the browser gave other last bits and another dataset fingerprint (D69 has since made the draws portable; the file stays, so the browser does not draw it again). Options were fetching the previews after the first paint (an empty page first) or computing them live on every visit (seconds of Python before any number). Code: session/demo_artefacts.py, web/apps/site/src/lib/demos/previews.ts. Explained in web/README.md.

D67. Charts are a small SVG component, with their data as a table

Decided: Plot.svelte draws lines, a band, points by kind, rules and markers on linear or log axes in SVG sized to its container; the geometry (scales, ticks, paths, thinning) is DOM-free in lib/demos/charts.ts and unit-tested; every chart has a text summary for screen readers, a legend in words and its data in a table behind a disclosure (built only when opened). Options were a chart library (D3, Chart.js: 60 to 200 KB for three charts) or Canvas (no DOM to test or read). Code: web/apps/site/src/lib/components/demos/Plot.svelte, lib/demos/charts.ts. Explained in web/README.md.

D68. A visitor's CSV is read in the browser and never uploaded

Decided: /parameters parses a CSV with HistoryCsv (RFC 4180 quotes, CRLF, a byte-order mark; the history line's fields flattened; errors name the line and column) and sends the cases to the engine in the page's own worker; nothing leaves the machine. Download history.jsonl writes the same cases in the format grounded-decision learn reads, so the command line computes the same dataset fingerprint. Options were JSON Lines only (what the CLI reads, but not what people export from a spreadsheet) or parsing in Python (a worse error for a malformed file, after the engine's download). Code: web/apps/site/src/lib/demos/csv.ts. Explained in web/README.md.

D69. Learning's numbers are the same on every platform

Decided: every number that enters a fingerprint, a decision or a documented output is computed so that it cannot depend on the processor or the C library (the Open question's option (a), made exact rather than rounded at a fixed precision). Two causes were found by running the engine in Pyodide: the Clopper-Pearson bounds were found by bisecting a tail computed with the C library's lgamma and exp (upper_confidence_bound(1, 120, 0.95) was 0.038920846860168785 on macOS and 0.03892084686016822 in Pyodide, so the how-to's 5% example had another params@ fingerprint in the browser), and numpy's uniform and normal(loc, scale) fuse a multiply and an add on Apple silicon but not on x86-64 or WebAssembly, while its beta also calls the C library, so the documented synthetic histories were specific to the machine that drew them.

  • Binomial tails are exact. A float rate is exactly a / 2**m, so the tail is a ratio of integers, which learn.statistics computes with Python integers. binomial_cdf rounds it to nearest, p_value_risk_below rounds it up, and the learner certifies with certifies_risk_below, which compares the exact tail with (1 - confidence) / tests as rational numbers, so no rounding can certify what the exact test rejects.
  • Bounds are rounded outwards to 12 significant digits. The upper bound is the smallest 12-digit decimal whose float passes the exact test, the lower bound the largest; the old float bisection only chooses where the search starts. A reported bound is therefore never less conservative than the exact one (by at most one part in 10^11), prints as its 12 digits (0.0389208468602), and is the same everywhere. Results are cached per count; learning on the how-to's 1,200-combination grid takes about 0.25 s instead of 0.05 s.
  • Synthetic draws are portable. examples.portable_random draws uniforms as low + (high - low) * u in Python from the raw 64-bit word (u = top 53 bits times 2**-53), computes exp and log in the decimal module, normals by the polar method and gamma and beta variates by Marsaglia-Tsang. The how-to's history, the parameter_learning example's authorisations and the how-to's make_history.py use it. The examples whose normals feed only snapshotted statistics (outcome_shift, live, combo, markets.desks) keep numpy's standard normal, so their words and their x86-64 values are unchanged, and apply the mean and scale as separate array operations (portable_random.normals). numpy's random(), integers, permutation and choice without weights are integer or exact operations and were already portable.
  • Evidence floats are rounded once. Regenerating the demo files in Pyodide showed one more cause: lambda_against_label_mix squared shares with **, which calls the C library's pow. It is now one correctly rounded ratio of integers, and d_lambda_sq multiplies λ * λ; the learner calls the same d_lambda_sq as Module.decide. The demo files are now checked byte for byte in Pyodide too.

Every fingerprint and number they produce was regenerated once: the how-to's history is now data@6575a29dabe3 (was data@2b3afb2c32b5) with the same counts, and its 5% result params@e55f023199be (was params@29497e9f6264, params@384666844c09 in the browser). The example's beta draws come from another algorithm, so its history is another sample of the same world (data@83aa73d12869, was data@a6f121a9dcfd) and its numbers changed, as did the build-a-service tutorial's, which uses it; the changelog lists every fingerprint. tests/test_portability.py records fingerprints, bounds, p-values and draws as exact hexadecimal floats and runs in the Linux CI job and in Pyodide (make web-pyodide-test); the /parameters end-to-end test checks that the browser computes the CPython fingerprint.

One residue is left, by design: the knife-edge window uses the C library's log of the case count (np.log in the learner, math.log in Module.decide, as before). A last-bit difference there can change a decision only for a case whose link density is within one rounding unit of the window's edge; the recorded fingerprints would catch it. Options were rounding the bounds to 12 digits after the float bisection (simpler, but a rounded float can still differ when the float itself does, and rounding to nearest can make a bound less conservative), documenting per-platform fingerprints, or rounding only inside the fingerprint (the decisions could still differ). Code: learn/statistics.py, learn/calibrate.py (learning_run), examples/portable_random.py, examples/parameter_learning.py. Explained on Learning settings with a guarantee.

D70. The elements are custom elements over the site's own packages

Decided: <gd-replay>, <gd-game>, <gd-demo> and <gd-ambient> are standard custom elements written in TypeScript with no framework. Each has an open Shadow DOM. The board renderer, the replay player, the inspector, the engine and the models draw and run them, the same code the site uses. The DOM-free models that both need moved from the site into web/packages/models: the catalogue, PlaySpec, LiveSession, the move targets, the demos' settings, the chart geometry and DemoRunner. The site keeps its own catalogue subclass, SiteCatalogue, which adds the cards and labels. Attributes are the command line's flags, and each attribute is also a property. The elements send ready, frame, move, verdict and error events. These, with the parts and custom properties, are the stable surface listed on Embed the games and demos. The options were compiling the site's Svelte components as custom elements (they depend on the layout, the settings context and app.css) or a second implementation for embeds. Code: web/packages/elements/src/, web/packages/models/src/.

D71. One self-contained versioned directory per release, a scripts-only alias, hashes in a manifest

Decided: make web-elements writes dist/elements/VERSION/. It holds gd.js and its chunks, the engine's worker, the engine's files (the same files make web-engine writes for the site), the gallery's replays, catalogue.json, the demos' recorded replies and integrity.json (SHA-384 of every file except the engine's, which engine/manifest.json hashes). The directory also holds dist/elements/MAJOR/, a copy of the scripts only. The bundle finds its files at ../VERSION/ from its own URL, so the alias needs no second copy of the 18 MB engine. The site's build mirrors the directory into /elements/, make dist gathers everything into dist/web/, and the owner copies that to their own nginx server. The elements are versioned independently of the package and start at 0.1.0. The options were copying the engine under the alias as well (another 18 MB per major version) or a server redirect for the alias (the end-to-end server, and any server configured from files, would need redirect rules). Code: web/packages/elements/scripts/build.ts, web/apps/site/scripts/embeds.ts.

D72. Lazy at every step; the replay elements' first load is budgeted at 60 KB

Decided: gd.js only defines the elements (9.7 KB gzipped). An element loads its board and player when it comes within 200 px of the viewport. Python loads only when the visitor first interacts: Play live, a click or a key on <gd-game>'s board, or a change of a <gd-demo> setting. Until then <gd-game> plays its game's gallery replay and <gd-demo> shows the engine's recorded reply (D66). When a page asks for settings other than the documented example's, <gd-demo> waits for a button press before it downloads the engine. The build fails if gd.js plus what <gd-replay> and <gd-ambient> load before their first frame is over 60 KB gzipped. Today it is 53.4 KB, most of it the board renderer and the protocol's validator. The options were one bundle per element (shared code downloaded twice on a page with two elements) or a single file (every page pays for the engine client and the demos). Code: scripts/build.ts, base.ts (whenVisible), game-element.ts, demo-element.ts.

D73. The engine's worker starts directly on its own origin and through a blob: module elsewhere

Decided: a page may not start a worker from a script on another origin. WorkerTransport therefore takes an optional workerClass: the class that constructs the package's own worker. The engine's createWorker names it Worker locally, so the bundler still finds and bundles worker.ts once. The elements pass BundleLocation.workerClass(). When the worker script is on the page's origin, that class starts it directly. Otherwise it starts a one-line blob: module that imports it. The worker resolves the manifest's absolute URL, so Pyodide's files come from the bundle's server. An embedding page's policy needs worker-src blob: and the server (a module worker's imports are checked against worker-src), script-src and connect-src for the server, and 'wasm-unsafe-eval'. The server sends Access-Control-Allow-Origin for /elements/. The end-to-end tests run a live game and a live demo under exactly that policy from another origin. The options were ?worker&url with a custom factory (it bundled a second copy of the worker) or requiring every embedding page to serve the worker itself. Code: web/packages/engine/src/transport.ts, web/packages/elements/src/location.ts.

D74. The theme follows the host page, then the system; styling is custom properties and parts

Decided: an element without a theme attribute uses its computed color-scheme when that names one scheme. MkDocs Material's dark palette sets it, and so does any page that declares one. Otherwise the element follows prefers-color-scheme. It re-reads the scheme when the root's or body's attributes change, and exposes it as :state(dark) and :state(light). Colours are the site's tokens, overridable as --gd-* custom properties. Every visible piece is a ::part(). Styles are one constructed style sheet adopted by every shadow root, which a page's style-src does not restrict. The board's own pixels follow the scheme, the palette and glyphs. The live region is summarised at speed by the inspector's MoveAnnouncer, as on the site's pages (D57). The options were a theme attribute only (every host page would have to wire its toggle to it) or inline <style> elements (blocked by a strict style-src). Code: base.ts, styles.ts, stage.ts.

D75. Iframe embeds are static pages that only they may be framed; oEmbed is a static file

Decided: the site's build writes /embed/replay/NAME/ and /embed/play/GAME/, one small HTML page per gallery replay and per game. Each page is one element with the script tag and its integrity, under its own strict <meta> policy. It writes /oembed/replay/NAME.json (rich, the iframe as html; absolute URLs when GD_SITE_ORIGIN is set). Each replay page links its file with <link rel="alternate" type="application/json+oembed">. _headers sends frame-ancestors * for /embed/ and frame-ancestors 'self' for the rest of the site. The options were SvelteKit routes (they would hydrate the whole site's layout inside every frame) or an oEmbed endpoint (it needs a server; a static file is the same answer). Code: web/apps/site/scripts/embeds.ts, static/_headers.

D76. The docs embed the elements' local build through a MkDocs hook

Decided: tools/mkdocs_elements.py, listed under hooks: in mkdocs.yml, does three things:

  • It copies the elements' local build into the docs site as elements/.
  • It adds the external module script tag, with its integrity hash, only to pages that contain a <gd-*> element. Nothing is inline.
  • It fills 0.1.0 and sha384-6ngHS4QbtmYZXBAXeEROtUlaC1J1bwWVBGVmla2OSTmegUQl9LUs9I5alme3H9xF in the embedding guide.

Once web/ is set up, make docs builds the elements first. Without a build, the strict docs build still passes, and the browser shows each element's children, which are the fallback text. The games pages carry a <gd-game> block. The learning, provenance, monitoring and runtime pages carry a <gd-demo> inside their existing "Try it in your browser" tip. The options were an extra_javascript entry on every page (every page would download the script and none could carry an integrity attribute) or links only (the plan asks for the widgets in place). Code: tools/mkdocs_elements.py, mkdocs.yml, Makefile (docs, web-elements).

D77. Our own service is raw ASGI on uvicorn and websockets, pinned exactly

Decided: grounded_decision.service is an ASGI application written without a framework (ServiceApp in service/app.py, four routes), served by uvicorn with its sans-I/O websockets protocol and h11, installed by the package's service extra with exact pins (uvicorn==0.54.0, websockets==17.1; uv.lock pins the rest, click and h11). The dev group includes the extra, so its tests always run. The options were the standard library alone (a WebSocket server by hand: framing, masking, fragmentation, ping and close handling are easy to get subtly wrong, and that code would be ours to maintain), Starlette on top (routing and a test client we do not need for four routes, plus anyio), or aiohttp (a larger, different stack). Requests run on a thread pool so the event loop keeps serving other sockets; one process uses one core, and more cores mean more instances of the systemd unit, one per port, listed in nginx's upstream. The reference decision service's routes moved out of its standard-library handler into DecisionRoutes (examples/reference_service.py), which both servers call, so the API under /api/decisions/ is the reference service's, request for request. Code: src/grounded_decision/service/, pyproject.toml; guide Run our own service.

D78. The service's limits are the protocol's own errors; the protocol stays 1.4.0

Decided: a request the service will not run gets an error reply with an existing code and a message that names the limit: message for a message too large, a binary frame or too much work in one request (moves, games); spec for a comparison with too many seeds (the command line's code for a bad --seeds); session, the protocol's "not now", for the rate, capacity and a request past its time limit (the socket then closes with 1013, since its session is no longer known). An error the engine could not match to a request has a null id; the TypeScript Engine now gives it to the oldest request waiting. The option was new codes (limit, busy), a minor protocol release touching the schema, the generated types and every client; nothing a client does differs between them and the existing codes, which already carry the meaning. The per-request work limits sit below the protocol's own maxima and are settings (GD_SERVICE_MAX_STEPS and the rest). Code: service/limits.py (Refusal, WorkLimits, RateLimiter, SessionSlots), web/packages/engine/src/engine.ts; the table is in Session protocol.

D79. One setting picks the engine; the worker is the service's fallback

Decided: EngineChoice (web/packages/engine/src/choice.ts) is the only switch: the site's build setting VITE_ENGINE (worker by default, or service; anything else fails the build), overridden by a page's ?engine= for testing. The service is reached on the page's own origin (/ws/), so the pages' connect-src 'self' policy is unchanged. With service, WebSocketTransport hands every request sent so far to a worker when its socket cannot open or the service turns the session away before answering, so a page still plays with the service off or full. The parameter learner keeps the worker whatever the setting, because it reads a visitor's own file, which never leaves the browser (D68). The embeddable elements keep the worker too: they run on other origins. The options were a setting per page (more to keep in step) or no fallback (a stopped service would break every live page). Code: choice.ts, socket.ts, web/apps/site/src/lib/engine.ts.

Decided: make dist-service writes dist/service: the wheel and every dependency downloaded for the server's platform and Python (SERVICE_PLATFORM, SERVICE_PYTHON), the requirements with their hashes exported from uv.lock, the nginx snippets, a systemd unit per port, run.sh, release.sh, and RELEASE, VERSION and SHA256SUMS. make build now writes the package's wheel and sdist into dist/python and clears only that, so it can run inside the web build's chain without removing dist/web or dist/service; each part of make dist clears only its own directory (a test checks the dry run). On the server, release.sh builds each release in its own directory with its own Python environment, offline (pip --no-index --require-hashes), switches the current link atomically, restarts, and waits for every /healthz to report the new version; when one does not, it switches back by itself. rollback and activate reuse an installed release, so going back needs nothing from the build machine. The service logs JSON lines to standard output (the journal) with no address, header, game, move or path; the ASGI server's access log is off and its own logger, which names client addresses, is kept at warning; nginx passes no X-Forwarded-For and its access log is off for the service's locations. The options were a container image (a registry or a runtime on the server), a package index (third-party), or installing over the running release in place (no rollback without the old files). Code: ops/service/, tools/service_release.py, Makefile (dist-service, serve, service-load), service/logs.py; guide Run our own service.

D81. The ambient screen plays replays from a manifest; its library is recorded locally and rotates daily

Decided: <gd-ambient> and /ambient play .gdr replays only, so an ambient screen never loads Python. They read a manifest (the gallery's replays/index.json, or /ambient/index.json with the library attribute), keep the replays whose name or game the games attribute lists, and play them in an order shuffled by seed, reshuffled by the same seed at every pass so a day-long screen does not repeat itself and two screens with one seed agree. make ambient-replays records a library of the eight backdrops of the web plan, three of each by default, from seeds derived from the date, into build/ambient/ (never committed). Each replay is read back and verified as replay-info --verify does before the manifest lists it with its content hash and outcome; the same seed and package version are not recorded twice. make dist copies the library into dist/web/ambient/, and the site's build into /ambient/; without a library the site's build writes a manifest of the committed gallery's replays, so the page never asks for a missing file. The nightly job records one as a test only and uploads nothing. The session package does no I/O (Pyodide runs it), so writing the files and the worker processes live in python -m grounded_decision.session. The options were a committed rotating library (a commit a day, and a growing repository), recording in the browser with Pyodide (the CPU budget), or a service that hands out new games (a running server for a background). Code: session/ambient.py, session/__main__.py, web/packages/elements/src/playlist.ts, ambient-show.ts, web/apps/site/scripts/ambient.ts, Makefile.

D82. Low power is a state machine over what the browser reports; the screen draws at 30 frames a second, 15 on battery

Decided: PowerPolicy turns four reports into one mode: the page hidden (Page Visibility) pauses everything; prefers-reduced-motion shows still positions that change every 8 seconds or more; a discharging battery (the Battery API, where there is one; none counts as plugged in) or prefers-reduced-data draws at most 15 frames a second; otherwise at most 30. A background needs no more, and the cap halves the drawing against the display's 60. The cap is a PacedClock given to the board renderer in place of requestAnimationFrame; a move animates for at most 300 ms and the board is still for the rest of it; replays play at 1 to 8 moves a second and a game longer than its pace cuts to its end. Kiosk mode asks for fullscreen on the first click, tap or key (browsers allow it only on a gesture), hides the cursor after 3 seconds at rest, and asks for a screen wake lock whenever the page is visible, falling back silently. The CPU budget is measured in Chromium's own headless mode, which draws with the machine's GPU as a laptop's browser does, with a trace of every thread; on a machine with no GPU the canvas is rasterised in software and the test's bound is 10% instead of 5%. The options were the display's own frame rate (twice the work for no visible gain on a background), pausing on battery (a dark screen), or measuring in the headless shell (software rendering, which no laptop uses). Code: power.ts, sensors.ts, paced-clock.ts, kiosk.ts, ambient-show.ts, web/tests/e2e/ambient.spec.ts.

D83. Captions are the deciding check, one every six seconds, overrides first

Decided: a caption is a move's verdict and the check that decided it, from the decision record: on an override or abstention the first failed check and its evidence, on an act the last (most specific) check that passed, for an ungated agent its move and first note; evidence is cut at a word to 150 characters. CaptionThrottle shows at most one every 6 seconds, the most telling of the moves since the last (an override over an act over an ungated move, the latest of its kind), and lets an override replace a calmer caption after 3 seconds. captions="large" sets the type to 3.4% of the screen's shorter side, readable across a room. Captions are not a live region: the board's own log, summarised at speed (D57), is what a screen reader hears. The options were the inspector's whole summary (too long to read at a distance), every move's caption (unreadable at 8 moves a second), or a live region for the captions (a second voice saying the same thing). Code: captions.ts, ambient-show.ts, styles.ts.

D84. Breakout's physics is integers and rational instants; a new direction waits for the tick's end

Decided: positions are integers (64 units to a cell) and velocities integers below a cell per tick; a crossing happens at the rational instant (line - origin) / speed, compared by cross multiplication, and a reflection keeps the line's origin and speed integers, so every position at a tick's end is an integer however many bounces it holds. The paddle's angle (and any spin) is applied at the end of the tick in which the ball meets it, and a capsule's change of speed at the next bounce, so a falling ball's flight is a pure function of the bricks and the forecast the gate relies on is exact. The options were floating point (not identical in the browser), exact fractions throughout (denominators grow without bound), or applying the new angle at the meeting point (positions stop being integers). Code: breakout_physics.py, breakout_rules.py, breakout_search.py.