Architecture notes¶
How the pieces are put together, and why. Useful before contributing, or before deciding whether to trust any of it.
The dependency graph¶
flowchart TD
V["minicheck.verdict<br/><i>the three-valued contract</i>"]
C["minicheck._core<br/><i>BFS, AG-EF, SMT</i>"]
S["minicheck.spec<br/><i>declarative loader</i>"]
M["minicheck.model<br/><i>Python class API</i>"]
R["minicheck.render / report / export"]
CLI["minicheck.cli"]
PB["protocol-bench"]
SF["specforge"]
MCP["minicheck-mcp"]
ACT["minicheck-action"]
V --> C
V --> R
V --> CLI
C --> S
C --> M
S --> R
C --> PB
C --> SF
V --> SF
C --> MCP
V --> MCP
CLI --> ACT
verdict sits at the root deliberately. It has no dependencies of its own, and everything that
produces or renders a verdict goes through it — so the meaning of UNDETERMINED cannot drift
between the CLI, the MCP server, the Action and the emitters.
polyfrac and failclosed are independent; they share the discipline, not the code.
The engine¶
Breadth-first reachability over an explicit state set. A state is a tuple of field values; the
frontier is a deque; visited states are a dict mapping state → (predecessor, label) so a trace can
be reconstructed by walking backwards.
BFS rather than DFS for one reason: the first violating state found is at minimum depth, so counterexamples are shortest by construction rather than by a later minimisation pass.
Invariants are evaluated inline during the sweep, not in a second pass. That was a change from the original design and it is what lets a counterexample survive a truncated search — the witness is recorded when the state is first reached, before any later step runs out of room.
Measured: 3.2×10⁵–1.0×10⁶ states/second for declarative specs, ~342 bytes per state.
Why declarative specs exist alongside the Python API¶
Two audiences with incompatible needs.
The Protocol and Model APIs take Python callables — maximally expressive, and completely
unsuitable for anything you did not write. A spec arriving over a network or from a language model
must not be executable.
The JSON format is deliberately weak: field names, literals, equality tests, incr/decr. It
cannot express arbitrary computation, which is exactly the property that makes it safe to accept
from a stranger. Model.to_spec() refuses rather than approximating, because a Python guard can
express conditions the JSON cannot and emitting an approximation would produce a spec for a
different machine.
The compiled transition path¶
Declarative specs are compiled once at build time into index-addressed tuples — guards as
(position, value) pairs, assignments as (position, value), increments as (position, name,
delta). The hot loop does no name lookups and no isinstance checks on constants.
Measured 2.8× over the interpreted version. The correctness argument is a differential test
requiring the compiled and interpreted paths to agree on every successor of every reachable
state, including on when to raise IntBoundExceeded — an optimisation that changed a verdict
would be far worse than a slow one.
The one runtime check that remains is the bound on incr/decr, because that genuinely depends on
the current value.
Testing philosophy¶
Three kinds of test, in increasing order of how much they are trusted:
- Unit tests — the least valuable. They ask whether the code agrees with itself.
- Adversarial tests — malformed, empty, enormous, out-of-distribution input, with one oracle: no input may produce a confident-looking answer that is wrong.
- Differential tests — the most valuable. The engine against a naive reimplementation sharing no code; the export against SPIN; a compiled path against an interpreted one.
The root cause of every defect found in the original audit was that the suite only ever asked "does the checker agree with itself". It never asked "does it agree with the truth". The differential tests are that question.
Regression suites are also mutation-tested: reintroduce the bug, confirm the suite goes red. A suite that passes on both the broken and the fixed code is worth nothing, and this has caught a test that was passing for the wrong reason.
Documentation is tested¶
Every numeric claim in a README is re-derived by tests/test_readme_claims.py in that repo: badge
counts against pytest --collect-only, line counts against the source tree, and tutorial outputs
against a real run.
This exists because documentation drift is the quiet member of the same family — a claim that was true once, is false now, and looks equally authoritative either way.