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¶
D1. A sixth check: do the related cases support the action?¶
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.
D48. Selecting a piece is a protocol request; every other input comes from the legal moves' cells¶
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.
D53. Display settings belong to the visitor, never to a link¶
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.
D56. A link reopens a game in progress: the person's moves and the position¶
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, whichlearn.statisticscomputes with Python integers.binomial_cdfrounds it to nearest,p_value_risk_belowrounds it up, and the learner certifies withcertifies_risk_below, which compares the exact tail with(1 - confidence) / testsas 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_randomdraws uniforms aslow + (high - low) * uin Python from the raw 64-bit word (u= top 53 bits times2**-53), computesexpandlogin thedecimalmodule, normals by the polar method and gamma and beta variates by Marsaglia-Tsang. The how-to's history, theparameter_learningexample's authorisations and the how-to'smake_history.pyuse 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'srandom(),integers,permutationandchoicewithout 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_mixsquared shares with**, which calls the C library'spow. It is now one correctly rounded ratio of integers, andd_lambda_sqmultipliesλ * λ; the learner calls the samed_lambda_sqasModule.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.0andsha384-6ngHS4QbtmYZXBAXeEROtUlaC1J1bwWVBGVmla2OSTmegUQl9LUs9I5alme3H9xFin 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.
D80. A release is a directory of local files; the server switches a link; logs name no visitor¶
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.