diff --git a/.gitignore b/.gitignore
index 063c477..98f84ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,5 @@ __pycache__/
*.py[cod]
build/
dist/
+node_modules/
.unitsentinel/
diff --git a/MANIFEST.in b/MANIFEST.in
index 33ec367..19d515a 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,3 +1,6 @@
include README.md
-recursive-include docs *.md
+recursive-include docs *.gif *.json *.md *.png *.svg *.txt
+recursive-include examples *.py
include src/unitsentinel/py.typed
+recursive-include tools *.json *.md *.mjs *.py
+prune tools/evidence/node_modules
diff --git a/README.md b/README.md
index 56b3d55..35d899e 100644
--- a/README.md
+++ b/README.md
@@ -3,110 +3,379 @@
Dimensional proof certificates for scientific and machine-learning computation
graphs.
-> **Status:** design bootstrap. The package boundary, verification model, and
-> threat model are specified; the dimensional algebra and verifier are the next
-> implementation slices. There are no benchmark results or model-support claims
-> yet.
+UnitSentinel catches a class of production bugs that tensor shapes and dtypes
+cannot express: a graph can be structurally valid while metres, seconds,
+temperature kinds, scales, or offsets are physically incompatible. It compiles
+a bounded canonical graph into tracked exact constraints, fails closed unless
+every public contract is unique, and can issue an unsigned, content-addressed
+certificate for a positive result.
+
+> **Status:** the v0.1 verification core, canonical graph codec, 33-unit
+> registry, deterministic CLI, detached certificate codec, and independent
+> strict replay are implemented. Bounded repair search, training/serving
+> comparison, and ONNX lowering remain future work.
+
+
+
+*Current implementation. Canonical bytes cross a bounded decoder; tracked
+constraints pass through Z3, exact extraction, and independent semantic replay
+before a positive claim can be issued. The [accessible SVG
+source](docs/assets/verification-pipeline.svg) contains the same content.*
+
+## A real end-to-end run
+
+
+
+*A 7.6-second loop rendered from the exact committed CLI transcripts:
+conflict, corrected graph with certificate issuance, then strict replay.
+Equivalent text paths are available for
+[conflict](docs/evidence/captures/conflict.txt),
+[verification](docs/evidence/captures/verify.txt), and
+[replay](docs/evidence/captures/replay.txt).*
+
+The demo is not a mock terminal. Its graphs, stdout, exit codes, JSON records,
+certificate, frames, and output hashes are all committed in the
+[evidence ledger](docs/evidence/README.md).
## The failure mode
Production ML contracts usually preserve tensor dtypes and shapes while the
physical meaning of a feature remains in preprocessing code, prose, or loose
-metadata. That leaves several bugs structurally valid:
+metadata. Several dangerous changes therefore remain structurally valid:
-- a serving feature arrives in `km/h` while training consumed `m/s`;
-- an absolute Celsius temperature is treated like a temperature difference;
-- a normalization constant is copied from a sensor with a different scale;
-- two tensors have the same shape and dtype but incompatible dimensions;
-- an exported model keeps its numerical graph but loses the unit contract around
- its inputs and outputs.
+- serving sends `km/h` while training consumed `m/s`;
+- an absolute Celsius temperature is treated as a temperature difference;
+- a normalization constant comes from a sensor with a different scale;
+- two tensors share a shape and dtype but carry incompatible dimensions;
+- an exported graph preserves numerical operations but loses the unit contract
+ around its inputs and outputs.
[ONNX tensor types](https://onnx.ai/onnx/intro/concepts.html#supported-types)
-describe element types and shapes. [TensorFlow Data
-Validation](https://www.tensorflow.org/tfx/guide/tfdv) checks schema properties
-and statistical skew. UnitSentinel targets a different layer: exact physical
-dimension and scale semantics across the computation itself.
+describe element types and shapes.
+[TensorFlow Data Validation](https://www.tensorflow.org/tfx/guide/tfdv) checks
+schema properties and statistical skew. UnitSentinel targets another layer:
+exact physical dimension, quantity kind, scale, and offset across the
+computation itself.
+
+## The example contract
+
+The committed example is a small physics-informed feature pipeline rather than
+a toy `metres + seconds` expression:
+
+1. subtract current and previous wheel speed in `km/h`;
+2. convert the speed delta to `m/s`;
+3. convert the sample period from `ms` to `s`;
+4. derive acceleration as `Δv / Δt`;
+5. normalize by a reference acceleration;
+6. apply a sigmoid only after the value is dimensionless.
+
+
-## What this project is
+*Dimensions, exact scales, and quantity kinds come from the live verified
+contract record. UnitSentinel checks the feature contract; it does not execute
+the tensors or calculate an anomaly score. [Inspect the accessible
+SVG](docs/assets/wheel-anomaly-contract.svg).*
-UnitSentinel treats a feature or model graph as a typed program:
+The verified graph is 2,072 canonical bytes and has SHA-256:
```text
-canonical graph + versioned unit registry
- │
- ▼
- typed quantity IR
- │
- ▼
- exact dimensional constraints
- │
- ┌─────────┴─────────┐
- ▼ ▼
- verified graph tracked conflict
- │ │
- ▼ ▼
- proof certificate bounded repair candidates
+139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c
+```
+
+## Install and verify
+
+UnitSentinel supports Python 3.11 and newer.
+
+```bash
+python3 -m venv .venv
+.venv/bin/python -m pip install -e '.[dev]'
+
+mkdir -p .unitsentinel/demo
+.venv/bin/python -I examples/build_wheel_anomaly_contract.py \
+ --variant verified \
+ > .unitsentinel/demo/wheel-anomaly.json
+
+.venv/bin/python -m unitsentinel verify \
+ .unitsentinel/demo/wheel-anomaly.json \
+ --certificate .unitsentinel/demo/wheel-anomaly.cert.json
+```
+
+Certificate output is deliberately no-overwrite: choose a fresh path for a
+second run. Successful human output includes ten exact contracts, both graph
+and result digests, the registry fingerprint, solver checks, and an explicit
+authentication disclaimer.
+
+
+
+*[Plain text](docs/evidence/captures/verify.txt) and
+[canonical JSON](docs/evidence/captures/verify.json) are the sources behind
+this screenshot. The PNG is derived from the
+[terminal SVG](docs/assets/verify-terminal.svg).*
+
+Machine consumers can request the same closed result record:
+
+```bash
+.venv/bin/python -m unitsentinel verify \
+ .unitsentinel/demo/wheel-anomaly.json \
+ --json
+```
+
+Normal verification and replay reports use structured stdout. Usage,
+input/output, expected-digest preflight, interruption, and redacted internal
+failures use stderr.
+
+| Exit | Meaning |
+| ---: | --- |
+| `0` | Graph verified or certificate reproduced |
+| `1` | Dimensional conflict |
+| `2` | Underconstrained public contract |
+| `3` | Verification is unknown or replay is indeterminate |
+| `4` | Input, output, or canonicality failure |
+| `5` | Replay or expected-digest mismatch |
+| `64` | Command-line usage error |
+| `70` | Redacted internal failure |
+| `130` | Interrupted execution |
+
+## Fail closed on a shape-valid bug
+
+The conflicting variant changes exactly one annotation:
+`acceleration-si` claims `meter-per-second` even though `Δv / Δt` derives
+`meter-per-second-squared`. Shapes and dtypes are unchanged.
+
+```bash
+.venv/bin/python -I examples/build_wheel_anomaly_contract.py \
+ --variant conflict \
+ > .unitsentinel/demo/wheel-anomaly-conflict.json
+
+.venv/bin/python -m unitsentinel verify \
+ .unitsentinel/demo/wheel-anomaly-conflict.json \
+ --certificate .unitsentinel/demo/should-not-exist.cert.json
+```
+
+
+
+*The command exits `1`; no positive certificate is written. See the
+[plain transcript](docs/evidence/captures/conflict.txt),
+[canonical record](docs/evidence/captures/conflict.json), or
+[accessible terminal SVG](docs/assets/conflict-terminal.svg).*
+
+UnitSentinel returns the actual four-item deletion-minimal tracked witness
+rather than a generic “unit mismatch” message:
+
+
+
+*The witness connects the wrong serving declaration to the divide operation and
+both explicit conversions. “Deletion-minimal” does not mean
+minimum-cardinality. [Inspect the accessible
+SVG](docs/assets/conflict-core.svg).*
+
+## Certificates and independent replay
+
+A positive certificate binds:
+
+- graph schema and SHA-256;
+- registry version and SHA-256;
+- verifier and solver versions;
+- exact solver limits;
+- ordered inferred contracts;
+- the source-labelled constraint catalog;
+- the complete verified result digest.
+
+It is canonical and content-addressed, but not signed. UnitSentinel therefore
+prints `authentication: not-provided` instead of implying provenance it cannot
+establish.
+
+
+
+*Graph, registry, toolchain, result, certificate, and replay identities are
+taken from the committed claim. `REPRODUCED` means semantic reproduction, not
+issuer authentication. [Inspect the accessible
+SVG](docs/assets/certificate-lineage.svg).*
+
+Replay can pin the expected certificate bytes and require the current toolchain
+to match the claim:
+
+```bash
+.venv/bin/python -m unitsentinel replay \
+ docs/evidence/claims/wheel-anomaly.cert.json \
+ --graph docs/evidence/contracts/wheel-anomaly-verified.json \
+ --expect-sha256 \
+ e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a \
+ --strict-toolchain
```
-The intended verifier will:
+
-- represent SI dimensions with exact rational exponents;
-- keep scale, offset, and absolute-versus-delta temperature semantics explicit;
-- compile graph operations into source-labelled constraints;
-- infer omitted dimensions when the system has a unique solution;
-- return a small, deterministic conflict core when constraints are inconsistent;
-- suggest bounded edits without silently changing a graph;
-- issue canonical JSON certificates that can be replayed offline;
-- compare training and serving graph contracts before values reach a model;
-- add an ONNX adapter only after the core semantics are independently testable.
+*Strict replay recomputes the certificate digest, checks graph/registry and
+toolchain bindings, replays pure semantic witnesses, then performs a fresh
+bounded uniqueness verification. Sources:
+[text](docs/evidence/captures/replay.txt),
+[JSON](docs/evidence/captures/replay.json), and
+[SVG](docs/assets/replay-terminal.svg).*
## Why a solver belongs here
-Local arithmetic catches obvious errors when every intermediate unit is known.
-Real graphs are partially annotated. A constraint solver can infer missing
-dimensions and explain a contradiction in terms of the declarations and
-operations that caused it. The solver is not the trust boundary by itself:
-UnitSentinel will revalidate every extracted model, cap graph and solver
-resources, and fail closed on `unknown` or timeout.
+Local arithmetic is enough when every intermediate unit is already known. Real
+graphs are only partially annotated. UnitSentinel creates exact expressions
+for seven SI dimension exponents, quantity kind, scale, and offset, then asks
+two distinct questions:
+
+1. is the tracked system satisfiable?
+2. can any observable contract take a different model value?
+
+Only a satisfiable system with no alternate observable model is `verified`.
+Every extracted rational value is checked against domain bounds and replayed by
+independent Python semantics before publication. A timeout, memory boundary,
+non-rational model value, unsupported operation, or out-of-domain exponent
+fails closed.
+
+Tracked assertions retain source identities such as:
+
+```text
+declaration/acceleration-si/unit
+operation/derive-acceleration/dimension
+operation/normalize-sample-period/dimension
+operation/normalize-speed-delta/dimension
+```
+
+Solver-generated names and raw diagnostics never enter public output.
+
+## Measured bounded scaling
+
+The repository includes one measured snapshot over identity chains of 1, 8,
+32, 128, and 256 operations. Each point is the median of three recorded runs.
+
+
+
+*This plot reports wall-clock verification-plus-issuance and strict replay on
+the recorded Python/Z3/Linux environment. It is not an accuracy benchmark,
+cross-machine ranking, or performance guarantee. Raw runs and environment:
+[scaling.json](docs/evidence/data/scaling.json); accessible source:
+[scaling.svg](docs/assets/scaling.svg).*
+
+## What runs today
-## Non-goals for v1
+The implementation includes:
+- immutable vectors over the seven SI base dimensions with bounded rational
+ exponents;
+- exact unit scales and affine offsets without float coercion;
+- distinct linear, absolute-temperature, and temperature-difference kinds;
+- an immutable 33-unit registry with canonical serialization and a pinned
+ SHA-256 fingerprint;
+- a closed topological graph IR with one producer per non-input value;
+- scalar types, bounded concrete/symbolic shapes, and explicit unit
+ annotations;
+- a byte-level decoder that rejects duplicate keys, floats, noncanonical JSON,
+ unknown fields, invalid topology, and oversized inputs;
+- structural preflight limits on bytes, nesting, tokens, nodes, and items;
+- exact constraints for all 14 supported graph operations;
+- alternate-model uniqueness checks;
+- deterministic tracked-core shrinking within a fixed check budget;
+- monotonic per-check and whole-run deadlines plus solver memory bounds;
+- independent semantic replay of extracted models;
+- canonical verification results, proof certificates, and replay reports;
+- a deterministic CLI with bounded regular-file reads and atomic private
+ certificate writes.
+
+The [canonical graph contract](docs/graph-format.md),
+[registry snapshot](docs/registry.md), and
+[architecture boundary](docs/architecture.md) specify the core. The
+[certificate and replay contract](docs/certificate-format.md) documents the
+detached claim byte boundary and replay ordering.
+
+## Trust boundary
+
+Verification and replay require no network and never execute model code. The
+CLI does not consume stdin; it accepts path-backed regular files and rejects
+FIFOs, final-path symlinks, oversized documents, duplicate JSON fields,
+executable extension hooks, and unsafe certificate targets. Input paths are
+opened without following the final symlink, the open descriptor must be a
+regular file, and bounded nonblocking reads still cap a file that grows after
+its initial `fstat`. Certificate writes use private no-overwrite temporary
+files and atomic publication.
+
+The threat model excludes a hostile same-UID process that can rewrite the
+installed verifier, solver, or repository parent directories during execution.
+A caller-trusted expected digest can detect certificate-byte substitution;
+successful replay establishes current semantic agreement. Neither establishes
+author identity or proves that the claimed issuance happened.
+
+## Deliberate non-goals
+
+- Claiming dimensional consistency proves scientific correctness.
+- Executing tensor payloads or validating broadcasting/matmul shapes.
- Full UCUM compatibility.
-- Currency, calendar arithmetic, logarithmic units, or context-dependent
- chemistry conversions.
-- Executing user-provided Python, model code, or arbitrary plugins.
-- Guessing scientific intent from names.
+- Currency, calendar arithmetic, logarithmic units, or contextual chemistry
+ conversion.
+- Guessing scientific intent from variable names.
+- Executing user Python, model code, plugins, URLs, or import paths.
- Applying an LLM-generated repair without formal re-verification.
-- Claiming that dimensional consistency proves scientific correctness.
-## Development slices
+## Reproduce the visual evidence
-1. Exact dimensions, units, quantities, and affine conversion semantics.
-2. A bounded canonical graph format and typed intermediate representation.
-3. Tracked SMT constraints, inference, deterministic conflict cores, and
- fail-closed resource handling.
-4. Bounded repairs, canonical proof certificates, and independent replay.
-5. Training/serving contract comparison and an ONNX metadata adapter.
-6. A grouped synthetic fault benchmark with calibration and abstention metrics.
-7. Real CLI captures, architecture and lineage diagrams, benchmark plots, and a
- short end-to-end demo generated from committed code.
+The recorder and renderer are part of the repository:
-Visual evidence will be added only when it can be reproduced from implemented
-behavior. No mock dashboard or invented result is used as a placeholder.
+```bash
+npm --prefix tools/evidence ci --ignore-scripts
+npm --prefix tools/evidence run audit
-## Local quality checks
+.venv/bin/python -m tools.evidence.generate --check
+npm --prefix tools/evidence run check
+```
-The bootstrap package uses Python 3.11+ and keeps development tooling optional:
+To refresh the deterministic records and renders:
```bash
-python3 -m venv .venv
-.venv/bin/python -m pip install -e '.[dev]'
+.venv/bin/python -m tools.evidence.generate --record
+npm --prefix tools/evidence run render
+.venv/bin/python -m tools.evidence.generate --write-manifest
+```
+
+The timing snapshot changes only through the explicit
+`--record-benchmark` mode. The [evidence ledger](docs/evidence/README.md),
+[generation guide](tools/evidence/README.md), and
+[closed manifest](docs/evidence/manifest.json) document every input and output.
+
+## Local quality gates
+The current suite contains 198 unit, integration, adversarial, and evidence
+tests with 96% combined statement/branch coverage.
+
+```bash
PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -v
+PYTHONPATH=src .venv/bin/coverage run -m unittest discover -s tests
+.venv/bin/coverage report
+
.venv/bin/ruff check .
.venv/bin/ruff format --check .
-.venv/bin/mypy src
+.venv/bin/mypy src tools/evidence
+.venv/bin/python -m build
+.venv/bin/pip-audit
+
+.venv/bin/python -m tools.evidence.generate --check
+npm --prefix tools/evidence run check
```
+The evidence tests independently validate canonical graph/certificate
+bindings, the closed manifest, SVG accessibility and self-containment, PNG
+chunk CRCs and decompressed dimensions, GIF frame timing/loop structure,
+README coverage, and secret/PII exclusions.
+
+## Roadmap
+
+| Slice | Status |
+| --- | --- |
+| Exact values, units, quantities, affine semantics | Complete |
+| Immutable content-addressed unit registry | Complete |
+| Bounded canonical graph IR and strict decoder | Complete |
+| Tracked exact verification and fail-closed outcomes | Complete |
+| Detached positive certificates and independent replay | Complete |
+| Production CLI and reproducible visual evidence | Complete |
+| Bounded formally reverified repair candidates | Next |
+| Training/serving contract comparison | Planned |
+| Closed-subset ONNX metadata adapter | Planned |
+| Grouped synthetic fault benchmark with abstention metrics | Planned |
+
The repository intentionally has no license yet. Licensing is a decision for
Omar before third-party reuse is invited.
diff --git a/docs/architecture.md b/docs/architecture.md
index 5e709a4..65a414b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,23 +1,27 @@
# Architecture and verification boundary
-This document defines the contract UnitSentinel will implement. Statements
-about future components are design requirements, not claims of completed
-behavior.
+This document separates the implemented verifier, certificate/replay boundary,
+and CLI from later repair and adapter design. The implementation map at the end
+is the authoritative status summary.
## Design objective
Given a bounded computation graph, a versioned unit registry, and optional unit
-annotations, determine one of four outcomes:
+annotations, the current verifier determines one of four outcomes:
1. `verified`: every value has a unique, consistent dimensional interpretation;
2. `underconstrained`: more than one interpretation remains;
3. `conflict`: a tracked subset of declarations and operations is inconsistent;
-4. `unknown`: the solver or resource boundary could not establish a result.
+4. `unknown`: the trusted verification result could not be established.
Only `verified` may produce a positive proof certificate. A timeout, unsupported
operation, malformed graph, or non-unique interpretation is never converted
into success.
+Stable `unknown` reasons cover timeout, memory/resource failure, solver unknown,
+contract rejection, out-of-domain model extraction, and internal
+inconsistency. Raw solver diagnostics are not exposed.
+
## Quantity model
### Dimension
@@ -40,14 +44,16 @@ A unit contains:
- a canonical identifier;
- one dimension vector;
-- an exact decimal scale relative to a canonical reference unit;
-- an exact decimal offset when the conversion is affine;
+- an exact rational scale relative to a canonical reference unit;
+- an exact rational offset when the conversion is affine;
- a quantity kind that distinguishes linear values, absolute temperatures, and
temperature differences.
An affine unit is not generally closed under multiplication, division, or
-exponentiation. For example, an absolute Celsius temperature must be converted
-to a linear reference before multiplicative operations. A temperature
+exponentiation. An absolute Celsius magnitude may be normalized onto the
+canonical absolute-temperature reference scale, but that conversion does not
+reclassify it as a linear quantity. Direct temperature units must declare
+whether they represent an absolute value or a difference; a temperature
difference has no absolute offset.
### Tensor value
@@ -66,7 +72,7 @@ still have an invalid tensor shape, and vice versa.
## Canonical graph format
-The first adapter will accept strict JSON with:
+The implemented core decoder accepts strict JSON with:
- a schema version;
- a bounded, unique list of graph inputs;
@@ -78,23 +84,22 @@ The first adapter will accept strict JSON with:
Parsing rejects duplicate JSON keys, unknown fields, invalid UTF-8, noncanonical
numbers, cycles, forward references, excessive nesting, and values outside
-declared limits. Source locations are graph identifiers and JSON pointers, not
-host filesystem paths.
+declared limits. Public witnesses use canonical value/node source identifiers
+and constraint identifiers, not host filesystem paths.
## Constraint compilation
-Each value receives seven exact solver expressions, one per base dimension.
-Operations emit labelled equations:
+Each value receives seven exact dimension expressions plus exact scale, offset,
+and semantic quantity-kind expressions. Operations emit labelled equations:
| Operation family | Dimensional rule |
| --- | --- |
-| identity, cast, reshape, transpose, reduce | output equals input |
-| add, subtract, minimum, maximum | operands and output are equal |
-| multiply | output equals left plus right |
-| divide | output equals left minus right |
+| identity | output contract equals input contract |
+| add, subtract, minimum, maximum | dimensions match; kind/transform follow the explicit arithmetic truth table |
+| multiply, matrix multiplication | output dimension is left plus right |
+| divide | output dimension is left minus right |
| power by rational constant | output equals input multiplied by exponent |
| exponential, logarithm, sigmoid, softmax | input and output are dimensionless |
-| matrix multiplication | output equals left plus right |
| explicit conversion | dimensions equal; unit transform is declared |
Every equation is tracked by a stable constraint identifier derived from its
@@ -102,25 +107,27 @@ graph declaration. Solver-generated names never appear in user-facing output.
### Inference
-A satisfiable system is not automatically verified. UnitSentinel will ask
-whether each unresolved dimension component has a unique value. If two models
-assign different values to an observable contract, the graph is
+A satisfiable system is not automatically verified. UnitSentinel asks whether
+every dimension component, quantity kind, scale, and offset has a unique value.
+If two models assign different values to any observable contract, the graph is
`underconstrained`.
-Extracted rational values are validated against exponent numerator and
-denominator bounds before they enter the trusted domain model.
+All extracted solver rationals pass a 256-bit numerator/denominator boundary.
+Dimension exponents additionally require an absolute numerator no greater than
+64 and a denominator no greater than 12 before they enter the domain model.
### Conflict cores
Tracked assertions provide an initial unsatisfiable core. Because solver cores
-need not be minimal, UnitSentinel will deterministically shrink the core within
-a fixed check budget. The resulting core is a diagnostic witness, not a proof
-that every omitted declaration is irrelevant to scientific intent.
+need not be minimal, UnitSentinel deterministically shrinks the sorted core
+within a fixed check budget. The resulting core is a diagnostic witness, not a
+proof that every omitted declaration is irrelevant to scientific intent.
## Bounded repair
-Repair generation is downstream of verification and never mutates input. The
-first repair operators are deliberately small:
+This section is a design constraint for the next slice; repair generation is
+not implemented. It will remain downstream of verification and will never
+mutate input. The first repair operators are deliberately small:
- replace one declared unit with another compatible registry unit;
- insert one explicit scale conversion;
@@ -128,9 +135,9 @@ first repair operators are deliberately small:
graph contract permits it;
- remove one contradictory optional annotation.
-Candidates are ordered by a deterministic cost tuple and recompiled through the
-same verifier. A candidate that is not independently `verified` is discarded.
-Ambiguous top candidates cause abstention.
+Candidates will be ordered by a deterministic cost tuple and recompiled through
+the same verifier. A candidate that is not independently `verified` will be
+discarded. Ambiguous top candidates will cause abstention.
An optional learned reranker may later order already-verified candidates. It
will not create candidates, bypass the verifier, or turn abstention into an
@@ -138,23 +145,41 @@ automatic edit.
## Proof certificate
-A canonical certificate will bind:
+A canonical positive certificate currently binds:
- graph schema version and SHA-256;
- unit registry version and SHA-256;
-- verifier and solver versions;
-- configured resource limits;
+- verifier version, semantic contract, and solver version;
+- the exact configured solver/resource limits and checks performed;
- ordered inferred contracts;
-- ordered constraint identities;
-- outcome and, where applicable, conflict core or repair identity;
-- certificate schema version and whole-document digest.
-
-Offline replay reparses the graph and registry, recompiles constraints, and
-compares a fresh result. A certificate is evidence of one bounded verifier
-execution, not a cryptographic signature or a proof of scientific validity.
+- the complete ordered source-labelled constraint catalog;
+- the verified result SHA-256;
+- certificate schema version.
+
+The complete canonical certificate bytes are content-addressed by SHA-256; the
+digest is computed outside the closed certificate document.
+
+Only an exact runtime `VerificationResult` with status `verified`, complete
+contract coverage, matching graph/registry bindings, and freshly revalidated
+sources can be issued. Negative and indeterminate outcomes have no certificate
+shape.
+
+Detached replay decodes the canonical claim, recomputes its digest, compares
+graph and registry identities, compares the current source-labelled catalog,
+optionally pins the toolchain, replays every claimed contract against the graph
+and registry through solver-independent Python semantics, then performs a fresh
+bounded uniqueness verification. It returns `reproduced`, `mismatch`, or
+`indeterminate`.
+
+A certificate is an unsigned claim about one bounded verifier execution.
+Successful replay establishes current semantic agreement, not that issuance
+happened, author identity, or scientific validity. The exact byte contract is
+documented in [certificate-format.md](certificate-format.md).
## Training and serving comparison
+This comparison layer is planned, not implemented.
+
Two individually verified graphs may still disagree. Contract comparison will
align declared public inputs and outputs, then report:
@@ -180,34 +205,37 @@ unit semantics. The adapter will:
4. lower into the same canonical core graph;
5. reject unsupported control flow or operator semantics rather than guessing.
-The adapter is not part of the first implementation slice.
+The adapter is not implemented.
## Resource and security limits
- No network access is required by verification or replay.
- No shell, `eval`, dynamic import, or arbitrary model execution.
-- Fixed limits for document bytes, nodes, edges, exponent size, core-shrink
- checks, repair candidates, solver memory, and solver time.
+- Fixed limits for document bytes/tree shape, inputs, values, nodes, outputs,
+ tensor rank, exponent size, core-shrink checks, uniqueness checks, solver
+ memory, and solver time.
- Deterministic errors omit host paths, environment values, and raw solver
diagnostics.
- Unknown unit identifiers and unsupported operations fail closed.
- Canonical JSON readers reject duplicate fields and non-finite numbers.
-- The same registry snapshot is used for compile, repair, certificate, and
- replay.
+- The same registry snapshot is used for compilation, certificate issuance,
+ and replay.
The threat model excludes a same-UID process that can rewrite the verifier,
-solver binary, or committed inputs during execution. Reproducible evidence
-tooling will state that boundary explicitly.
+solver binary, or repository parent directories during execution. The
+reproducible evidence tooling states its separate rendering and filesystem
+boundary explicitly.
## Current implementation map
| Area | Current | Next |
| --- | --- | --- |
-| Package boundary | Python package metadata and public version | Exact domain value objects |
-| Dimension semantics | Specified here | Exact algebra and property tests |
-| Unit registry | Supported subset and exclusions specified | Versioned immutable registry |
-| Graph IR | Closed JSON contract specified | Strict decoder and graph validation |
-| Solver | Constraint and fail-closed behavior specified | Tracked exact constraints |
+| Package boundary | Typed exact values, graph, registry, results, certificates, and replay reports | Contract comparison |
+| Dimension semantics | Exact bounded rational algebra and graph inference | Contract comparison |
+| Unit registry | Immutable 33-unit snapshot with pinned SHA-256 | External snapshot decoder |
+| Graph IR | Content-addressed bounded IR and strict decoder | ONNX lowering |
+| Solver | Tracked dimension/kind/scale/offset constraints, uniqueness, replay, and bounded cores | Bounded repairs |
| Repairs | Bounded operators specified | Verified candidate enumeration |
-| Certificates | Required bindings specified | Canonical codec and replay |
-| Visual evidence | No placeholders | Generate from implemented behavior |
+| Certificates | Positive-only canonical codec, content digest, and detached replay with optional strict-toolchain policy | Contract-comparison evidence |
+| CLI | Bounded regular-file reads, stable exits, atomic no-overwrite certificate publication | Contract-comparison commands |
+| Visual evidence | Real CLI captures, diagrams, plot, GIF, accessible SVG, and closed digest manifest | Grouped synthetic fault corpus |
diff --git a/docs/assets/certificate-lineage.png b/docs/assets/certificate-lineage.png
new file mode 100644
index 0000000..260830e
Binary files /dev/null and b/docs/assets/certificate-lineage.png differ
diff --git a/docs/assets/certificate-lineage.svg b/docs/assets/certificate-lineage.svg
new file mode 100644
index 0000000..bd0f734
--- /dev/null
+++ b/docs/assets/certificate-lineage.svg
@@ -0,0 +1,78 @@
+
+
diff --git a/docs/assets/conflict-core.png b/docs/assets/conflict-core.png
new file mode 100644
index 0000000..8a6db26
Binary files /dev/null and b/docs/assets/conflict-core.png differ
diff --git a/docs/assets/conflict-core.svg b/docs/assets/conflict-core.svg
new file mode 100644
index 0000000..6c86266
--- /dev/null
+++ b/docs/assets/conflict-core.svg
@@ -0,0 +1,62 @@
+
+
diff --git a/docs/assets/conflict-terminal.png b/docs/assets/conflict-terminal.png
new file mode 100644
index 0000000..af8408a
Binary files /dev/null and b/docs/assets/conflict-terminal.png differ
diff --git a/docs/assets/conflict-terminal.svg b/docs/assets/conflict-terminal.svg
new file mode 100644
index 0000000..139f55a
--- /dev/null
+++ b/docs/assets/conflict-terminal.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/docs/assets/replay-terminal.png b/docs/assets/replay-terminal.png
new file mode 100644
index 0000000..9fab31a
Binary files /dev/null and b/docs/assets/replay-terminal.png differ
diff --git a/docs/assets/replay-terminal.svg b/docs/assets/replay-terminal.svg
new file mode 100644
index 0000000..be20c70
--- /dev/null
+++ b/docs/assets/replay-terminal.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/docs/assets/scaling.png b/docs/assets/scaling.png
new file mode 100644
index 0000000..8284e60
Binary files /dev/null and b/docs/assets/scaling.png differ
diff --git a/docs/assets/scaling.svg b/docs/assets/scaling.svg
new file mode 100644
index 0000000..640cd52
--- /dev/null
+++ b/docs/assets/scaling.svg
@@ -0,0 +1,66 @@
+
+
diff --git a/docs/assets/unitsentinel-demo.gif b/docs/assets/unitsentinel-demo.gif
new file mode 100644
index 0000000..5811bd5
Binary files /dev/null and b/docs/assets/unitsentinel-demo.gif differ
diff --git a/docs/assets/verification-pipeline.png b/docs/assets/verification-pipeline.png
new file mode 100644
index 0000000..e89356b
Binary files /dev/null and b/docs/assets/verification-pipeline.png differ
diff --git a/docs/assets/verification-pipeline.svg b/docs/assets/verification-pipeline.svg
new file mode 100644
index 0000000..c9392fd
--- /dev/null
+++ b/docs/assets/verification-pipeline.svg
@@ -0,0 +1,115 @@
+
+
diff --git a/docs/assets/verify-terminal.png b/docs/assets/verify-terminal.png
new file mode 100644
index 0000000..2614279
Binary files /dev/null and b/docs/assets/verify-terminal.png differ
diff --git a/docs/assets/verify-terminal.svg b/docs/assets/verify-terminal.svg
new file mode 100644
index 0000000..5dadffa
--- /dev/null
+++ b/docs/assets/verify-terminal.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/docs/assets/wheel-anomaly-contract.png b/docs/assets/wheel-anomaly-contract.png
new file mode 100644
index 0000000..45a4235
Binary files /dev/null and b/docs/assets/wheel-anomaly-contract.png differ
diff --git a/docs/assets/wheel-anomaly-contract.svg b/docs/assets/wheel-anomaly-contract.svg
new file mode 100644
index 0000000..4e8ac25
--- /dev/null
+++ b/docs/assets/wheel-anomaly-contract.svg
@@ -0,0 +1,101 @@
+
+
diff --git a/docs/certificate-format.md b/docs/certificate-format.md
new file mode 100644
index 0000000..75072a0
--- /dev/null
+++ b/docs/certificate-format.md
@@ -0,0 +1,174 @@
+# Proof certificate and replay contract
+
+UnitSentinel certificates are detached, canonical positive-verification claims.
+Factory issuance records one completed bounded verification and preserves
+enough source identity and semantic evidence for an independent current replay.
+The bytes remain deliberately unsigned.
+
+The current schema is:
+
+```text
+unitsentinel.proof-certificate/v1
+```
+
+## Security posture
+
+A factory-issued certificate records:
+
+- a SHA-256 content identity that a caller can compare with an externally
+ trusted expected digest;
+- a claimed binding to one canonical graph digest and one registry snapshot;
+- a complete source-labelled constraint catalog;
+- exact inferred contracts and the verified result digest;
+- the verifier, solver, limits, and check count reported by issuance.
+
+It does **not** provide:
+
+- an issuer identity or signature;
+- proof that the graph is scientifically correct;
+- proof that tensor payloads were executed;
+- authority to load code, plugins, registries, or graphs from a URL;
+- permission for replay to trust the resource limits claimed by the issuer.
+
+Callers that need identity must authenticate the certificate bytes in a
+separate envelope. Human and JSON CLI outputs keep this distinction visible as
+`authentication: not-provided`; that explanatory field is not part of the
+closed seven-field certificate document.
+
+## Canonical byte boundary
+
+`decode_certificate()` accepts exact `bytes` and applies the same closed JSON
+discipline as the graph decoder:
+
+- at most 2,097,152 bytes;
+- valid UTF-8 without a BOM;
+- no duplicate fields, floats, `NaN`, or infinities;
+- at most eight nested containers, 2,112 items in one container, and 65,536
+ total JSON values;
+- at most 192 characters per string and ten digits per JSON integer;
+- exact root and nested field sets;
+- compact key-sorted encoding with no trailing newline;
+- byte-for-byte equality after semantic decode and re-encode.
+
+Semantic construction additionally caps the catalog at 2,112 constraints,
+contracts at 576, checks performed at 1,025, and the solver-version string at
+32 characters.
+
+The SHA-256 digest is computed over the complete canonical certificate bytes.
+It is a property of the `ProofCertificate` value rather than a self-referential
+field inside the JSON document.
+
+Successful decode establishes a canonical, internally coherent unsigned claim,
+including its embedded verification-result digest. It does not authenticate an
+issuer, prove that issuance happened, or replay the claimed graph semantics.
+Direct `ProofCertificate` construction is likewise an untrusted claim.
+
+## Closed root fields
+
+The root contains exactly seven fields:
+
+| Field | Bound evidence |
+| --- | --- |
+| `schema` | Certificate schema identity |
+| `graph` | Graph schema and SHA-256 |
+| `registry` | Registry schema, version, and SHA-256 |
+| `verifier` | Implementation, semantic contract, and version |
+| `solver` | Solver implementation and version |
+| `run` | Checks performed and exact issuance limits |
+| `proof` | Positive outcome, result digest, contracts, and constraints |
+
+Every constraint record contains exactly:
+
+```text
+constraint_id, rule, source, source_id
+```
+
+Every inferred contract contains exactly:
+
+```text
+value_id, dimension, kind, scale, offset
+```
+
+Dimensions contain only nonzero, sorted SI-base terms with canonical reduced
+rational exponents. Scale and offset are canonical reduced rational strings;
+they never cross the certificate boundary as JSON floats.
+
+## Positive-only issuance
+
+`create_certificate()` issues only when all of these conditions hold:
+
+1. graph, registry, and solver limits are exact validated runtime values;
+2. one bounded verification attempt returns an exact `VerificationResult`;
+3. status is `verified`;
+4. graph and registry digests match the supplied sources;
+5. inferred contracts cover every declared graph value exactly once;
+6. the ordered constraints equal the compiler's current source catalog;
+7. the graph, registry, limits, result, and constructed certificate still
+ validate after construction.
+
+Conflict, underconstrained, and unknown results cannot be represented as a
+positive certificate. An exception, source mutation, partial coverage, stale
+binding, or unexpected result subtype fails issuance closed.
+
+The certificate records issuance limits as provenance. They are not executable
+instructions for a later replay.
+
+## Detached replay
+
+Replay requires the certificate and the caller-supplied canonical graph. The
+CLI uses the built-in registry snapshot; the Python API can receive an explicit
+validated registry and fresh solver limits.
+
+`replay_certificate()` performs the checks in a deterministic order:
+
+1. revalidate exact certificate, graph, registry, and limit values;
+2. compare graph digest, registry digest, and registry version;
+3. rebuild and compare the complete source-labelled constraint catalog;
+4. require exact contract coverage for all graph values;
+5. apply the optional strict verifier/solver version policy;
+6. replay every claimed contract through independent Python semantics;
+7. run one fresh bounded verification using the replay caller's limits;
+8. compare the fresh status, bindings, and exact contracts with the claim.
+
+Early binding, catalog, coverage, witness, and strict-toolchain mismatches stop
+before the fresh solver run. The report itself is canonical and
+content-addressed under:
+
+```text
+unitsentinel.certificate-replay/v1
+```
+
+## Replay outcomes
+
+| Status | Meaning |
+| --- | --- |
+| `reproduced` | Pure witness replay and fresh unique verification agree with the claim |
+| `mismatch` | A binding, catalog, coverage, witness, toolchain, or fresh semantic result disagrees |
+| `indeterminate` | The fresh verifier returned `unknown`; replay refuses to convert it into a mismatch or success |
+
+Mismatch reports contain one stable first reason. Public reasons distinguish
+graph/registry identity, catalog, coverage, witness, toolchain, fresh conflict,
+fresh underconstraint, and fresh contract disagreement. Raw solver diagnostics
+and host paths do not enter the report.
+
+## CLI reproduction
+
+The committed positive claim can be pinned and replayed without network access:
+
+```bash
+.venv/bin/python -m unitsentinel replay \
+ docs/evidence/claims/wheel-anomaly.cert.json \
+ --graph docs/evidence/contracts/wheel-anomaly-verified.json \
+ --expect-sha256 \
+ e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a \
+ --strict-toolchain
+```
+
+Argparse validates the syntax of `--expect-sha256` before either input is
+opened. The CLI then bounded-reads and decodes the certificate, compares its
+actual content digest with the expected value, and rejects a mismatch before it
+opens the graph or performs solver work.
+
+The exact example certificate, replay JSON, terminal transcript, lineage
+diagram, and cross-bound digests are indexed in the
+[evidence ledger](evidence/README.md).
diff --git a/docs/evidence/README.md b/docs/evidence/README.md
new file mode 100644
index 0000000..017f567
--- /dev/null
+++ b/docs/evidence/README.md
@@ -0,0 +1,124 @@
+# UnitSentinel evidence ledger
+
+This directory is the audit trail behind the images in the project README.
+Every terminal capture comes from the production CLI, every graph and claim is
+canonical JSON, and every public PNG/GIF is derived from a committed SVG or
+frame manifest. The closed [evidence manifest](manifest.json) records the exact
+file set, byte counts, and SHA-256 digests.
+
+## Reproduce the current snapshot
+
+From a checkout with the Python development environment installed:
+
+```bash
+npm --prefix tools/evidence ci --ignore-scripts
+
+.venv/bin/python -m tools.evidence.generate --check
+npm --prefix tools/evidence run check
+```
+
+The Python command re-executes the verified, conflict, and strict-replay CLI
+paths. It does not remeasure the timing benchmark. The Node command renders all
+expected PNG/GIF bytes in memory and compares them with the committed files.
+Neither check publishes replacements.
+
+To intentionally refresh deterministic evidence while retaining the recorded
+benchmark:
+
+```bash
+.venv/bin/python -m tools.evidence.generate --record
+npm --prefix tools/evidence run render
+.venv/bin/python -m tools.evidence.generate --write-manifest
+```
+
+Use `--record-benchmark` instead of `--record` only when a new measured host
+snapshot is intended. Benchmark timings are observations, not canonical
+goldens.
+
+## Public visual index
+
+| Question | Accessible SVG source | PNG rendering |
+| --- | --- | --- |
+| Where are the verification boundaries? | [Verification pipeline SVG](../assets/verification-pipeline.svg) | [Verification pipeline PNG](../assets/verification-pipeline.png) |
+| What physical feature graph was checked? | [Wheel anomaly contract SVG](../assets/wheel-anomaly-contract.svg) | [Wheel anomaly contract PNG](../assets/wheel-anomaly-contract.png) |
+| What did a successful CLI run return? | [Verified terminal SVG](../assets/verify-terminal.svg) | [Verified terminal PNG](../assets/verify-terminal.png) |
+| How did the serving-contract bug fail? | [Conflict terminal SVG](../assets/conflict-terminal.svg) | [Conflict terminal PNG](../assets/conflict-terminal.png) |
+| Which tracked constraints form the conflict? | [Conflict core SVG](../assets/conflict-core.svg) | [Conflict core PNG](../assets/conflict-core.png) |
+| How is a claim bound and replayed? | [Certificate lineage SVG](../assets/certificate-lineage.svg) | [Certificate lineage PNG](../assets/certificate-lineage.png) |
+| What did strict replay return? | [Replay terminal SVG](../assets/replay-terminal.svg) | [Replay terminal PNG](../assets/replay-terminal.png) |
+| How did bounded graph size affect this host? | [Scaling plot SVG](../assets/scaling.svg) | [Scaling plot PNG](../assets/scaling.png) |
+
+The [7.6-second CLI demo GIF](../assets/unitsentinel-demo.gif) is presentation,
+not the primary record. Its three frames loop in the order declared below.
+
+## Canonical inputs and outputs
+
+### Graph contracts
+
+- [Verified wheel-anomaly graph](contracts/wheel-anomaly-verified.json)
+- [Conflicting wheel-anomaly graph](contracts/wheel-anomaly-conflict.json)
+
+The two graphs differ in one serving annotation:
+`acceleration-si` is `meter-per-second-squared` in the verified graph and
+`meter-per-second` in the conflict graph. Shape and dtype metadata stay the
+same.
+
+### Positive claim
+
+- [Detached wheel-anomaly certificate](claims/wheel-anomaly.cert.json)
+
+The certificate is content-addressed and unsigned. The CLI transcript and JSON
+wrapper deliberately label its authentication as `not-provided`; authentication
+is not a field in the closed certificate document. A reproduced replay
+establishes current semantic reproduction, not issuer identity or issuance
+provenance.
+
+### Actual CLI captures
+
+| Path | Human transcript | Canonical JSON record |
+| --- | --- | --- |
+| Verify and issue | [verify.txt](captures/verify.txt) | [verify.json](captures/verify.json) |
+| Fail closed on conflict | [conflict.txt](captures/conflict.txt) | [conflict.json](captures/conflict.json) |
+| Strict detached replay | [replay.txt](captures/replay.txt) | [replay.json](captures/replay.json) |
+
+The transcript commands use `.unitsentinel/evidence-run` as an isolated scratch
+directory. The recorder creates it with private permissions, refuses to delete
+an existing directory at that path, and removes only the directory it created.
+
+### Cross-bindings and benchmark
+
+- [Graph/result/certificate/replay provenance](provenance.json)
+- [Measured scaling runs](data/scaling.json)
+
+The recorded benchmark uses identity chains with 1, 8, 32, 128, and 256
+operations. Each plotted point is the median of three raw runs for
+verification-plus-certificate issuance or strict replay. It is a single-host
+engineering snapshot, not a real-world accuracy result, cross-machine ranking,
+or performance guarantee.
+
+### Demo sources
+
+- [Frame manifest and delays](demo/frames.json)
+- [Conflict frame](demo/frame-01.svg)
+- [Verified frame](demo/frame-02.svg)
+- [Replay frame](demo/frame-03.svg)
+
+The frames are byte-identical copies of the corresponding public terminal SVG
+sources. The renderer rejects undeclared frames, mixed dimensions, external
+resources, and malformed delays.
+
+## Interpretation limits
+
+- UnitSentinel verifies dimension, quantity kind, exact scale, and offset. It
+ does not execute tensors or calculate anomaly scores.
+- Shape is preserved as contract metadata; broadcasting and matrix-shape
+ correctness are outside this verifier.
+- `core_minimal: true` means deletion-minimal under the bounded shrink
+ procedure, not minimum-cardinality.
+- A verified dimensional contract does not establish scientific correctness.
+- Timings include the recorded Python, UnitSentinel, and Z3 versions shown in
+ [the benchmark data](data/scaling.json).
+
+The generation implementation, dependency pins, host assumptions, and renderer
+limits are documented in the
+[evidence tooling guide](../../tools/evidence/README.md).
diff --git a/docs/evidence/captures/conflict.json b/docs/evidence/captures/conflict.json
new file mode 100644
index 0000000..f313918
--- /dev/null
+++ b/docs/evidence/captures/conflict.json
@@ -0,0 +1 @@
+{"certificate":null,"certificate_output":"not-requested","exit_code":1,"graph":{"graph_id":"wheel-anomaly-conflict","schema":"unitsentinel.graph/v1","sha256":"6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708"},"registry":{"schema":"unitsentinel.unit-registry/v1","sha256":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","version":"1.0.0"},"result":{"record":{"checks_performed":5,"conflict_core":[{"constraint_id":"declaration/acceleration-si/unit","rule":"unit-annotation","source":"declaration","source_id":"acceleration-si"},{"constraint_id":"operation/derive-acceleration/dimension","rule":"divide-dimension","source":"operation","source_id":"derive-acceleration"},{"constraint_id":"operation/normalize-sample-period/dimension","rule":"convert-dimension","source":"operation","source_id":"normalize-sample-period"},{"constraint_id":"operation/normalize-speed-delta/dimension","rule":"convert-dimension","source":"operation","source_id":"normalize-speed-delta"}],"contracts":[],"core_minimal":true,"graph_digest":"6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708","limits":{"max_core_shrink_checks":64,"max_memory_mb":256,"max_uniqueness_checks":577,"per_check_timeout_ms":250,"total_timeout_ms":5000},"registry_digest":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","solver_version":"4.16.0","status":"conflict","underconstrained_values":[],"unknown_reason":null},"sha256":"521b85cbf597e5ca45716c7add5346cc65191063060c24c87ca70797bac67aea"},"schema":"unitsentinel.cli.verify/v1","tool":{"name":"unitsentinel","version":"0.1.0"}}
diff --git a/docs/evidence/captures/conflict.txt b/docs/evidence/captures/conflict.txt
new file mode 100644
index 0000000..bb13312
--- /dev/null
+++ b/docs/evidence/captures/conflict.txt
@@ -0,0 +1,20 @@
+$ .venv/bin/python -m unitsentinel verify \
+ docs/evidence/contracts/wheel-anomaly-conflict.json \
+ --certificate .unitsentinel/evidence-run/conflict.cert.json
+UnitSentinel verification: CONFLICT
+exit code: 1
+tool: unitsentinel 0.1.0
+graph id: wheel-anomaly-conflict
+graph sha256: 6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708
+result sha256: 521b85cbf597e5ca45716c7add5346cc65191063060c24c87ca70797bac67aea
+registry version: 1.0.0
+registry sha256: fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5
+solver: z3 4.16.0 (5 checks)
+conflict core (4, minimal=yes):
+ declaration/acceleration-si/unit | declaration:acceleration-si | unit-annotation
+ operation/derive-acceleration/dimension | operation:derive-acceleration | divide-dimension
+ operation/normalize-sample-period/dimension | operation:normalize-sample-period | convert-dimension
+ operation/normalize-speed-delta/dimension | operation:normalize-speed-delta | convert-dimension
+certificate: not-issued
+certificate output: not-issued
+[exit 1]
diff --git a/docs/evidence/captures/replay.json b/docs/evidence/captures/replay.json
new file mode 100644
index 0000000..04ef3f1
--- /dev/null
+++ b/docs/evidence/captures/replay.json
@@ -0,0 +1 @@
+{"certificate":{"authentication":"not-provided","schema":"unitsentinel.proof-certificate/v1","sha256":"e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a"},"exit_code":0,"graph":{"graph_id":"wheel-anomaly-verified","schema":"unitsentinel.graph/v1","sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c"},"report":{"record":{"certificate_sha256":"e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a","fresh_result":{"record":{"checks_performed":2,"conflict_core":[],"contracts":[{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-reference"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-si"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"anomaly-score"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"normalized-acceleration"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"previous-wheel-speed-kph"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1/1000","value_id":"sample-period-ms"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1","value_id":"sample-period-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"speed-delta-kph"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"1","value_id":"speed-delta-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"wheel-speed-kph"}],"core_minimal":null,"graph_digest":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c","limits":{"max_core_shrink_checks":64,"max_memory_mb":256,"max_uniqueness_checks":577,"per_check_timeout_ms":250,"total_timeout_ms":5000},"registry_digest":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","solver_version":"4.16.0","status":"verified","underconstrained_values":[],"unknown_reason":null},"sha256":"f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd"},"graph_sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c","reason":null,"registry":{"sha256":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","version":"1.0.0"},"schema":"unitsentinel.certificate-replay/v1","status":"reproduced","strict_toolchain":true,"toolchain":{"certificate":{"solver_version":"4.16.0","verifier_version":"0.1.0"},"current":{"solver_version":"4.16.0","verifier_version":"0.1.0"},"match":true}},"sha256":"aca0b2794371a552a1f1b3af75bdd86b8cf8fb21e5cb664b835b2adf19acf3aa"},"schema":"unitsentinel.cli.replay/v1","tool":{"name":"unitsentinel","version":"0.1.0"}}
diff --git a/docs/evidence/captures/replay.txt b/docs/evidence/captures/replay.txt
new file mode 100644
index 0000000..c0e1aeb
--- /dev/null
+++ b/docs/evidence/captures/replay.txt
@@ -0,0 +1,21 @@
+$ .venv/bin/python -m unitsentinel replay \
+ .unitsentinel/evidence-run/wheel-anomaly.cert.json \
+ --graph docs/evidence/contracts/wheel-anomaly-verified.json \
+ --expect-sha256 e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a \
+ --strict-toolchain
+UnitSentinel replay: REPRODUCED
+exit code: 0
+tool: unitsentinel 0.1.0
+reason: none
+certificate sha256: e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a
+certificate authentication: not-provided
+graph id: wheel-anomaly-verified
+graph sha256: 139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c
+registry version: 1.0.0
+registry sha256: fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5
+replay sha256: aca0b2794371a552a1f1b3af75bdd86b8cf8fb21e5cb664b835b2adf19acf3aa
+toolchain match: yes (strict=yes)
+certificate toolchain: unitsentinel 0.1.0, z3 4.16.0
+current toolchain: unitsentinel 0.1.0, z3 4.16.0
+fresh result: verified f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd
+[exit 0]
diff --git a/docs/evidence/captures/verify.json b/docs/evidence/captures/verify.json
new file mode 100644
index 0000000..d0bc1d8
--- /dev/null
+++ b/docs/evidence/captures/verify.json
@@ -0,0 +1 @@
+{"certificate":{"authentication":"not-provided","schema":"unitsentinel.proof-certificate/v1","sha256":"e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a"},"certificate_output":"not-requested","exit_code":0,"graph":{"graph_id":"wheel-anomaly-verified","schema":"unitsentinel.graph/v1","sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c"},"registry":{"schema":"unitsentinel.unit-registry/v1","sha256":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","version":"1.0.0"},"result":{"record":{"checks_performed":2,"conflict_core":[],"contracts":[{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-reference"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-si"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"anomaly-score"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"normalized-acceleration"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"previous-wheel-speed-kph"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1/1000","value_id":"sample-period-ms"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1","value_id":"sample-period-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"speed-delta-kph"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"1","value_id":"speed-delta-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"wheel-speed-kph"}],"core_minimal":null,"graph_digest":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c","limits":{"max_core_shrink_checks":64,"max_memory_mb":256,"max_uniqueness_checks":577,"per_check_timeout_ms":250,"total_timeout_ms":5000},"registry_digest":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","solver_version":"4.16.0","status":"verified","underconstrained_values":[],"unknown_reason":null},"sha256":"f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd"},"schema":"unitsentinel.cli.verify/v1","tool":{"name":"unitsentinel","version":"0.1.0"}}
diff --git a/docs/evidence/captures/verify.txt b/docs/evidence/captures/verify.txt
new file mode 100644
index 0000000..28d9a17
--- /dev/null
+++ b/docs/evidence/captures/verify.txt
@@ -0,0 +1,27 @@
+$ .venv/bin/python -m unitsentinel verify \
+ docs/evidence/contracts/wheel-anomaly-verified.json \
+ --certificate .unitsentinel/evidence-run/wheel-anomaly.cert.json
+UnitSentinel verification: VERIFIED
+exit code: 0
+tool: unitsentinel 0.1.0
+graph id: wheel-anomaly-verified
+graph sha256: 139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c
+result sha256: f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd
+registry version: 1.0.0
+registry sha256: fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5
+solver: z3 4.16.0 (2 checks)
+contracts (10):
+ acceleration-reference | length^1 time^-2 | linear | scale=1 offset=0
+ acceleration-si | length^1 time^-2 | linear | scale=1 offset=0
+ anomaly-score | dimensionless | linear | scale=1 offset=0
+ normalized-acceleration | dimensionless | linear | scale=1 offset=0
+ previous-wheel-speed-kph | length^1 time^-1 | linear | scale=5/18 offset=0
+ sample-period-ms | time^1 | linear | scale=1/1000 offset=0
+ sample-period-si | time^1 | linear | scale=1 offset=0
+ speed-delta-kph | length^1 time^-1 | linear | scale=5/18 offset=0
+ speed-delta-si | length^1 time^-1 | linear | scale=1 offset=0
+ wheel-speed-kph | length^1 time^-1 | linear | scale=5/18 offset=0
+certificate sha256: e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a
+certificate authentication: not-provided
+certificate output: written
+[exit 0]
diff --git a/docs/evidence/claims/wheel-anomaly.cert.json b/docs/evidence/claims/wheel-anomaly.cert.json
new file mode 100644
index 0000000..d8fcfe4
--- /dev/null
+++ b/docs/evidence/claims/wheel-anomaly.cert.json
@@ -0,0 +1 @@
+{"graph":{"schema":"unitsentinel.graph/v1","sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c"},"proof":{"constraints":[{"constraint_id":"declaration/acceleration-reference/unit","rule":"unit-annotation","source":"declaration","source_id":"acceleration-reference"},{"constraint_id":"declaration/acceleration-si/unit","rule":"unit-annotation","source":"declaration","source_id":"acceleration-si"},{"constraint_id":"declaration/anomaly-score/unit","rule":"unit-annotation","source":"declaration","source_id":"anomaly-score"},{"constraint_id":"declaration/previous-wheel-speed-kph/unit","rule":"unit-annotation","source":"declaration","source_id":"previous-wheel-speed-kph"},{"constraint_id":"declaration/sample-period-ms/unit","rule":"unit-annotation","source":"declaration","source_id":"sample-period-ms"},{"constraint_id":"declaration/wheel-speed-kph/unit","rule":"unit-annotation","source":"declaration","source_id":"wheel-speed-kph"},{"constraint_id":"operation/compute-speed-delta/dimension","rule":"subtract-dimension","source":"operation","source_id":"compute-speed-delta"},{"constraint_id":"operation/compute-speed-delta/kind","rule":"subtract-kind","source":"operation","source_id":"compute-speed-delta"},{"constraint_id":"operation/compute-speed-delta/unit-transform","rule":"subtract-unit-transform","source":"operation","source_id":"compute-speed-delta"},{"constraint_id":"operation/derive-acceleration/dimension","rule":"divide-dimension","source":"operation","source_id":"derive-acceleration"},{"constraint_id":"operation/derive-acceleration/kind","rule":"divide-kind","source":"operation","source_id":"derive-acceleration"},{"constraint_id":"operation/derive-acceleration/unit-transform","rule":"divide-unit-transform","source":"operation","source_id":"derive-acceleration"},{"constraint_id":"operation/normalize-acceleration/dimension","rule":"divide-dimension","source":"operation","source_id":"normalize-acceleration"},{"constraint_id":"operation/normalize-acceleration/kind","rule":"divide-kind","source":"operation","source_id":"normalize-acceleration"},{"constraint_id":"operation/normalize-acceleration/unit-transform","rule":"divide-unit-transform","source":"operation","source_id":"normalize-acceleration"},{"constraint_id":"operation/normalize-sample-period/dimension","rule":"convert-dimension","source":"operation","source_id":"normalize-sample-period"},{"constraint_id":"operation/normalize-sample-period/kind","rule":"convert-kind","source":"operation","source_id":"normalize-sample-period"},{"constraint_id":"operation/normalize-sample-period/unit-transform","rule":"convert-unit-transform","source":"operation","source_id":"normalize-sample-period"},{"constraint_id":"operation/normalize-speed-delta/dimension","rule":"convert-dimension","source":"operation","source_id":"normalize-speed-delta"},{"constraint_id":"operation/normalize-speed-delta/kind","rule":"convert-kind","source":"operation","source_id":"normalize-speed-delta"},{"constraint_id":"operation/normalize-speed-delta/unit-transform","rule":"convert-unit-transform","source":"operation","source_id":"normalize-speed-delta"},{"constraint_id":"operation/score-acceleration/dimension","rule":"sigmoid-dimension","source":"operation","source_id":"score-acceleration"},{"constraint_id":"operation/score-acceleration/kind","rule":"sigmoid-kind","source":"operation","source_id":"score-acceleration"},{"constraint_id":"operation/score-acceleration/unit-transform","rule":"sigmoid-unit-transform","source":"operation","source_id":"score-acceleration"}],"contracts":[{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-reference"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-2"}],"kind":"linear","offset":"0","scale":"1","value_id":"acceleration-si"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"anomaly-score"},{"dimension":[],"kind":"linear","offset":"0","scale":"1","value_id":"normalized-acceleration"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"previous-wheel-speed-kph"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1/1000","value_id":"sample-period-ms"},{"dimension":[{"base":"time","exponent":"1"}],"kind":"linear","offset":"0","scale":"1","value_id":"sample-period-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"speed-delta-kph"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"1","value_id":"speed-delta-si"},{"dimension":[{"base":"length","exponent":"1"},{"base":"time","exponent":"-1"}],"kind":"linear","offset":"0","scale":"5/18","value_id":"wheel-speed-kph"}],"outcome":"verified","verification_result_sha256":"f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd"},"registry":{"schema":"unitsentinel.unit-registry/v1","sha256":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","version":"1.0.0"},"run":{"checks_performed":2,"limits":{"max_core_shrink_checks":64,"max_memory_mb":256,"max_uniqueness_checks":577,"per_check_timeout_ms":250,"total_timeout_ms":5000}},"schema":"unitsentinel.proof-certificate/v1","solver":{"implementation":"z3","version":"4.16.0"},"verifier":{"implementation":"unitsentinel","semantics":"unitsentinel.verifier/v1","version":"0.1.0"}}
\ No newline at end of file
diff --git a/docs/evidence/contracts/wheel-anomaly-conflict.json b/docs/evidence/contracts/wheel-anomaly-conflict.json
new file mode 100644
index 0000000..2d4f1b2
--- /dev/null
+++ b/docs/evidence/contracts/wheel-anomaly-conflict.json
@@ -0,0 +1 @@
+{"graph_id":"wheel-anomaly-conflict","inputs":["acceleration-reference","previous-wheel-speed-kph","sample-period-ms","wheel-speed-kph"],"nodes":[{"attributes":{},"inputs":["wheel-speed-kph","previous-wheel-speed-kph"],"node_id":"compute-speed-delta","operation":"subtract","output":"speed-delta-kph"},{"attributes":{"unit_id":"meter-per-second"},"inputs":["speed-delta-kph"],"node_id":"normalize-speed-delta","operation":"convert","output":"speed-delta-si"},{"attributes":{"unit_id":"second"},"inputs":["sample-period-ms"],"node_id":"normalize-sample-period","operation":"convert","output":"sample-period-si"},{"attributes":{},"inputs":["speed-delta-si","sample-period-si"],"node_id":"derive-acceleration","operation":"divide","output":"acceleration-si"},{"attributes":{},"inputs":["acceleration-si","acceleration-reference"],"node_id":"normalize-acceleration","operation":"divide","output":"normalized-acceleration"},{"attributes":{},"inputs":["normalized-acceleration"],"node_id":"score-acceleration","operation":"sigmoid","output":"anomaly-score"}],"outputs":["acceleration-si","anomaly-score"],"schema":"unitsentinel.graph/v1","values":[{"dtype":"float32","shape":["batch"],"unit_id":"meter-per-second-squared","value_id":"acceleration-reference"},{"dtype":"float32","shape":["batch"],"unit_id":"meter-per-second","value_id":"acceleration-si"},{"dtype":"float32","shape":["batch"],"unit_id":"one","value_id":"anomaly-score"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"normalized-acceleration"},{"dtype":"float32","shape":["batch"],"unit_id":"kilometer-per-hour","value_id":"previous-wheel-speed-kph"},{"dtype":"float32","shape":["batch"],"unit_id":"millisecond","value_id":"sample-period-ms"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"sample-period-si"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"speed-delta-kph"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"speed-delta-si"},{"dtype":"float32","shape":["batch"],"unit_id":"kilometer-per-hour","value_id":"wheel-speed-kph"}]}
\ No newline at end of file
diff --git a/docs/evidence/contracts/wheel-anomaly-verified.json b/docs/evidence/contracts/wheel-anomaly-verified.json
new file mode 100644
index 0000000..a882b83
--- /dev/null
+++ b/docs/evidence/contracts/wheel-anomaly-verified.json
@@ -0,0 +1 @@
+{"graph_id":"wheel-anomaly-verified","inputs":["acceleration-reference","previous-wheel-speed-kph","sample-period-ms","wheel-speed-kph"],"nodes":[{"attributes":{},"inputs":["wheel-speed-kph","previous-wheel-speed-kph"],"node_id":"compute-speed-delta","operation":"subtract","output":"speed-delta-kph"},{"attributes":{"unit_id":"meter-per-second"},"inputs":["speed-delta-kph"],"node_id":"normalize-speed-delta","operation":"convert","output":"speed-delta-si"},{"attributes":{"unit_id":"second"},"inputs":["sample-period-ms"],"node_id":"normalize-sample-period","operation":"convert","output":"sample-period-si"},{"attributes":{},"inputs":["speed-delta-si","sample-period-si"],"node_id":"derive-acceleration","operation":"divide","output":"acceleration-si"},{"attributes":{},"inputs":["acceleration-si","acceleration-reference"],"node_id":"normalize-acceleration","operation":"divide","output":"normalized-acceleration"},{"attributes":{},"inputs":["normalized-acceleration"],"node_id":"score-acceleration","operation":"sigmoid","output":"anomaly-score"}],"outputs":["acceleration-si","anomaly-score"],"schema":"unitsentinel.graph/v1","values":[{"dtype":"float32","shape":["batch"],"unit_id":"meter-per-second-squared","value_id":"acceleration-reference"},{"dtype":"float32","shape":["batch"],"unit_id":"meter-per-second-squared","value_id":"acceleration-si"},{"dtype":"float32","shape":["batch"],"unit_id":"one","value_id":"anomaly-score"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"normalized-acceleration"},{"dtype":"float32","shape":["batch"],"unit_id":"kilometer-per-hour","value_id":"previous-wheel-speed-kph"},{"dtype":"float32","shape":["batch"],"unit_id":"millisecond","value_id":"sample-period-ms"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"sample-period-si"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"speed-delta-kph"},{"dtype":"float32","shape":["batch"],"unit_id":null,"value_id":"speed-delta-si"},{"dtype":"float32","shape":["batch"],"unit_id":"kilometer-per-hour","value_id":"wheel-speed-kph"}]}
\ No newline at end of file
diff --git a/docs/evidence/data/scaling.json b/docs/evidence/data/scaling.json
new file mode 100644
index 0000000..1b14037
--- /dev/null
+++ b/docs/evidence/data/scaling.json
@@ -0,0 +1 @@
+{"environment":{"architecture":"x86_64","python":"3.12.3","solver":"4.16.0","system":"linux","unitsentinel":"0.1.0"},"recorded_at_utc":"2026-07-26T07:25:03+00:00","repetitions":3,"rows":[{"certificate_bytes":1533,"constraints":4,"graph_bytes":407,"nodes":1,"replay_median_ms":28.845358,"replay_runs_ms":[28.845358,29.077953,28.451814],"verify_median_ms":20.025903,"verify_runs_ms":[21.545381,19.66042,20.025903]},{"certificate_bytes":5082,"constraints":25,"graph_bytes":1737,"nodes":8,"replay_median_ms":53.977639,"replay_runs_ms":[53.66656,53.977639,54.248006],"verify_median_ms":42.020693,"verify_runs_ms":[41.530042,42.020693,42.327653]},{"certificate_bytes":17250,"constraints":97,"graph_bytes":6297,"nodes":32,"replay_median_ms":140.867159,"replay_runs_ms":[139.01514,140.867159,142.244826],"verify_median_ms":113.037724,"verify_runs_ms":[113.010352,113.037724,118.022064]},{"certificate_bytes":65922,"constraints":385,"graph_bytes":24537,"nodes":128,"replay_median_ms":500.381579,"replay_runs_ms":[523.942648,491.245913,500.381579],"verify_median_ms":413.363742,"verify_runs_ms":[423.325421,410.820339,413.363742]},{"certificate_bytes":130818,"constraints":769,"graph_bytes":48857,"nodes":256,"replay_median_ms":1034.880072,"replay_runs_ms":[1024.198686,1034.880072,1036.388748],"verify_median_ms":866.972044,"verify_runs_ms":[866.972044,862.329458,1119.904438]}],"schema":"unitsentinel.scaling-benchmark/v1"}
diff --git a/docs/evidence/demo/frame-01.svg b/docs/evidence/demo/frame-01.svg
new file mode 100644
index 0000000..139f55a
--- /dev/null
+++ b/docs/evidence/demo/frame-01.svg
@@ -0,0 +1,46 @@
+
+
diff --git a/docs/evidence/demo/frame-02.svg b/docs/evidence/demo/frame-02.svg
new file mode 100644
index 0000000..5dadffa
--- /dev/null
+++ b/docs/evidence/demo/frame-02.svg
@@ -0,0 +1,53 @@
+
+
diff --git a/docs/evidence/demo/frame-03.svg b/docs/evidence/demo/frame-03.svg
new file mode 100644
index 0000000..be20c70
--- /dev/null
+++ b/docs/evidence/demo/frame-03.svg
@@ -0,0 +1,47 @@
+
+
diff --git a/docs/evidence/demo/frames.json b/docs/evidence/demo/frames.json
new file mode 100644
index 0000000..122838c
--- /dev/null
+++ b/docs/evidence/demo/frames.json
@@ -0,0 +1 @@
+{"frames":[{"delay_ms":2400,"path":"frame-01.svg"},{"delay_ms":2400,"path":"frame-02.svg"},{"delay_ms":2800,"path":"frame-03.svg"}],"schema":"unitsentinel.demo-frames/v1"}
diff --git a/docs/evidence/manifest.json b/docs/evidence/manifest.json
new file mode 100644
index 0000000..82c5e48
--- /dev/null
+++ b/docs/evidence/manifest.json
@@ -0,0 +1 @@
+{"files":[{"bytes":178244,"path":"docs/assets/certificate-lineage.png","sha256":"bd7459fcae3a6bbfcf44b2fbcfcbdfc34ee23a87322de46f0dc629b86af4d9be"},{"bytes":6630,"path":"docs/assets/certificate-lineage.svg","sha256":"c8f6c7939f164d87a5fd946d5fd7fc24816316bad0f1c2c350aa95af9f72cd11"},{"bytes":150997,"path":"docs/assets/conflict-core.png","sha256":"71ff7a528f110ee31a3fa5db862982bfe00f0aa1ed4ebbb9869c96b6cf68055d"},{"bytes":5524,"path":"docs/assets/conflict-core.svg","sha256":"ba9e58099d9791d5a9ab8297bfbee446580a1b807234fb9a1319a772a627a9a1"},{"bytes":121548,"path":"docs/assets/conflict-terminal.png","sha256":"9fe5afeb22c9c9fe679a391f30f2a74b206ffc9213228c99b7e9c0c0b191aeef"},{"bytes":5431,"path":"docs/assets/conflict-terminal.svg","sha256":"ef62f5ba2645a62ca0fd5d16d67923ff3289f4b96188fc33e926036f39aa8536"},{"bytes":129162,"path":"docs/assets/replay-terminal.png","sha256":"8810182ec118e78c55de8c9bb52ae8307a7ee2f6e6d15f900cb27f8b8e3840cd"},{"bytes":5560,"path":"docs/assets/replay-terminal.svg","sha256":"611a30f0f88c2d390487b0ac9392a209684e795ce56b4ebc8b4e531bdcf56cfd"},{"bytes":103956,"path":"docs/assets/scaling.png","sha256":"594c97167fa4dcf1f4f94678131d4acd3cb678ebbc04814cf02f95f4ea60a05f"},{"bytes":6738,"path":"docs/assets/scaling.svg","sha256":"6a0b1c4b7d5d6fc22dee186ef6f1390f555f84688a4368d3c5d219b8e20d8ebc"},{"bytes":189661,"path":"docs/assets/unitsentinel-demo.gif","sha256":"b5293575fa14db5a237abfb7b83a28184fe60343cc9fa456dcefd5b82358309a"},{"bytes":213625,"path":"docs/assets/verification-pipeline.png","sha256":"d268104f111d76302292bea232a44e0f7c1c4f0bff2766c3f5478cb8f26ee391"},{"bytes":9955,"path":"docs/assets/verification-pipeline.svg","sha256":"3996ba132fa0f373ac84b23f22fc986c8f4f6bd5b412e5600e71194f1847e238"},{"bytes":154941,"path":"docs/assets/verify-terminal.png","sha256":"7103380189f9463f6c0440bcd77474b7db24bd25a2063ff564d39ea328a39e64"},{"bytes":6776,"path":"docs/assets/verify-terminal.svg","sha256":"31132b7f6e5dde1a575416179f8b3d69a8062d084153ed391ec72460f66e0868"},{"bytes":183966,"path":"docs/assets/wheel-anomaly-contract.png","sha256":"953dd7e70257f0ef958fe589392f848e48e5fb78a2a9c8eebfc3dbb2ef22e0e9"},{"bytes":8319,"path":"docs/assets/wheel-anomaly-contract.svg","sha256":"65cf986a9c56ad8b3a27367cb5f0a0280a72d88ca2c551093f79cce985e496b2"},{"bytes":5900,"path":"docs/evidence/README.md","sha256":"39e7dcb29bb60dce9fc3162ee5cdb33b1ad3283d7501a563529c89067d80d46b"},{"bytes":1604,"path":"docs/evidence/captures/conflict.json","sha256":"49d9d8ebbf0aaae89da60fbaf3b7294b7ff983c3c44d650132c9a833c622480a"},{"bytes":1039,"path":"docs/evidence/captures/conflict.txt","sha256":"67dfdd382fed2b35209a209b3e58e8c4eab909861217a74cebf0130ebc9d58a3"},{"bytes":3000,"path":"docs/evidence/captures/replay.json","sha256":"af6bbb96e4d9e99d7512c5fc66c6fec0ec047f27b6f84a1c7e43b68703d17075"},{"bytes":1017,"path":"docs/evidence/captures/replay.txt","sha256":"4159b13f925369f2f06af2f38937d2757e12531cfa9ee27f61322add24cb622c"},{"bytes":2537,"path":"docs/evidence/captures/verify.json","sha256":"8f80645c12f597cb8dff01ae9dc169c95d81ff40be97a7dcd560861116c1bdb6"},{"bytes":1412,"path":"docs/evidence/captures/verify.txt","sha256":"54d6ea2c19fc5cdfb3e4c6a0d048f8bac5daf188595844e39539fc00960e878c"},{"bytes":5559,"path":"docs/evidence/claims/wheel-anomaly.cert.json","sha256":"e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a"},{"bytes":2064,"path":"docs/evidence/contracts/wheel-anomaly-conflict.json","sha256":"6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708"},{"bytes":2072,"path":"docs/evidence/contracts/wheel-anomaly-verified.json","sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c"},{"bytes":1416,"path":"docs/evidence/data/scaling.json","sha256":"560491f6d6fbc35c7a1ae61e6d642f3c7daf4649576daa48f86340f3ee637019"},{"bytes":5431,"path":"docs/evidence/demo/frame-01.svg","sha256":"ef62f5ba2645a62ca0fd5d16d67923ff3289f4b96188fc33e926036f39aa8536"},{"bytes":6776,"path":"docs/evidence/demo/frame-02.svg","sha256":"31132b7f6e5dde1a575416179f8b3d69a8062d084153ed391ec72460f66e0868"},{"bytes":5560,"path":"docs/evidence/demo/frame-03.svg","sha256":"611a30f0f88c2d390487b0ac9392a209684e795ce56b4ebc8b4e531bdcf56cfd"},{"bytes":172,"path":"docs/evidence/demo/frames.json","sha256":"c8cbc9b827dc4a42175a796b1925c900910cdaaa3aabe94425bc87f5dea31596"},{"bytes":1117,"path":"docs/evidence/provenance.json","sha256":"8119639430b237a7c358f2b5c0570d8e8b7ccc427391164d7009f24c889bf14d"}],"schema":"unitsentinel.evidence-manifest/v1"}
diff --git a/docs/evidence/provenance.json b/docs/evidence/provenance.json
new file mode 100644
index 0000000..639bf4e
--- /dev/null
+++ b/docs/evidence/provenance.json
@@ -0,0 +1 @@
+{"certificate":{"authentication":"not-provided","bytes":5559,"sha256":"e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a"},"conflict":{"checks_performed":5,"core":["declaration/acceleration-si/unit","operation/derive-acceleration/dimension","operation/normalize-sample-period/dimension","operation/normalize-speed-delta/dimension"],"core_minimal":true,"graph_sha256":"6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708","result_sha256":"521b85cbf597e5ca45716c7add5346cc65191063060c24c87ca70797bac67aea","status":"conflict"},"registry":{"sha256":"fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5","version":"1.0.0"},"replay":{"authentication":"not-established","report_sha256":"aca0b2794371a552a1f1b3af75bdd86b8cf8fb21e5cb664b835b2adf19acf3aa","status":"reproduced","strict_toolchain":true},"schema":"unitsentinel.evidence-provenance/v1","verified":{"checks_performed":2,"contracts":10,"graph_sha256":"139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c","result_sha256":"f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd","status":"verified"}}
diff --git a/docs/graph-format.md b/docs/graph-format.md
new file mode 100644
index 0000000..57f46fa
--- /dev/null
+++ b/docs/graph-format.md
@@ -0,0 +1,167 @@
+# Canonical graph format
+
+The v1 graph document is a closed, content-addressed contract. It describes
+tensor metadata and computation topology; successfully decoding it does **not**
+mean that the graph is dimensionally consistent. That decision belongs to the
+verifier.
+
+## Byte boundary
+
+`decode_graph()` accepts exact `bytes`, not text or a Python dictionary. Before
+constructing a JSON tree it enforces:
+
+- a 1 MiB document limit;
+- valid UTF-8 without a BOM;
+- at most eight nested containers;
+- at most 1,024 items in one object or array;
+- a 32,768-token structural budget.
+
+The preflight scanner understands quoted strings and escapes, so brackets and
+commas inside a JSON string do not affect structural counts. A second validation
+pass repeats the limits on the parsed tree.
+
+The remaining byte rules are deliberately strict:
+
+- duplicate object keys are rejected;
+- `NaN`, infinities, and all floating-point JSON numbers are rejected;
+- integer tokens are limited to ten digits before conversion;
+- keys are sorted and separators are compact;
+- Unicode is emitted directly as UTF-8 rather than optional `\u` escapes;
+- no leading/trailing whitespace, BOM, or trailing newline is accepted;
+- re-encoding the semantic graph must reproduce every input byte.
+
+Exact rational power exponents use reduced strings such as `"2"` or `"1/2"`.
+Physical values are never smuggled through an imprecise JSON float.
+
+## Root schema
+
+Every document has exactly six fields. This is the real speed-contract example
+formatted for readability (the generator emits its compact canonical bytes):
+
+```json
+{
+ "graph_id": "speed-contract",
+ "inputs": ["raw-speed"],
+ "nodes": [
+ {
+ "attributes": {"unit_id": "meter-per-second"},
+ "inputs": ["raw-speed"],
+ "node_id": "normalize-speed",
+ "operation": "convert",
+ "output": "si-speed"
+ }
+ ],
+ "outputs": ["si-speed"],
+ "schema": "unitsentinel.graph/v1",
+ "values": [
+ {
+ "dtype": "float32",
+ "shape": ["batch"],
+ "unit_id": "kilometer-per-hour",
+ "value_id": "raw-speed"
+ },
+ {
+ "dtype": "float32",
+ "shape": ["batch"],
+ "unit_id": "meter-per-second",
+ "value_id": "si-speed"
+ }
+ ]
+}
+```
+
+The executable generator in `examples/build_speed_contract.py` is the
+authoritative byte representation.
+
+### Values
+
+Each entry has exactly:
+
+| Field | Contract |
+| --- | --- |
+| `value_id` | canonical lowercase ASCII identifier |
+| `dtype` | `float16`, `bfloat16`, `float32`, or `float64` |
+| `shape` | zero to eight positive integer or canonical symbolic axes |
+| `unit_id` | canonical registry identifier or `null` |
+
+The values array is sorted by `value_id`. This removes one irrelevant source of
+digest variation while preserving meaningful public input/output ordering.
+
+### Nodes
+
+Each node has one canonical `node_id`, one output, an ordered input array, and a
+closed attributes object:
+
+| Operation family | Arity | Attributes |
+| --- | ---: | --- |
+| `identity`, `exp`, `log`, `sigmoid`, `softmax` | 1 | none |
+| `add`, `subtract`, `minimum`, `maximum` | 2 | none |
+| `multiply`, `divide`, `matmul` | 2 | none |
+| `power` | 1 | exact rational `exponent` |
+| `convert` | 1 | canonical target `unit_id` |
+
+Unknown operations or attributes are rejected. There is no URL, import path,
+callback, executable expression, or extension hook.
+
+## Topology invariants
+
+The immutable graph constructor revalidates all nested values and requires:
+
+- at least one input, value, and output;
+- unique value, node, input, and output identifiers;
+- node identifiers disjoint from value identifiers;
+- every node input already available earlier in topological order;
+- exactly one producer for every non-input value;
+- every declared output to exist;
+- every value and node to contribute to at least one public output.
+
+Those rules reject cycles, forward references, overwritten inputs, unproduced
+declarations, and dead subgraphs without executing graph code.
+
+Unit annotations are checked separately against one exact registry snapshot.
+Aliases are useful at an ingestion boundary, but canonical graph contracts must
+name the registry's canonical unit identifiers.
+
+## Reproducible example
+
+After the local setup from the README:
+
+```bash
+mkdir -p .unitsentinel
+.venv/bin/python -I examples/build_speed_contract.py \
+ > .unitsentinel/speed-contract.json
+sha256sum .unitsentinel/speed-contract.json
+```
+
+Expected SHA-256:
+
+```text
+aecbeff2ce89cfd7b2aab6a0414ec307a5061577f4b8b0d1c53298f896569546
+```
+
+`tests/test_examples.py` runs the generator in an isolated interpreter,
+requires empty stderr and no trailing newline, decodes the exact stdout bytes,
+and pins the same graph digest.
+
+## CLI and certificate binding
+
+The production CLI accepts the same canonical bytes from a bounded regular
+file:
+
+```bash
+.venv/bin/python -m unitsentinel verify \
+ .unitsentinel/speed-contract.json \
+ --certificate .unitsentinel/speed-contract.cert.json
+```
+
+The final input path may not be a symlink, directory, FIFO, device, or oversized
+file. The reader checks the open descriptor with `fstat`, uses nonblocking
+bounded reads, and caps growth even when the initial file size becomes stale.
+It does not attempt to freeze a regular file against concurrent same-UID
+writers; that race is outside the stated CLI threat model.
+
+A positive certificate records the graph schema and SHA-256, not a pathname.
+Replay therefore accepts a caller-supplied canonical graph file, recomputes its
+identity, and stops before the solver if it does not match the claim. Moving an
+unchanged graph does not change its identity; changing even irrelevant JSON
+spelling is impossible because noncanonical bytes are rejected at decode time.
diff --git a/docs/registry.md b/docs/registry.md
new file mode 100644
index 0000000..0bf2066
--- /dev/null
+++ b/docs/registry.md
@@ -0,0 +1,101 @@
+# Built-in registry contract
+
+UnitSentinel's registry is a versioned input to verification, not a mutable
+process-wide table. Every positive certificate binds the exact registry schema,
+version, and SHA-256 it was checked against; detached replay requires the
+current registry identity to match.
+
+## Snapshot identity
+
+| Field | Value |
+| --- | --- |
+| Schema | `unitsentinel.unit-registry/v1` |
+| Registry version | `1.0.0` |
+| Canonical units | 33 |
+| Explicit aliases | 10 |
+| SHA-256 | `fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5` |
+
+The digest covers the schema, registry version, ordered unit records, aliases,
+dimension exponents, quantity kinds, scales, offsets, identifiers, and display
+symbols. It is computed over compact, key-sorted UTF-8 JSON with no BOM or
+trailing newline. The digest itself is not part of the hashed record.
+
+Changing any unit or alias requires a new registry version and produces a new
+digest. Reusing `1.0.0` for different bytes would violate the registry contract.
+
+## Validation invariants
+
+A registry snapshot is accepted only when:
+
+- its version is canonical three-component SemVer;
+- units and aliases are exact immutable tuples within declared size limits;
+- every entry is an exact, currently valid domain value;
+- canonical unit identifiers and display symbols are unique;
+- units and aliases are sorted by identifier;
+- every alias is unique, does not collide with a canonical unit, and points
+ directly to a canonical unit in the same snapshot;
+- the freshly recomputed digest matches the digest captured at construction.
+
+Lookup repeats the complete validation. This catches a nested `Unit` or alias
+that was changed through low-level Python mutation after construction. The
+registry deliberately does not case-fold, guess plurals, synthesize SI
+prefixes, follow alias chains, or resolve display symbols.
+
+## Curated surface
+
+| Family | Canonical unit identifiers |
+| --- | --- |
+| Dimensionless | `one`, `percent` |
+| Length | `meter`, `kilometer`, `centimeter`, `millimeter`, `micrometer` |
+| Mass | `kilogram`, `gram`, `milligram` |
+| Time | `second`, `millisecond`, `minute`, `hour` |
+| Other SI bases | `ampere`, `mole`, `candela` |
+| Absolute temperature | `kelvin`, `degree-celsius`, `degree-fahrenheit` |
+| Temperature differences | `delta-kelvin`, `delta-celsius`, `delta-fahrenheit` |
+| Kinematics | `meter-per-second`, `kilometer-per-hour`, `meter-per-second-squared` |
+| Derived engineering units | `coulomb`, `hertz`, `newton`, `pascal`, `joule`, `watt`, `volt` |
+
+The explicit aliases are:
+
+```text
+celsius -> degree-celsius
+celsius-delta -> delta-celsius
+centimetre -> centimeter
+fahrenheit -> degree-fahrenheit
+fahrenheit-delta -> delta-fahrenheit
+kelvin-delta -> delta-kelvin
+kilometre -> kilometer
+metre -> meter
+micrometre -> micrometer
+millimetre -> millimeter
+```
+
+Equivalent transforms are allowed when their semantics remain explicit.
+`delta-kelvin` and `delta-celsius`, for example, intentionally have the same
+dimension, scale, and offset but distinct public identifiers and symbols.
+
+## Deliberate exclusions
+
+The first snapshot omits units whose scientific meaning would collapse under
+the current `Dimension` and `QuantityKind` model:
+
+- angle degrees or radians versus a plain dimensionless ratio;
+- becquerel versus hertz;
+- gray versus sievert;
+- torque units versus energy units.
+
+Adding those names before the type system can preserve their semantic
+distinction would create a registry that is numerically convenient but unsafe
+for proof certificates.
+
+## Reproducing the fingerprint
+
+From a clean checkout with the package installed:
+
+```bash
+PYTHONPATH=src .venv/bin/python -c \
+ 'from unitsentinel import BUILTIN_REGISTRY; print(BUILTIN_REGISTRY.digest)'
+```
+
+The golden digest and the full canonical record are also exercised by
+`tests/test_registry.py`.
diff --git a/examples/build_speed_contract.py b/examples/build_speed_contract.py
new file mode 100644
index 0000000..d7c9ec1
--- /dev/null
+++ b/examples/build_speed_contract.py
@@ -0,0 +1,54 @@
+"""Emit the canonical speed-normalization graph used in documentation."""
+
+from __future__ import annotations
+
+import sys
+
+from unitsentinel import (
+ ComputationGraph,
+ Node,
+ Operation,
+ ScalarType,
+ ValueSpec,
+ encode_graph,
+)
+
+
+def build_graph() -> ComputationGraph:
+ return ComputationGraph(
+ graph_id="speed-contract",
+ values=(
+ ValueSpec(
+ "raw-speed",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "kilometer-per-hour",
+ ),
+ ValueSpec(
+ "si-speed",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "meter-per-second",
+ ),
+ ),
+ inputs=("raw-speed",),
+ nodes=(
+ Node(
+ "normalize-speed",
+ Operation.CONVERT,
+ ("raw-speed",),
+ "si-speed",
+ target_unit_id="meter-per-second",
+ ),
+ ),
+ outputs=("si-speed",),
+ )
+
+
+def main() -> int:
+ sys.stdout.buffer.write(encode_graph(build_graph()))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/build_wheel_anomaly_contract.py b/examples/build_wheel_anomaly_contract.py
new file mode 100644
index 0000000..85783d3
--- /dev/null
+++ b/examples/build_wheel_anomaly_contract.py
@@ -0,0 +1,156 @@
+"""Emit a verified or deliberately conflicting wheel-anomaly feature graph."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from collections.abc import Sequence
+from typing import Final, Literal
+
+from unitsentinel import (
+ ComputationGraph,
+ Node,
+ Operation,
+ ScalarType,
+ ValueSpec,
+ encode_graph,
+)
+
+GraphVariant = Literal["verified", "conflict"]
+VARIANTS: Final[tuple[GraphVariant, ...]] = ("verified", "conflict")
+
+
+def build_graph(variant: GraphVariant = "verified") -> ComputationGraph:
+ """Build one realistic feature pipeline with an optional serving-contract bug."""
+
+ if variant not in VARIANTS:
+ raise ValueError("graph variant must be verified or conflict")
+ acceleration_unit = (
+ "meter-per-second-squared" if variant == "verified" else "meter-per-second"
+ )
+ return ComputationGraph(
+ graph_id=f"wheel-anomaly-{variant}",
+ values=(
+ ValueSpec(
+ "acceleration-reference",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "meter-per-second-squared",
+ ),
+ ValueSpec(
+ "acceleration-si",
+ ScalarType.FLOAT32,
+ ("batch",),
+ acceleration_unit,
+ ),
+ ValueSpec(
+ "anomaly-score",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "one",
+ ),
+ ValueSpec(
+ "normalized-acceleration",
+ ScalarType.FLOAT32,
+ ("batch",),
+ ),
+ ValueSpec(
+ "previous-wheel-speed-kph",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "kilometer-per-hour",
+ ),
+ ValueSpec(
+ "sample-period-ms",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "millisecond",
+ ),
+ ValueSpec(
+ "sample-period-si",
+ ScalarType.FLOAT32,
+ ("batch",),
+ ),
+ ValueSpec("speed-delta-kph", ScalarType.FLOAT32, ("batch",)),
+ ValueSpec("speed-delta-si", ScalarType.FLOAT32, ("batch",)),
+ ValueSpec(
+ "wheel-speed-kph",
+ ScalarType.FLOAT32,
+ ("batch",),
+ "kilometer-per-hour",
+ ),
+ ),
+ inputs=(
+ "acceleration-reference",
+ "previous-wheel-speed-kph",
+ "sample-period-ms",
+ "wheel-speed-kph",
+ ),
+ nodes=(
+ Node(
+ "compute-speed-delta",
+ Operation.SUBTRACT,
+ ("wheel-speed-kph", "previous-wheel-speed-kph"),
+ "speed-delta-kph",
+ ),
+ Node(
+ "normalize-speed-delta",
+ Operation.CONVERT,
+ ("speed-delta-kph",),
+ "speed-delta-si",
+ target_unit_id="meter-per-second",
+ ),
+ Node(
+ "normalize-sample-period",
+ Operation.CONVERT,
+ ("sample-period-ms",),
+ "sample-period-si",
+ target_unit_id="second",
+ ),
+ Node(
+ "derive-acceleration",
+ Operation.DIVIDE,
+ ("speed-delta-si", "sample-period-si"),
+ "acceleration-si",
+ ),
+ Node(
+ "normalize-acceleration",
+ Operation.DIVIDE,
+ ("acceleration-si", "acceleration-reference"),
+ "normalized-acceleration",
+ ),
+ Node(
+ "score-acceleration",
+ Operation.SIGMOID,
+ ("normalized-acceleration",),
+ "anomaly-score",
+ ),
+ ),
+ outputs=("acceleration-si", "anomaly-score"),
+ )
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Emit one canonical wheel-anomaly contract graph.",
+ )
+ parser.add_argument(
+ "--variant",
+ choices=VARIANTS,
+ default="verified",
+ help="select the valid pipeline or its deliberate serving-contract bug",
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ arguments = _parser().parse_args(argv)
+ variant = arguments.variant
+ if variant not in VARIANTS:
+ raise AssertionError("argparse returned an unsupported graph variant")
+ sys.stdout.buffer.write(encode_graph(build_graph(variant)))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/pyproject.toml b/pyproject.toml
index d9bf662..0125b58 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,9 @@ requires-python = ">=3.11"
authors = [
{ name = "Omar Ibrahim", email = "31526072+omar07ibrahim@users.noreply.github.com" },
]
-dependencies = []
+dependencies = [
+ "z3-solver==4.16.0.0",
+]
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
@@ -22,6 +24,9 @@ classifiers = [
"Typing :: Typed",
]
+[project.scripts]
+unitsentinel = "unitsentinel.cli:main"
+
[project.optional-dependencies]
dev = [
"build==1.5.0",
diff --git a/src/unitsentinel/__init__.py b/src/unitsentinel/__init__.py
index 2729400..cc791ab 100644
--- a/src/unitsentinel/__init__.py
+++ b/src/unitsentinel/__init__.py
@@ -1,7 +1,126 @@
-"""UnitSentinel's stable package identity."""
+"""Exact dimensional contracts for scientific and ML computation graphs."""
from typing import Final
-__version__: Final = "0.1.0"
+from .certificate import (
+ CertificateDecodeError,
+ CertificateError,
+ ProofCertificate,
+ create_certificate,
+ decode_certificate,
+ encode_certificate,
+)
+from .domain import (
+ AMOUNT_OF_SUBSTANCE,
+ DIMENSIONLESS,
+ ELECTRIC_CURRENT,
+ LENGTH,
+ LUMINOUS_INTENSITY,
+ MASS,
+ THERMODYNAMIC_TEMPERATURE,
+ TIME,
+ BaseDimension,
+ ConversionError,
+ Dimension,
+ DimensionError,
+ Quantity,
+ QuantityKind,
+ Unit,
+ UnitDefinitionError,
+ UnitSentinelError,
+)
+from .graph import (
+ ComputationGraph,
+ GraphError,
+ GraphValidationError,
+ Node,
+ Operation,
+ ScalarType,
+ ValueSpec,
+)
+from .graph_codec import GraphDecodeError, decode_graph, encode_graph
+from .registry import (
+ BUILTIN_REGISTRY,
+ RegistryError,
+ UnitAlias,
+ UnitRegistry,
+ UnknownUnitError,
+)
+from .replay import (
+ CertificateReplay,
+ CertificateReplayError,
+ ReplayReason,
+ ReplayStatus,
+ replay_certificate,
+)
+from .verification import (
+ ConstraintSource,
+ ConstraintWitness,
+ InferredContract,
+ SolverLimits,
+ UnknownReason,
+ VerificationError,
+ VerificationResult,
+ VerificationStatus,
+)
+from .verifier import constraint_catalog, verify_graph
+from .version import VERSION
-__all__ = ["__version__"]
+__version__: Final = VERSION
+
+__all__ = [
+ "AMOUNT_OF_SUBSTANCE",
+ "BUILTIN_REGISTRY",
+ "DIMENSIONLESS",
+ "ELECTRIC_CURRENT",
+ "LENGTH",
+ "LUMINOUS_INTENSITY",
+ "MASS",
+ "THERMODYNAMIC_TEMPERATURE",
+ "TIME",
+ "BaseDimension",
+ "CertificateDecodeError",
+ "CertificateError",
+ "CertificateReplay",
+ "CertificateReplayError",
+ "ComputationGraph",
+ "ConstraintSource",
+ "ConstraintWitness",
+ "ConversionError",
+ "Dimension",
+ "DimensionError",
+ "GraphDecodeError",
+ "GraphError",
+ "GraphValidationError",
+ "InferredContract",
+ "Node",
+ "Operation",
+ "ProofCertificate",
+ "Quantity",
+ "QuantityKind",
+ "RegistryError",
+ "ReplayReason",
+ "ReplayStatus",
+ "ScalarType",
+ "SolverLimits",
+ "Unit",
+ "UnitAlias",
+ "UnitDefinitionError",
+ "UnitRegistry",
+ "UnitSentinelError",
+ "UnknownReason",
+ "UnknownUnitError",
+ "ValueSpec",
+ "VerificationError",
+ "VerificationResult",
+ "VerificationStatus",
+ "__version__",
+ "constraint_catalog",
+ "create_certificate",
+ "decode_certificate",
+ "decode_graph",
+ "encode_certificate",
+ "encode_graph",
+ "replay_certificate",
+ "verify_graph",
+]
diff --git a/src/unitsentinel/__main__.py b/src/unitsentinel/__main__.py
new file mode 100644
index 0000000..29aa997
--- /dev/null
+++ b/src/unitsentinel/__main__.py
@@ -0,0 +1,5 @@
+"""Run UnitSentinel as a Python module."""
+
+from .cli import main
+
+raise SystemExit(main())
diff --git a/src/unitsentinel/canonical.py b/src/unitsentinel/canonical.py
new file mode 100644
index 0000000..ca4a1e8
--- /dev/null
+++ b/src/unitsentinel/canonical.py
@@ -0,0 +1,24 @@
+"""Canonical byte primitives shared by content-addressed contracts."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+
+def canonical_json_bytes(value: object) -> bytes:
+ """Encode a JSON-compatible value into deterministic UTF-8 bytes."""
+
+ return json.dumps(
+ value,
+ ensure_ascii=False,
+ allow_nan=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ).encode("utf-8")
+
+
+def sha256_hex(value: bytes) -> str:
+ """Return a lowercase SHA-256 digest for an exact byte sequence."""
+
+ return hashlib.sha256(value).hexdigest()
diff --git a/src/unitsentinel/certificate.py b/src/unitsentinel/certificate.py
new file mode 100644
index 0000000..b520e4e
--- /dev/null
+++ b/src/unitsentinel/certificate.py
@@ -0,0 +1,661 @@
+"""Detached positive proof certificates for verified computation graphs."""
+
+from __future__ import annotations
+
+import hmac
+import re
+from dataclasses import dataclass, field
+from fractions import Fraction
+from typing import Final, cast
+
+from .canonical import canonical_json_bytes, sha256_hex
+from .domain import (
+ BaseDimension,
+ Dimension,
+ QuantityKind,
+ UnitSentinelError,
+ _fraction_text,
+)
+from .graph import (
+ GRAPH_SCHEMA,
+ MAX_GRAPH_NODES,
+ MAX_GRAPH_VALUES,
+ ComputationGraph,
+)
+from .json_boundary import (
+ CanonicalJSONError,
+ CanonicalJSONLimits,
+ decode_canonical_json,
+)
+from .registry import (
+ BUILTIN_REGISTRY,
+ MAX_REGISTRY_VERSION_LENGTH,
+ REGISTRY_SCHEMA,
+ REGISTRY_VERSION,
+ SHA256_HEX,
+ UnitRegistry,
+)
+from .verification import (
+ MAX_CORE_SHRINK_CHECKS,
+ MAX_UNIQUENESS_CHECKS,
+ ConstraintSource,
+ ConstraintWitness,
+ InferredContract,
+ SolverLimits,
+ VerificationResult,
+ VerificationStatus,
+)
+from .verifier import constraint_catalog, verify_graph
+from .version import VERSION
+
+CERTIFICATE_SCHEMA: Final = "unitsentinel.proof-certificate/v1"
+VERIFIER_IMPLEMENTATION: Final = "unitsentinel"
+VERIFIER_SEMANTICS: Final = "unitsentinel.verifier/v1"
+SOLVER_IMPLEMENTATION: Final = "z3"
+MAX_CERTIFICATE_CONSTRAINTS: Final = MAX_GRAPH_VALUES + 3 * MAX_GRAPH_NODES
+MAX_CERTIFICATE_CONTRACTS: Final = MAX_GRAPH_VALUES
+MAX_CERTIFICATE_BYTES: Final = 2_097_152
+MAX_CERTIFICATE_JSON_DEPTH: Final = 8
+MAX_CERTIFICATE_JSON_VALUES: Final = 65_536
+MAX_CERTIFICATE_STRING_LENGTH: Final = 192
+MAX_CERTIFICATE_INTEGER_DIGITS: Final = 10
+MAX_CERTIFICATE_SOLVER_VERSION_LENGTH: Final = 32
+MAX_CERTIFICATE_CHECKS: Final = 1 + max(
+ MAX_CORE_SHRINK_CHECKS,
+ MAX_UNIQUENESS_CHECKS,
+)
+_DEFAULT_SOLVER_LIMITS: Final = SolverLimits()
+_CERTIFICATE_JSON_LIMITS: Final = CanonicalJSONLimits(
+ max_bytes=MAX_CERTIFICATE_BYTES,
+ max_depth=MAX_CERTIFICATE_JSON_DEPTH,
+ max_container_items=MAX_CERTIFICATE_CONSTRAINTS,
+ max_total_values=MAX_CERTIFICATE_JSON_VALUES,
+ max_string_length=MAX_CERTIFICATE_STRING_LENGTH,
+ max_integer_digits=MAX_CERTIFICATE_INTEGER_DIGITS,
+)
+_RATIONAL_TEXT: Final = re.compile(r"^(?:0|-?[1-9][0-9]*)(?:/[1-9][0-9]*)?$")
+_ROOT_FIELDS: Final = frozenset(
+ {"graph", "proof", "registry", "run", "schema", "solver", "verifier"}
+)
+_GRAPH_FIELDS: Final = frozenset({"schema", "sha256"})
+_PROOF_FIELDS: Final = frozenset(
+ {
+ "constraints",
+ "contracts",
+ "outcome",
+ "verification_result_sha256",
+ }
+)
+_REGISTRY_FIELDS: Final = frozenset({"schema", "sha256", "version"})
+_RUN_FIELDS: Final = frozenset({"checks_performed", "limits"})
+_SOLVER_FIELDS: Final = frozenset({"implementation", "version"})
+_VERIFIER_FIELDS: Final = frozenset({"implementation", "semantics", "version"})
+_LIMIT_FIELDS: Final = frozenset(
+ {
+ "max_core_shrink_checks",
+ "max_memory_mb",
+ "max_uniqueness_checks",
+ "per_check_timeout_ms",
+ "total_timeout_ms",
+ }
+)
+_CONSTRAINT_FIELDS: Final = frozenset({"constraint_id", "rule", "source", "source_id"})
+_CONTRACT_FIELDS: Final = frozenset(
+ {"dimension", "kind", "offset", "scale", "value_id"}
+)
+_DIMENSION_TERM_FIELDS: Final = frozenset({"base", "exponent"})
+
+
+class CertificateError(UnitSentinelError):
+ """Raised when a proof certificate cannot be created or trusted."""
+
+
+class CertificateDecodeError(CertificateError):
+ """Raised when bytes do not encode one exact bounded certificate claim."""
+
+
+def _require_semver(value: object, *, label: str) -> str:
+ if (
+ type(value) is not str
+ or len(value) > MAX_REGISTRY_VERSION_LENGTH
+ or REGISTRY_VERSION.fullmatch(value) is None
+ ):
+ raise CertificateError(f"{label} must be canonical SemVer")
+ return value
+
+
+@dataclass(frozen=True, slots=True)
+class ProofCertificate:
+ """A deterministic detached record of one positive verification claim.
+
+ The record is content-addressed, not signed. Direct construction therefore
+ produces an untrusted claim; callers should use :func:`create_certificate`
+ for issuance and replay the claim against its bound graph and registry
+ before relying on it.
+ """
+
+ registry_version: str
+ verifier_version: str
+ constraints: tuple[ConstraintWitness, ...]
+ result: VerificationResult
+ _digest: str = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._validate_structure()
+ object.__setattr__(self, "_digest", self._compute_digest())
+ self.validate()
+
+ def _validate_structure(self) -> None:
+ if type(self) is not ProofCertificate:
+ raise CertificateError(
+ "proof certificate must be an exact ProofCertificate"
+ )
+ _require_semver(self.registry_version, label="certificate registry version")
+ _require_semver(self.verifier_version, label="certificate verifier version")
+ if type(self.constraints) is not tuple:
+ raise CertificateError("certificate constraints must be a tuple")
+ if not self.constraints:
+ raise CertificateError("certificate constraint manifest cannot be empty")
+ if len(self.constraints) > MAX_CERTIFICATE_CONSTRAINTS:
+ raise CertificateError("certificate constraint manifest exceeds the limit")
+ constraint_ids: list[str] = []
+ for witness in self.constraints:
+ if type(witness) is not ConstraintWitness:
+ raise CertificateError(
+ "certificate constraints must contain exact witnesses"
+ )
+ try:
+ witness.validate()
+ except UnitSentinelError:
+ raise CertificateError(
+ "certificate contains an invalid constraint witness"
+ ) from None
+ constraint_ids.append(witness.constraint_id)
+ if constraint_ids != sorted(set(constraint_ids)):
+ raise CertificateError("certificate constraints must be sorted and unique")
+ if type(self.result) is not VerificationResult:
+ raise CertificateError(
+ "certificate result must be an exact VerificationResult"
+ )
+ try:
+ self.result.validate()
+ except UnitSentinelError:
+ raise CertificateError(
+ "certificate contains an invalid verification result"
+ ) from None
+ if self.result.status is not VerificationStatus.VERIFIED:
+ raise CertificateError("proof certificates require a verified result")
+ if len(self.result.contracts) > MAX_CERTIFICATE_CONTRACTS:
+ raise CertificateError("certificate contracts exceed the limit")
+ if len(self.result.solver_version) > MAX_CERTIFICATE_SOLVER_VERSION_LENGTH:
+ raise CertificateError("certificate solver version exceeds the limit")
+ if self.result.checks_performed > MAX_CERTIFICATE_CHECKS:
+ raise CertificateError("certificate solver check count exceeds the limit")
+
+ def validate(self) -> None:
+ self._validate_structure()
+ digest = getattr(self, "_digest", None)
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise CertificateError("proof certificate digest is malformed")
+ if not hmac.compare_digest(digest, self._compute_digest()):
+ raise CertificateError(
+ "proof certificate digest does not match its contents"
+ )
+
+ def _canonical_record_unchecked(self) -> dict[str, object]:
+ return {
+ "graph": {
+ "schema": GRAPH_SCHEMA,
+ "sha256": self.result.graph_digest,
+ },
+ "proof": {
+ "constraints": [
+ witness.canonical_record() for witness in self.constraints
+ ],
+ "contracts": [
+ contract.canonical_record() for contract in self.result.contracts
+ ],
+ "outcome": VerificationStatus.VERIFIED.value,
+ "verification_result_sha256": self.result.digest,
+ },
+ "registry": {
+ "schema": REGISTRY_SCHEMA,
+ "sha256": self.result.registry_digest,
+ "version": self.registry_version,
+ },
+ "run": {
+ "checks_performed": self.result.checks_performed,
+ "limits": self.result.limits.canonical_record(),
+ },
+ "schema": CERTIFICATE_SCHEMA,
+ "solver": {
+ "implementation": SOLVER_IMPLEMENTATION,
+ "version": self.result.solver_version,
+ },
+ "verifier": {
+ "implementation": VERIFIER_IMPLEMENTATION,
+ "semantics": VERIFIER_SEMANTICS,
+ "version": self.verifier_version,
+ },
+ }
+
+ def _compute_digest(self) -> str:
+ return sha256_hex(canonical_json_bytes(self._canonical_record_unchecked()))
+
+ @property
+ def digest(self) -> str:
+ self.validate()
+ return self._digest
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return self._canonical_record_unchecked()
+
+ def canonical_bytes(self) -> bytes:
+ self.validate()
+ return canonical_json_bytes(self._canonical_record_unchecked())
+
+
+def _create_certificate_attempt(
+ graph: ComputationGraph,
+ registry: UnitRegistry = BUILTIN_REGISTRY,
+ limits: SolverLimits = _DEFAULT_SOLVER_LIMITS,
+) -> tuple[VerificationResult, ProofCertificate | None]:
+ """Verify exactly once and attach a certificate only to a positive result."""
+
+ if type(graph) is not ComputationGraph:
+ raise CertificateError("certificate graph must be an exact ComputationGraph")
+ if type(registry) is not UnitRegistry:
+ raise CertificateError("certificate registry must be an exact UnitRegistry")
+ if type(limits) is not SolverLimits:
+ raise CertificateError("certificate limits must be an exact SolverLimits")
+ try:
+ graph_digest = graph.digest
+ registry_digest = registry.digest
+ registry_version = registry.version
+ limits_record = limits.canonical_record()
+ expected_ids = tuple(value.value_id for value in graph.values)
+ constraints = constraint_catalog(graph, registry)
+ except UnitSentinelError:
+ raise CertificateError(
+ "certificate source contract is rejected or mutated"
+ ) from None
+
+ try:
+ result = verify_graph(graph, registry=registry, limits=limits)
+ except UnitSentinelError:
+ raise CertificateError("certificate verification failed") from None
+ if type(result) is not VerificationResult:
+ raise CertificateError("certificate verification returned an invalid result")
+ try:
+ graph.validate()
+ registry.validate()
+ limits.validate()
+ result.validate()
+ except UnitSentinelError:
+ raise CertificateError(
+ "certificate source changed during verification"
+ ) from None
+ if (
+ graph.digest != graph_digest
+ or registry.digest != registry_digest
+ or registry.version != registry_version
+ or limits.canonical_record() != limits_record
+ or result.graph_digest != graph_digest
+ or result.registry_digest != registry_digest
+ or result.limits.canonical_record() != limits_record
+ ):
+ raise CertificateError("certificate source changed during verification")
+ if result.status is not VerificationStatus.VERIFIED:
+ return result, None
+ actual_ids = tuple(contract.value_id for contract in result.contracts)
+ if actual_ids != expected_ids:
+ raise CertificateError("verified contracts do not cover every graph value")
+ try:
+ certificate = ProofCertificate(
+ registry_version=registry_version,
+ verifier_version=VERSION,
+ constraints=constraints,
+ result=result,
+ )
+ except UnitSentinelError:
+ raise CertificateError("verified result could not be certified") from None
+ try:
+ graph.validate()
+ registry.validate()
+ limits.validate()
+ certificate.validate()
+ sources_unchanged = (
+ graph.digest == graph_digest
+ and registry.digest == registry_digest
+ and registry.version == registry_version
+ and limits.canonical_record() == limits_record
+ and certificate.result is result
+ and result.graph_digest == graph_digest
+ and result.registry_digest == registry_digest
+ and result.limits.canonical_record() == limits_record
+ )
+ except UnitSentinelError:
+ raise CertificateError(
+ "certificate source changed during certification"
+ ) from None
+ if not sources_unchanged:
+ raise CertificateError("certificate source changed during certification")
+ return result, certificate
+
+
+def create_certificate(
+ graph: ComputationGraph,
+ registry: UnitRegistry = BUILTIN_REGISTRY,
+ limits: SolverLimits = _DEFAULT_SOLVER_LIMITS,
+) -> ProofCertificate:
+ """Run verification and issue a detached certificate only on success."""
+
+ result, certificate = _create_certificate_attempt(graph, registry, limits)
+ if certificate is None:
+ raise CertificateError(
+ f"certificate issuance requires verified, got {result.status.value}"
+ )
+ return certificate
+
+
+def encode_certificate(certificate: ProofCertificate) -> bytes:
+ """Return exact canonical bytes for one currently valid certificate."""
+
+ if type(certificate) is not ProofCertificate:
+ raise CertificateError("certificate encoder requires an exact ProofCertificate")
+ try:
+ return certificate.canonical_bytes()
+ except UnitSentinelError:
+ raise CertificateError("certificate encoding failed") from None
+
+
+def decode_certificate(payload: bytes) -> ProofCertificate:
+ """Decode one well-formed unsigned v1 claim from untrusted JSON bytes.
+
+ Successful decoding establishes structural integrity only. It does not
+ authenticate the issuer or reproduce the certificate's semantic claim.
+ """
+
+ try:
+ parsed = decode_canonical_json(
+ payload,
+ limits=_CERTIFICATE_JSON_LIMITS,
+ label="certificate",
+ )
+ except CanonicalJSONError as error:
+ raise CertificateDecodeError(str(error)) from None
+
+ try:
+ certificate, claimed_result_digest = _decode_certificate_record(parsed)
+ except UnitSentinelError as error:
+ raise CertificateDecodeError(
+ f"certificate semantic contract failed: {error}"
+ ) from None
+ if not hmac.compare_digest(
+ claimed_result_digest,
+ certificate.result.digest,
+ ):
+ raise CertificateDecodeError(
+ "certificate verification-result digest does not match its contents"
+ )
+ if certificate.canonical_bytes() != payload:
+ raise CertificateDecodeError(
+ "certificate payload does not match the canonical certificate model"
+ )
+ return certificate
+
+
+def _decode_certificate_record(
+ value: object,
+) -> tuple[ProofCertificate, str]:
+ root = _expect_object(value, _ROOT_FIELDS, label="certificate document")
+ _expect_literal(root["schema"], CERTIFICATE_SCHEMA, label="certificate schema")
+
+ graph = _expect_object(root["graph"], _GRAPH_FIELDS, label="graph binding")
+ _expect_literal(graph["schema"], GRAPH_SCHEMA, label="graph schema")
+ graph_digest = _expect_sha256(graph["sha256"], label="graph digest")
+
+ registry = _expect_object(
+ root["registry"],
+ _REGISTRY_FIELDS,
+ label="registry binding",
+ )
+ _expect_literal(registry["schema"], REGISTRY_SCHEMA, label="registry schema")
+ registry_digest = _expect_sha256(
+ registry["sha256"],
+ label="registry digest",
+ )
+ registry_version = _expect_text(
+ registry["version"],
+ label="registry version",
+ )
+
+ solver = _expect_object(root["solver"], _SOLVER_FIELDS, label="solver identity")
+ _expect_literal(
+ solver["implementation"],
+ SOLVER_IMPLEMENTATION,
+ label="solver implementation",
+ )
+ solver_version = _expect_text(solver["version"], label="solver version")
+
+ verifier = _expect_object(
+ root["verifier"],
+ _VERIFIER_FIELDS,
+ label="verifier identity",
+ )
+ _expect_literal(
+ verifier["implementation"],
+ VERIFIER_IMPLEMENTATION,
+ label="verifier implementation",
+ )
+ _expect_literal(
+ verifier["semantics"],
+ VERIFIER_SEMANTICS,
+ label="verifier semantics",
+ )
+ verifier_version = _expect_text(
+ verifier["version"],
+ label="verifier version",
+ )
+
+ run = _expect_object(root["run"], _RUN_FIELDS, label="verification run")
+ limits = _decode_limits(run["limits"])
+ checks_performed = _expect_integer(
+ run["checks_performed"],
+ label="solver check count",
+ )
+
+ proof = _expect_object(root["proof"], _PROOF_FIELDS, label="proof claim")
+ _expect_literal(
+ proof["outcome"],
+ VerificationStatus.VERIFIED.value,
+ label="proof outcome",
+ )
+ claimed_result_digest = _expect_sha256(
+ proof["verification_result_sha256"],
+ label="verification-result digest",
+ )
+ constraints = tuple(
+ _decode_constraint(item)
+ for item in _expect_array(
+ proof["constraints"],
+ label="proof constraints",
+ )
+ )
+ contracts = tuple(
+ _decode_contract(item)
+ for item in _expect_array(
+ proof["contracts"],
+ label="proof contracts",
+ )
+ )
+ result = VerificationResult(
+ status=VerificationStatus.VERIFIED,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=checks_performed,
+ contracts=contracts,
+ )
+ return (
+ ProofCertificate(
+ registry_version=registry_version,
+ verifier_version=verifier_version,
+ constraints=constraints,
+ result=result,
+ ),
+ claimed_result_digest,
+ )
+
+
+def _decode_limits(value: object) -> SolverLimits:
+ record = _expect_object(value, _LIMIT_FIELDS, label="solver limits")
+ return SolverLimits(
+ per_check_timeout_ms=_expect_integer(
+ record["per_check_timeout_ms"],
+ label="per-check timeout",
+ ),
+ total_timeout_ms=_expect_integer(
+ record["total_timeout_ms"],
+ label="total timeout",
+ ),
+ max_memory_mb=_expect_integer(
+ record["max_memory_mb"],
+ label="solver memory",
+ ),
+ max_core_shrink_checks=_expect_integer(
+ record["max_core_shrink_checks"],
+ label="core-shrink check limit",
+ ),
+ max_uniqueness_checks=_expect_integer(
+ record["max_uniqueness_checks"],
+ label="uniqueness check limit",
+ ),
+ )
+
+
+def _decode_constraint(value: object) -> ConstraintWitness:
+ record = _expect_object(
+ value,
+ _CONSTRAINT_FIELDS,
+ label="constraint witness",
+ )
+ source_text = _expect_text(
+ record["source"],
+ label="constraint source",
+ )
+ try:
+ source = ConstraintSource(source_text)
+ except ValueError:
+ raise CertificateError("constraint source is not supported") from None
+ return ConstraintWitness(
+ constraint_id=_expect_text(
+ record["constraint_id"],
+ label="constraint identifier",
+ ),
+ source=source,
+ source_id=_expect_text(
+ record["source_id"],
+ label="constraint source identifier",
+ ),
+ rule=_expect_text(record["rule"], label="constraint rule"),
+ )
+
+
+def _decode_contract(value: object) -> InferredContract:
+ record = _expect_object(value, _CONTRACT_FIELDS, label="inferred contract")
+ kind_text = _expect_text(record["kind"], label="inferred quantity kind")
+ try:
+ kind = QuantityKind(kind_text)
+ except ValueError:
+ raise CertificateError("inferred quantity kind is not supported") from None
+ return InferredContract(
+ value_id=_expect_text(
+ record["value_id"],
+ label="inferred value identifier",
+ ),
+ dimension=_decode_dimension(record["dimension"]),
+ kind=kind,
+ scale=_decode_rational(record["scale"], label="inferred scale"),
+ offset=_decode_rational(record["offset"], label="inferred offset"),
+ )
+
+
+def _decode_dimension(value: object) -> Dimension:
+ terms = _expect_array(value, label="inferred dimension")
+ exponents: dict[BaseDimension, Fraction] = {}
+ for item in terms:
+ record = _expect_object(
+ item,
+ _DIMENSION_TERM_FIELDS,
+ label="dimension term",
+ )
+ base_text = _expect_text(record["base"], label="dimension base")
+ try:
+ base = BaseDimension(base_text)
+ except ValueError:
+ raise CertificateError("dimension base is not supported") from None
+ if base in exponents:
+ raise CertificateError("inferred dimension contains a duplicate base")
+ exponents[base] = _decode_rational(
+ record["exponent"],
+ label="dimension exponent",
+ )
+ return Dimension.from_mapping(exponents)
+
+
+def _decode_rational(value: object, *, label: str) -> Fraction:
+ text = _expect_text(value, label=label)
+ if _RATIONAL_TEXT.fullmatch(text) is None:
+ raise CertificateError(f"{label} is not a canonical rational")
+ result = Fraction(text)
+ if _fraction_text(result) != text:
+ raise CertificateError(f"{label} is not reduced")
+ return result
+
+
+def _expect_object(
+ value: object,
+ fields: frozenset[str],
+ *,
+ label: str,
+) -> dict[str, object]:
+ if type(value) is not dict:
+ raise CertificateError(f"{label} must be an object")
+ record = cast(dict[str, object], value)
+ if set(record) != fields:
+ raise CertificateError(f"{label} has missing or unknown fields")
+ return record
+
+
+def _expect_array(value: object, *, label: str) -> list[object]:
+ if type(value) is not list:
+ raise CertificateError(f"{label} must be an array")
+ return cast(list[object], value)
+
+
+def _expect_text(value: object, *, label: str) -> str:
+ if type(value) is not str:
+ raise CertificateError(f"{label} must be text")
+ return value
+
+
+def _expect_integer(value: object, *, label: str) -> int:
+ if type(value) is not int:
+ raise CertificateError(f"{label} must be an exact integer")
+ return value
+
+
+def _expect_sha256(value: object, *, label: str) -> str:
+ digest = _expect_text(value, label=label)
+ if SHA256_HEX.fullmatch(digest) is None:
+ raise CertificateError(f"{label} is malformed")
+ return digest
+
+
+def _expect_literal(value: object, expected: str, *, label: str) -> None:
+ text = _expect_text(value, label=label)
+ if text != expected:
+ raise CertificateError(f"{label} is not supported")
diff --git a/src/unitsentinel/cli.py b/src/unitsentinel/cli.py
new file mode 100644
index 0000000..f8ef9c8
--- /dev/null
+++ b/src/unitsentinel/cli.py
@@ -0,0 +1,683 @@
+"""Deterministic command-line verification and certificate replay."""
+
+from __future__ import annotations
+
+import argparse
+import errno
+import hmac
+import os
+import secrets
+import stat
+import sys
+from collections.abc import Sequence
+from contextlib import suppress
+from fractions import Fraction
+from typing import Final, NoReturn, cast
+
+from .canonical import canonical_json_bytes
+from .certificate import (
+ CERTIFICATE_SCHEMA,
+ MAX_CERTIFICATE_BYTES,
+ CertificateDecodeError,
+ CertificateError,
+ ProofCertificate,
+ _create_certificate_attempt,
+ decode_certificate,
+ encode_certificate,
+)
+from .domain import UnitSentinelError
+from .graph import GRAPH_SCHEMA, ComputationGraph
+from .graph_codec import MAX_GRAPH_BYTES, GraphDecodeError, decode_graph
+from .registry import BUILTIN_REGISTRY, REGISTRY_SCHEMA, SHA256_HEX
+from .replay import (
+ CertificateReplay,
+ CertificateReplayError,
+ ReplayStatus,
+ replay_certificate,
+)
+from .verification import VerificationResult, VerificationStatus
+from .version import VERSION
+
+VERIFY_OUTPUT_SCHEMA: Final = "unitsentinel.cli.verify/v1"
+REPLAY_OUTPUT_SCHEMA: Final = "unitsentinel.cli.replay/v1"
+
+EXIT_SUCCESS: Final = 0
+EXIT_CONFLICT: Final = 1
+EXIT_UNDERCONSTRAINED: Final = 2
+EXIT_INDETERMINATE: Final = 3
+EXIT_INPUT: Final = 4
+EXIT_MISMATCH: Final = 5
+EXIT_USAGE: Final = 64
+EXIT_INTERNAL: Final = 70
+EXIT_INTERRUPTED: Final = 130
+
+_READ_CHUNK_BYTES: Final = 65_536
+_TEMP_ATTEMPTS: Final = 32
+
+
+class _CLIError(Exception):
+ """One bounded user-facing CLI failure."""
+
+ def __init__(self, exit_code: int, message: str) -> None:
+ super().__init__(message)
+ self.exit_code = exit_code
+ self.message = message
+
+
+class _ArgumentParser(argparse.ArgumentParser):
+ def error(self, message: str) -> NoReturn:
+ del message
+ raise _CLIError(
+ EXIT_USAGE,
+ "invalid command-line arguments; use --help",
+ )
+
+
+def _sha256_argument(value: str) -> str:
+ if SHA256_HEX.fullmatch(value) is None:
+ raise argparse.ArgumentTypeError("expected a lowercase SHA-256 digest")
+ return value
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = _ArgumentParser(
+ prog="unitsentinel",
+ description=(
+ "Verify exact dimensional contracts and replay detached proof claims."
+ ),
+ )
+ parser.add_argument(
+ "--version",
+ action="version",
+ version=f"%(prog)s {VERSION}",
+ )
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ verify = commands.add_parser(
+ "verify",
+ help="verify one canonical computation graph",
+ )
+ verify.add_argument("graph", help="canonical graph JSON file")
+ verify.add_argument(
+ "--certificate",
+ dest="certificate_output",
+ metavar="FILE",
+ help="atomically write a new positive certificate",
+ )
+ verify.add_argument(
+ "--json",
+ action="store_true",
+ help="emit one canonical machine-readable record",
+ )
+
+ replay = commands.add_parser(
+ "replay",
+ help="replay one certificate against its graph",
+ )
+ replay.add_argument("certificate", help="canonical certificate JSON file")
+ replay.add_argument(
+ "--graph",
+ required=True,
+ help="canonical graph JSON file",
+ )
+ replay.add_argument(
+ "--expect-sha256",
+ type=_sha256_argument,
+ metavar="DIGEST",
+ help="reject the certificate before replay unless its digest matches",
+ )
+ replay.add_argument(
+ "--strict-toolchain",
+ action="store_true",
+ help="require current verifier and solver versions to match the claim",
+ )
+ replay.add_argument(
+ "--json",
+ action="store_true",
+ help="emit one canonical machine-readable record",
+ )
+ return parser
+
+
+def _read_bounded_file(path: str, *, label: str, max_bytes: int) -> bytes:
+ flags = (
+ os.O_RDONLY
+ | getattr(os, "O_CLOEXEC", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ | getattr(os, "O_NONBLOCK", 0)
+ )
+ try:
+ descriptor = os.open(path, flags)
+ except OSError:
+ raise _CLIError(EXIT_INPUT, f"{label} could not be opened") from None
+
+ try:
+ try:
+ metadata = os.fstat(descriptor)
+ if not stat.S_ISREG(metadata.st_mode):
+ raise _CLIError(
+ EXIT_INPUT,
+ f"{label} must be a regular file",
+ )
+ if metadata.st_size > max_bytes:
+ raise _CLIError(
+ EXIT_INPUT,
+ f"{label} exceeds the byte limit",
+ )
+
+ chunks: list[bytes] = []
+ total = 0
+ while total <= max_bytes:
+ chunk = os.read(
+ descriptor,
+ min(_READ_CHUNK_BYTES, max_bytes + 1 - total),
+ )
+ if not chunk:
+ break
+ chunks.append(chunk)
+ total += len(chunk)
+ if total > max_bytes:
+ raise _CLIError(
+ EXIT_INPUT,
+ f"{label} exceeds the byte limit",
+ )
+ return b"".join(chunks)
+ except _CLIError:
+ raise
+ except OSError:
+ raise _CLIError(EXIT_INPUT, f"{label} could not be read") from None
+ finally:
+ with suppress(OSError):
+ os.close(descriptor)
+
+
+def _write_all(descriptor: int, payload: bytes) -> None:
+ view = memoryview(payload)
+ written = 0
+ while written < len(view):
+ count = os.write(descriptor, view[written:])
+ if count <= 0:
+ raise OSError(errno.EIO, "short write")
+ written += count
+
+
+def _open_output_directory(path: str) -> tuple[int, str]:
+ parent, name = os.path.split(path)
+ if not name or name in {".", ".."}:
+ raise _CLIError(EXIT_INPUT, "certificate output path is invalid")
+ directory = parent or "."
+ flags = (
+ os.O_RDONLY
+ | getattr(os, "O_CLOEXEC", 0)
+ | getattr(os, "O_DIRECTORY", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ )
+ try:
+ descriptor = os.open(directory, flags)
+ except OSError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output directory could not be opened",
+ ) from None
+ return descriptor, name
+
+
+def _create_output_temp(directory: int) -> tuple[int, str]:
+ flags = (
+ os.O_WRONLY
+ | os.O_CREAT
+ | os.O_EXCL
+ | getattr(os, "O_CLOEXEC", 0)
+ | getattr(os, "O_NOFOLLOW", 0)
+ )
+ for _ in range(_TEMP_ATTEMPTS):
+ name = f".unitsentinel-{secrets.token_hex(12)}.tmp"
+ try:
+ return os.open(name, flags, 0o600, dir_fd=directory), name
+ except FileExistsError:
+ continue
+ except OSError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output could not be created",
+ ) from None
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output could not be created",
+ )
+
+
+def _unlink_temp(directory: int, name: str | None) -> bool:
+ if name is None:
+ return True
+ try:
+ os.unlink(name, dir_fd=directory)
+ except OSError:
+ return False
+ return True
+
+
+def _atomic_write_new(path: str, payload: bytes) -> None:
+ directory, target = _open_output_directory(path)
+ temporary: str | None = None
+ descriptor = -1
+ try:
+ descriptor, temporary = _create_output_temp(directory)
+ try:
+ os.fchmod(descriptor, 0o600)
+ _write_all(descriptor, payload)
+ os.fsync(descriptor)
+ except OSError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output could not be written",
+ ) from None
+ finally:
+ with suppress(OSError):
+ os.close(descriptor)
+ descriptor = -1
+
+ try:
+ os.link(
+ temporary,
+ target,
+ src_dir_fd=directory,
+ dst_dir_fd=directory,
+ follow_symlinks=False,
+ )
+ except FileExistsError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output already exists",
+ ) from None
+ except OSError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output could not be published",
+ ) from None
+
+ if not _unlink_temp(directory, temporary):
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output cleanup could not be confirmed",
+ )
+ temporary = None
+ try:
+ os.fsync(directory)
+ except OSError:
+ raise _CLIError(
+ EXIT_INPUT,
+ "certificate output durability could not be confirmed",
+ ) from None
+ finally:
+ if descriptor >= 0:
+ with suppress(OSError):
+ os.close(descriptor)
+ _unlink_temp(directory, temporary)
+ with suppress(OSError):
+ os.close(directory)
+
+
+def _decode_graph_file(path: str) -> ComputationGraph:
+ payload = _read_bounded_file(
+ path,
+ label="graph input",
+ max_bytes=MAX_GRAPH_BYTES,
+ )
+ try:
+ return decode_graph(payload)
+ except GraphDecodeError as error:
+ raise _CLIError(
+ EXIT_INPUT,
+ f"graph input is invalid: {error}",
+ ) from None
+
+
+def _decode_certificate_file(path: str) -> ProofCertificate:
+ payload = _read_bounded_file(
+ path,
+ label="certificate input",
+ max_bytes=MAX_CERTIFICATE_BYTES,
+ )
+ try:
+ return decode_certificate(payload)
+ except CertificateDecodeError as error:
+ raise _CLIError(
+ EXIT_INPUT,
+ f"certificate input is invalid: {error}",
+ ) from None
+
+
+def _fraction_text(value: Fraction) -> str:
+ if value.denominator == 1:
+ return str(value.numerator)
+ return f"{value.numerator}/{value.denominator}"
+
+
+def _dimension_text(pairs: tuple[tuple[str, str], ...]) -> str:
+ if not pairs:
+ return "dimensionless"
+ return " ".join(f"{base}^{exponent}" for base, exponent in pairs)
+
+
+def _verification_text(
+ graph: ComputationGraph,
+ result: VerificationResult,
+ certificate: ProofCertificate | None,
+ *,
+ certificate_requested: bool,
+ certificate_written: bool,
+ exit_code: int,
+) -> str:
+ lines = [
+ f"UnitSentinel verification: {result.status.value.upper()}",
+ f"exit code: {exit_code}",
+ f"tool: unitsentinel {VERSION}",
+ f"graph id: {graph.graph_id}",
+ f"graph sha256: {result.graph_digest}",
+ f"result sha256: {result.digest}",
+ f"registry version: {BUILTIN_REGISTRY.version}",
+ f"registry sha256: {result.registry_digest}",
+ f"solver: z3 {result.solver_version} ({result.checks_performed} checks)",
+ ]
+ if result.status is VerificationStatus.VERIFIED:
+ lines.append(f"contracts ({len(result.contracts)}):")
+ for contract in result.contracts:
+ lines.append(
+ " "
+ f"{contract.value_id} | "
+ f"{_dimension_text(contract.dimension.canonical_pairs())} | "
+ f"{contract.kind.value} | "
+ f"scale={_fraction_text(contract.scale)} "
+ f"offset={_fraction_text(contract.offset)}"
+ )
+ elif result.status is VerificationStatus.UNDERCONSTRAINED:
+ lines.append(
+ "underconstrained values: " + ", ".join(result.underconstrained_values)
+ )
+ elif result.status is VerificationStatus.CONFLICT:
+ minimal = "yes" if result.core_minimal else "no"
+ lines.append(f"conflict core ({len(result.conflict_core)}, minimal={minimal}):")
+ for witness in result.conflict_core:
+ lines.append(
+ " "
+ f"{witness.constraint_id} | "
+ f"{witness.source.value}:{witness.source_id} | "
+ f"{witness.rule}"
+ )
+ else:
+ reason = result.unknown_reason
+ lines.append(
+ "unknown reason: " + ("unknown" if reason is None else reason.value)
+ )
+ if certificate is not None:
+ lines.append(f"certificate sha256: {certificate.digest}")
+ lines.append("certificate authentication: not-provided")
+ else:
+ lines.append("certificate: not-issued")
+ if certificate_written:
+ certificate_output = "written"
+ elif certificate_requested and certificate is None:
+ certificate_output = "not-issued"
+ else:
+ certificate_output = "not-requested"
+ lines.append(f"certificate output: {certificate_output}")
+ return "\n".join(lines) + "\n"
+
+
+def _verification_json(
+ graph: ComputationGraph,
+ result: VerificationResult,
+ certificate: ProofCertificate | None,
+ *,
+ certificate_requested: bool,
+ certificate_written: bool,
+ exit_code: int,
+) -> str:
+ certificate_record: dict[str, str] | None = None
+ if certificate is not None:
+ certificate_record = {
+ "authentication": "not-provided",
+ "schema": CERTIFICATE_SCHEMA,
+ "sha256": certificate.digest,
+ }
+ if certificate_written:
+ certificate_output = "written"
+ elif certificate_requested and certificate is None:
+ certificate_output = "not-issued"
+ else:
+ certificate_output = "not-requested"
+ record = {
+ "certificate": certificate_record,
+ "certificate_output": certificate_output,
+ "exit_code": exit_code,
+ "graph": {
+ "graph_id": graph.graph_id,
+ "schema": GRAPH_SCHEMA,
+ "sha256": graph.digest,
+ },
+ "registry": {
+ "schema": REGISTRY_SCHEMA,
+ "sha256": result.registry_digest,
+ "version": BUILTIN_REGISTRY.version,
+ },
+ "result": {
+ "record": result.canonical_record(),
+ "sha256": result.digest,
+ },
+ "schema": VERIFY_OUTPUT_SCHEMA,
+ "tool": {"name": "unitsentinel", "version": VERSION},
+ }
+ return canonical_json_bytes(record).decode("utf-8") + "\n"
+
+
+def _replay_text(
+ graph: ComputationGraph,
+ report: CertificateReplay,
+ *,
+ exit_code: int,
+) -> str:
+ reason = "none" if report.reason is None else report.reason.value
+ lines = [
+ f"UnitSentinel replay: {report.status.value.upper()}",
+ f"exit code: {exit_code}",
+ f"tool: unitsentinel {VERSION}",
+ f"reason: {reason}",
+ f"certificate sha256: {report.certificate_digest}",
+ "certificate authentication: not-provided",
+ f"graph id: {graph.graph_id}",
+ f"graph sha256: {report.graph_digest}",
+ f"registry version: {report.registry_version}",
+ f"registry sha256: {report.registry_digest}",
+ f"replay sha256: {report.digest}",
+ (
+ "toolchain match: "
+ + ("yes" if report.toolchain_match else "no")
+ + f" (strict={'yes' if report.strict_toolchain else 'no'})"
+ ),
+ (
+ "certificate toolchain: "
+ f"unitsentinel {report.certificate_verifier_version}, "
+ f"z3 {report.certificate_solver_version}"
+ ),
+ (
+ "current toolchain: "
+ f"unitsentinel {report.current_verifier_version}, "
+ f"z3 {report.current_solver_version}"
+ ),
+ ]
+ if report.fresh_result is not None:
+ lines.append(
+ "fresh result: "
+ f"{report.fresh_result.status.value} "
+ f"{report.fresh_result.digest}"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def _replay_json(
+ certificate: ProofCertificate,
+ graph: ComputationGraph,
+ report: CertificateReplay,
+ *,
+ exit_code: int,
+) -> str:
+ record = {
+ "certificate": {
+ "authentication": "not-provided",
+ "schema": CERTIFICATE_SCHEMA,
+ "sha256": certificate.digest,
+ },
+ "exit_code": exit_code,
+ "graph": {
+ "graph_id": graph.graph_id,
+ "schema": GRAPH_SCHEMA,
+ "sha256": graph.digest,
+ },
+ "report": {
+ "record": report.canonical_record(),
+ "sha256": report.digest,
+ },
+ "schema": REPLAY_OUTPUT_SCHEMA,
+ "tool": {"name": "unitsentinel", "version": VERSION},
+ }
+ return canonical_json_bytes(record).decode("utf-8") + "\n"
+
+
+def _verification_exit(status: VerificationStatus) -> int:
+ return {
+ VerificationStatus.VERIFIED: EXIT_SUCCESS,
+ VerificationStatus.CONFLICT: EXIT_CONFLICT,
+ VerificationStatus.UNDERCONSTRAINED: EXIT_UNDERCONSTRAINED,
+ VerificationStatus.UNKNOWN: EXIT_INDETERMINATE,
+ }[status]
+
+
+def _replay_exit(status: ReplayStatus) -> int:
+ return {
+ ReplayStatus.REPRODUCED: EXIT_SUCCESS,
+ ReplayStatus.MISMATCH: EXIT_MISMATCH,
+ ReplayStatus.INDETERMINATE: EXIT_INDETERMINATE,
+ }[status]
+
+
+def _run_verify(arguments: argparse.Namespace) -> int:
+ graph_path = cast(str, arguments.graph)
+ graph = _decode_graph_file(graph_path)
+ try:
+ result, certificate = _create_certificate_attempt(graph)
+ except CertificateError:
+ raise _CLIError(
+ EXIT_INTERNAL,
+ "verification could not be completed safely",
+ ) from None
+
+ output_path = cast(str | None, arguments.certificate_output)
+ certificate_requested = output_path is not None
+ certificate_written = False
+ if output_path is not None and certificate is not None:
+ _atomic_write_new(output_path, encode_certificate(certificate))
+ certificate_written = True
+
+ machine_readable = cast(bool, arguments.json)
+ exit_code = _verification_exit(result.status)
+ if machine_readable:
+ sys.stdout.write(
+ _verification_json(
+ graph,
+ result,
+ certificate,
+ certificate_requested=certificate_requested,
+ certificate_written=certificate_written,
+ exit_code=exit_code,
+ )
+ )
+ else:
+ sys.stdout.write(
+ _verification_text(
+ graph,
+ result,
+ certificate,
+ certificate_requested=certificate_requested,
+ certificate_written=certificate_written,
+ exit_code=exit_code,
+ )
+ )
+ return exit_code
+
+
+def _run_replay(arguments: argparse.Namespace) -> int:
+ certificate_path = cast(str, arguments.certificate)
+ certificate = _decode_certificate_file(certificate_path)
+ expected_digest = cast(str | None, arguments.expect_sha256)
+ if expected_digest is not None and not hmac.compare_digest(
+ certificate.digest,
+ expected_digest,
+ ):
+ raise _CLIError(
+ EXIT_MISMATCH,
+ "certificate sha256 does not match the expected digest",
+ )
+
+ graph_path = cast(str, arguments.graph)
+ graph = _decode_graph_file(graph_path)
+ strict_toolchain = cast(bool, arguments.strict_toolchain)
+ try:
+ report = replay_certificate(
+ certificate,
+ graph,
+ strict_toolchain=strict_toolchain,
+ )
+ except CertificateReplayError:
+ raise _CLIError(
+ EXIT_INTERNAL,
+ "certificate replay could not be completed safely",
+ ) from None
+
+ machine_readable = cast(bool, arguments.json)
+ exit_code = _replay_exit(report.status)
+ if machine_readable:
+ sys.stdout.write(
+ _replay_json(
+ certificate,
+ graph,
+ report,
+ exit_code=exit_code,
+ )
+ )
+ else:
+ sys.stdout.write(_replay_text(graph, report, exit_code=exit_code))
+ return exit_code
+
+
+def _dispatch(arguments: argparse.Namespace) -> int:
+ command = cast(str, arguments.command)
+ if command == "verify":
+ return _run_verify(arguments)
+ if command == "replay":
+ return _run_replay(arguments)
+ raise _CLIError(EXIT_USAGE, "command is required; use --help")
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Run the CLI and return its stable process exit code."""
+
+ try:
+ arguments = _parser().parse_args(argv)
+ return _dispatch(arguments)
+ except _CLIError as error:
+ sys.stderr.write(f"unitsentinel: error: {error.message}\n")
+ return error.exit_code
+ except KeyboardInterrupt:
+ sys.stderr.write("unitsentinel: interrupted\n")
+ return EXIT_INTERRUPTED
+ except UnitSentinelError:
+ sys.stderr.write("unitsentinel: error: internal contract failure\n")
+ return EXIT_INTERNAL
+ except Exception:
+ sys.stderr.write("unitsentinel: error: internal failure\n")
+ return EXIT_INTERNAL
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/unitsentinel/domain.py b/src/unitsentinel/domain.py
new file mode 100644
index 0000000..10a4224
--- /dev/null
+++ b/src/unitsentinel/domain.py
@@ -0,0 +1,367 @@
+"""Exact dimensional and unit value objects.
+
+The module deliberately accepts ``Fraction`` rather than floats. Unit contracts
+are metadata, so preserving an exact scale is more useful than accepting every
+numeric convenience type.
+"""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from collections.abc import Mapping
+from dataclasses import dataclass
+from enum import StrEnum
+from fractions import Fraction
+from typing import Final
+
+BASE_DIMENSION_COUNT: Final = 7
+MAX_EXPONENT_NUMERATOR: Final = 64
+MAX_EXPONENT_DENOMINATOR: Final = 12
+MAX_RATIONAL_BITS: Final = 256
+MAX_UNIT_ID_LENGTH: Final = 64
+MAX_UNIT_SYMBOL_LENGTH: Final = 24
+UNIT_ID: Final = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
+
+
+class UnitSentinelError(ValueError):
+ """Base class for stable, user-facing domain errors."""
+
+
+class DimensionError(UnitSentinelError):
+ """Raised when a dimension is malformed or exceeds declared bounds."""
+
+
+class UnitDefinitionError(UnitSentinelError):
+ """Raised when a concrete unit definition is invalid."""
+
+
+class ConversionError(UnitSentinelError):
+ """Raised when an exact conversion is not semantically valid."""
+
+
+class BaseDimension(StrEnum):
+ """The seven SI base dimensions in certificate order."""
+
+ LENGTH = "length"
+ MASS = "mass"
+ TIME = "time"
+ ELECTRIC_CURRENT = "electric-current"
+ THERMODYNAMIC_TEMPERATURE = "thermodynamic-temperature"
+ AMOUNT_OF_SUBSTANCE = "amount-of-substance"
+ LUMINOUS_INTENSITY = "luminous-intensity"
+
+
+BASE_DIMENSIONS: Final = tuple(BaseDimension)
+
+
+class QuantityKind(StrEnum):
+ """Semantics that cannot be recovered from a dimension vector alone."""
+
+ LINEAR = "linear"
+ ABSOLUTE_TEMPERATURE = "absolute-temperature"
+ TEMPERATURE_DELTA = "temperature-delta"
+
+
+def _require_exact_fraction(
+ value: object,
+ *,
+ label: str,
+ error_type: type[UnitSentinelError],
+) -> Fraction:
+ if type(value) is not Fraction:
+ raise error_type(f"{label} must be an exact Fraction")
+ if (
+ abs(value.numerator).bit_length() > MAX_RATIONAL_BITS
+ or value.denominator.bit_length() > MAX_RATIONAL_BITS
+ ):
+ raise error_type(f"{label} exceeds the rational size limit")
+ return value
+
+
+def _validate_exponent(value: object) -> Fraction:
+ exponent = _require_exact_fraction(
+ value,
+ label="dimension exponent",
+ error_type=DimensionError,
+ )
+ if (
+ abs(exponent.numerator) > MAX_EXPONENT_NUMERATOR
+ or exponent.denominator > MAX_EXPONENT_DENOMINATOR
+ ):
+ raise DimensionError("dimension exponent exceeds the declared bounds")
+ return exponent
+
+
+@dataclass(frozen=True, slots=True)
+class Dimension:
+ """An immutable exact vector over the seven SI base dimensions."""
+
+ exponents: tuple[Fraction, ...]
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not Dimension:
+ raise DimensionError("dimension must be an exact Dimension")
+ if type(self.exponents) is not tuple:
+ raise DimensionError("dimension exponents must be a tuple")
+ if len(self.exponents) != BASE_DIMENSION_COUNT:
+ raise DimensionError("dimension must contain seven exponents")
+ for exponent in self.exponents:
+ _validate_exponent(exponent)
+
+ @classmethod
+ def dimensionless(cls) -> Dimension:
+ return cls((Fraction(0),) * BASE_DIMENSION_COUNT)
+
+ @classmethod
+ def base(cls, base: BaseDimension) -> Dimension:
+ if type(base) is not BaseDimension:
+ raise DimensionError("base dimension must be a known SI dimension")
+ values = [Fraction(0)] * BASE_DIMENSION_COUNT
+ values[BASE_DIMENSIONS.index(base)] = Fraction(1)
+ return cls(tuple(values))
+
+ @classmethod
+ def from_mapping(
+ cls,
+ exponents: Mapping[BaseDimension, Fraction],
+ ) -> Dimension:
+ if not isinstance(exponents, Mapping):
+ raise DimensionError("dimension mapping must implement Mapping")
+ if len(exponents) > BASE_DIMENSION_COUNT:
+ raise DimensionError("dimension mapping contains too many entries")
+ if any(type(key) is not BaseDimension for key in exponents):
+ raise DimensionError("dimension mapping contains an unknown base")
+ return cls(
+ tuple(
+ _validate_exponent(exponents.get(base, Fraction(0)))
+ for base in BASE_DIMENSIONS
+ )
+ )
+
+ def multiply(self, other: Dimension) -> Dimension:
+ self.validate()
+ _require_dimension(other)
+ return Dimension(
+ tuple(
+ left + right
+ for left, right in zip(
+ self.exponents,
+ other.exponents,
+ strict=True,
+ )
+ )
+ )
+
+ def divide(self, other: Dimension) -> Dimension:
+ self.validate()
+ _require_dimension(other)
+ return Dimension(
+ tuple(
+ left - right
+ for left, right in zip(
+ self.exponents,
+ other.exponents,
+ strict=True,
+ )
+ )
+ )
+
+ def power(self, exponent: Fraction) -> Dimension:
+ self.validate()
+ factor = _validate_exponent(exponent)
+ return Dimension(tuple(value * factor for value in self.exponents))
+
+ @property
+ def is_dimensionless(self) -> bool:
+ self.validate()
+ return all(exponent == 0 for exponent in self.exponents)
+
+ def canonical_pairs(self) -> tuple[tuple[str, str], ...]:
+ """Return the stable non-zero representation used by codecs."""
+
+ self.validate()
+ return tuple(
+ (base.value, _fraction_text(exponent))
+ for base, exponent in zip(
+ BASE_DIMENSIONS,
+ self.exponents,
+ strict=True,
+ )
+ if exponent
+ )
+
+
+def _require_dimension(value: object) -> Dimension:
+ if type(value) is not Dimension:
+ raise DimensionError("operand must be an exact Dimension")
+ value.validate()
+ return value
+
+
+def _fraction_text(value: Fraction) -> str:
+ return (
+ str(value.numerator)
+ if value.denominator == 1
+ else f"{value.numerator}/{value.denominator}"
+ )
+
+
+def _validate_display_symbol(symbol: object) -> str:
+ if type(symbol) is not str:
+ raise UnitDefinitionError("unit symbol must be text")
+ if not symbol or symbol != symbol.strip() or len(symbol) > MAX_UNIT_SYMBOL_LENGTH:
+ raise UnitDefinitionError("unit symbol has an invalid length or padding")
+ if any(character.isspace() for character in symbol):
+ raise UnitDefinitionError("unit symbol contains whitespace")
+ normalized = unicodedata.normalize("NFC", symbol)
+ if normalized != symbol:
+ raise UnitDefinitionError("unit symbol must use canonical Unicode")
+ if any(unicodedata.category(character).startswith("C") for character in symbol):
+ raise UnitDefinitionError("unit symbol contains a control character")
+ return symbol
+
+
+@dataclass(frozen=True, slots=True)
+class Unit:
+ """A concrete exact transform to one dimension's reference unit."""
+
+ unit_id: str
+ symbol: str
+ dimension: Dimension
+ scale: Fraction
+ offset: Fraction = Fraction(0)
+ kind: QuantityKind = QuantityKind.LINEAR
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not Unit:
+ raise UnitDefinitionError("unit must be an exact Unit")
+ if (
+ type(self.unit_id) is not str
+ or len(self.unit_id) > MAX_UNIT_ID_LENGTH
+ or UNIT_ID.fullmatch(self.unit_id) is None
+ ):
+ raise UnitDefinitionError("unit identifier is not canonical")
+ _validate_display_symbol(self.symbol)
+ dimension = _require_dimension(self.dimension)
+ scale = _require_exact_fraction(
+ self.scale,
+ label="unit scale",
+ error_type=UnitDefinitionError,
+ )
+ offset = _require_exact_fraction(
+ self.offset,
+ label="unit offset",
+ error_type=UnitDefinitionError,
+ )
+ if scale <= 0:
+ raise UnitDefinitionError("unit scale must be positive")
+ if type(self.kind) is not QuantityKind:
+ raise UnitDefinitionError("unit kind is unknown")
+ if self.kind is QuantityKind.LINEAR:
+ if dimension == THERMODYNAMIC_TEMPERATURE:
+ raise UnitDefinitionError(
+ "temperature units require an explicit absolute or delta kind"
+ )
+ if offset:
+ raise UnitDefinitionError("linear units cannot have an offset")
+ return
+ if dimension != THERMODYNAMIC_TEMPERATURE:
+ raise UnitDefinitionError("temperature kinds require temperature dimension")
+ if self.kind is QuantityKind.TEMPERATURE_DELTA and offset:
+ raise UnitDefinitionError("temperature deltas cannot have an offset")
+
+ def convert_value_to(self, value: Fraction, target: Unit) -> Fraction:
+ """Convert one exact value while preserving quantity-kind semantics."""
+
+ self.validate()
+ if type(target) is not Unit:
+ raise ConversionError("conversion target must be an exact Unit")
+ target.validate()
+ magnitude = _require_exact_fraction(
+ value,
+ label="conversion value",
+ error_type=ConversionError,
+ )
+ if self.dimension != target.dimension:
+ raise ConversionError("units have incompatible dimensions")
+ if self.kind is not target.kind:
+ raise ConversionError("units have incompatible quantity kinds")
+
+ reference = magnitude * self.scale
+ if self.kind is QuantityKind.ABSOLUTE_TEMPERATURE:
+ reference += self.offset
+ converted = (reference - target.offset) / target.scale
+ else:
+ converted = reference / target.scale
+ return _require_exact_fraction(
+ converted,
+ label="converted value",
+ error_type=ConversionError,
+ )
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return {
+ "dimension": [
+ {"base": base, "exponent": exponent}
+ for base, exponent in self.dimension.canonical_pairs()
+ ],
+ "kind": self.kind.value,
+ "offset": _fraction_text(self.offset),
+ "scale": _fraction_text(self.scale),
+ "symbol": self.symbol,
+ "unit_id": self.unit_id,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class Quantity:
+ """An exact magnitude paired with one validated concrete unit."""
+
+ magnitude: Fraction
+ unit: Unit
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not Quantity:
+ raise UnitDefinitionError("quantity must be an exact Quantity")
+ _require_exact_fraction(
+ self.magnitude,
+ label="quantity magnitude",
+ error_type=UnitDefinitionError,
+ )
+ if type(self.unit) is not Unit:
+ raise UnitDefinitionError("quantity unit must be an exact Unit")
+ self.unit.validate()
+
+ def to(self, target: Unit) -> Quantity:
+ self.validate()
+ return Quantity(self.unit.convert_value_to(self.magnitude, target), target)
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return {
+ "magnitude": _fraction_text(self.magnitude),
+ "unit_id": self.unit.unit_id,
+ }
+
+
+DIMENSIONLESS: Final = Dimension.dimensionless()
+LENGTH: Final = Dimension.base(BaseDimension.LENGTH)
+MASS: Final = Dimension.base(BaseDimension.MASS)
+TIME: Final = Dimension.base(BaseDimension.TIME)
+ELECTRIC_CURRENT: Final = Dimension.base(BaseDimension.ELECTRIC_CURRENT)
+THERMODYNAMIC_TEMPERATURE: Final = Dimension.base(
+ BaseDimension.THERMODYNAMIC_TEMPERATURE
+)
+AMOUNT_OF_SUBSTANCE: Final = Dimension.base(BaseDimension.AMOUNT_OF_SUBSTANCE)
+LUMINOUS_INTENSITY: Final = Dimension.base(BaseDimension.LUMINOUS_INTENSITY)
diff --git a/src/unitsentinel/graph.py b/src/unitsentinel/graph.py
new file mode 100644
index 0000000..c07cb61
--- /dev/null
+++ b/src/unitsentinel/graph.py
@@ -0,0 +1,413 @@
+"""Bounded immutable computation-graph contracts.
+
+The graph layer validates structure and provenance identifiers. Dimensional
+rules are compiled by the verifier in a later layer; accepting a graph here is
+not a claim that its operations are dimensionally consistent.
+"""
+
+from __future__ import annotations
+
+import hmac
+import re
+from dataclasses import dataclass, field
+from enum import StrEnum
+from fractions import Fraction
+from typing import Final, TypeAlias
+
+from .canonical import canonical_json_bytes, sha256_hex
+from .domain import (
+ MAX_UNIT_ID_LENGTH,
+ UNIT_ID,
+ UnitSentinelError,
+ _fraction_text,
+ _validate_exponent,
+)
+from .registry import SHA256_HEX, UnitRegistry
+
+GRAPH_SCHEMA: Final = "unitsentinel.graph/v1"
+MAX_GRAPH_ID_LENGTH: Final = 64
+MAX_GRAPH_INPUTS: Final = 64
+MAX_GRAPH_NODES: Final = 512
+MAX_GRAPH_OUTPUTS: Final = 64
+MAX_GRAPH_VALUES: Final = MAX_GRAPH_INPUTS + MAX_GRAPH_NODES
+MAX_TENSOR_RANK: Final = 8
+MAX_AXIS_SIZE: Final = 2_147_483_647
+MAX_AXIS_SYMBOL_LENGTH: Final = 32
+AXIS_SYMBOL: Final = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$")
+
+ShapeAxis: TypeAlias = int | str
+
+
+class GraphError(UnitSentinelError):
+ """Base class for stable graph-contract failures."""
+
+
+class GraphValidationError(GraphError):
+ """Raised when an immutable graph value violates the v1 contract."""
+
+
+class ScalarType(StrEnum):
+ """Closed scalar types accepted by the dimensional graph core."""
+
+ FLOAT16 = "float16"
+ BFLOAT16 = "bfloat16"
+ FLOAT32 = "float32"
+ FLOAT64 = "float64"
+
+
+class Operation(StrEnum):
+ """Closed v1 operation set with independently specified dimensional rules."""
+
+ ADD = "add"
+ CONVERT = "convert"
+ DIVIDE = "divide"
+ EXP = "exp"
+ IDENTITY = "identity"
+ LOG = "log"
+ MATMUL = "matmul"
+ MAXIMUM = "maximum"
+ MINIMUM = "minimum"
+ MULTIPLY = "multiply"
+ POWER = "power"
+ SIGMOID = "sigmoid"
+ SOFTMAX = "softmax"
+ SUBTRACT = "subtract"
+
+
+UNARY_OPERATIONS: Final = frozenset(
+ {
+ Operation.CONVERT,
+ Operation.EXP,
+ Operation.IDENTITY,
+ Operation.LOG,
+ Operation.POWER,
+ Operation.SIGMOID,
+ Operation.SOFTMAX,
+ }
+)
+BINARY_OPERATIONS: Final = frozenset(Operation) - UNARY_OPERATIONS
+
+
+def _require_identifier(
+ value: object,
+ *,
+ label: str,
+ max_length: int = MAX_UNIT_ID_LENGTH,
+) -> str:
+ if (
+ type(value) is not str
+ or len(value) > max_length
+ or UNIT_ID.fullmatch(value) is None
+ ):
+ raise GraphValidationError(f"{label} is not canonical")
+ return value
+
+
+def _validate_shape_axis(axis: object) -> None:
+ if type(axis) is int:
+ if axis <= 0 or axis > MAX_AXIS_SIZE:
+ raise GraphValidationError("concrete shape dimensions are out of bounds")
+ return
+ if (
+ type(axis) is str
+ and len(axis) <= MAX_AXIS_SYMBOL_LENGTH
+ and AXIS_SYMBOL.fullmatch(axis) is not None
+ ):
+ return
+ raise GraphValidationError("shape dimensions must be bounded integers or symbols")
+
+
+@dataclass(frozen=True, slots=True)
+class ValueSpec:
+ """One declared tensor value, optionally annotated with a concrete unit."""
+
+ value_id: str
+ dtype: ScalarType
+ shape: tuple[ShapeAxis, ...]
+ unit_id: str | None = None
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not ValueSpec:
+ raise GraphValidationError("value declaration must be an exact ValueSpec")
+ _require_identifier(self.value_id, label="value identifier")
+ if type(self.dtype) is not ScalarType:
+ raise GraphValidationError("value dtype is not supported")
+ if type(self.shape) is not tuple:
+ raise GraphValidationError("value shape must be a tuple")
+ if len(self.shape) > MAX_TENSOR_RANK:
+ raise GraphValidationError("value shape exceeds the rank limit")
+ for axis in self.shape:
+ _validate_shape_axis(axis)
+ if self.unit_id is not None:
+ _require_identifier(self.unit_id, label="value unit identifier")
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return {
+ "dtype": self.dtype.value,
+ "shape": list(self.shape),
+ "unit_id": self.unit_id,
+ "value_id": self.value_id,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class Node:
+ """One source-labelled operation producing exactly one declared value."""
+
+ node_id: str
+ operation: Operation
+ inputs: tuple[str, ...]
+ output: str
+ exponent: Fraction | None = None
+ target_unit_id: str | None = None
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not Node:
+ raise GraphValidationError("node must be an exact Node")
+ _require_identifier(self.node_id, label="node identifier")
+ if type(self.operation) is not Operation:
+ raise GraphValidationError("node operation is not supported")
+ if type(self.inputs) is not tuple:
+ raise GraphValidationError("node inputs must be a tuple")
+ for input_id in self.inputs:
+ _require_identifier(input_id, label="node input identifier")
+ _require_identifier(self.output, label="node output identifier")
+
+ expected_arity = 1 if self.operation in UNARY_OPERATIONS else 2
+ if len(self.inputs) != expected_arity:
+ raise GraphValidationError("node input arity does not match its operation")
+
+ if self.operation is Operation.POWER:
+ if type(self.exponent) is not Fraction:
+ raise GraphValidationError("power nodes require an exact exponent")
+ _validate_exponent(self.exponent)
+ if self.target_unit_id is not None:
+ raise GraphValidationError("power nodes cannot declare a target unit")
+ return
+ if self.operation is Operation.CONVERT:
+ if self.exponent is not None:
+ raise GraphValidationError(
+ "conversion nodes cannot declare an exponent"
+ )
+ _require_identifier(
+ self.target_unit_id,
+ label="conversion target unit identifier",
+ )
+ return
+ if self.exponent is not None or self.target_unit_id is not None:
+ raise GraphValidationError("operation does not accept node attributes")
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ attributes: dict[str, str] = {}
+ if self.operation is Operation.POWER:
+ assert self.exponent is not None
+ attributes["exponent"] = _fraction_text(self.exponent)
+ elif self.operation is Operation.CONVERT:
+ assert self.target_unit_id is not None
+ attributes["unit_id"] = self.target_unit_id
+ return {
+ "attributes": attributes,
+ "inputs": list(self.inputs),
+ "node_id": self.node_id,
+ "operation": self.operation.value,
+ "output": self.output,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class ComputationGraph:
+ """A closed topological graph with a mutation-detecting content digest."""
+
+ graph_id: str
+ values: tuple[ValueSpec, ...]
+ inputs: tuple[str, ...]
+ nodes: tuple[Node, ...]
+ outputs: tuple[str, ...]
+ _digest: str = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._validate_structure()
+ object.__setattr__(self, "_digest", self._compute_digest())
+ self.validate()
+
+ def _validate_structure(self) -> None:
+ if type(self) is not ComputationGraph:
+ raise GraphValidationError("graph must be an exact ComputationGraph")
+ _require_identifier(
+ self.graph_id,
+ label="graph identifier",
+ max_length=MAX_GRAPH_ID_LENGTH,
+ )
+ self._validate_values()
+ self._validate_inputs()
+ self._validate_nodes_and_topology()
+ self._validate_outputs_and_reachability()
+
+ def _validate_values(self) -> None:
+ if type(self.values) is not tuple:
+ raise GraphValidationError("graph values must be a tuple")
+ if not self.values:
+ raise GraphValidationError("graph must declare at least one value")
+ if len(self.values) > MAX_GRAPH_VALUES:
+ raise GraphValidationError("graph contains too many values")
+ value_ids: list[str] = []
+ for value in self.values:
+ if type(value) is not ValueSpec:
+ raise GraphValidationError(
+ "graph values must be exact ValueSpec instances"
+ )
+ value.validate()
+ value_ids.append(value.value_id)
+ if len(set(value_ids)) != len(value_ids):
+ raise GraphValidationError("graph value identifiers must be unique")
+ if value_ids != sorted(value_ids):
+ raise GraphValidationError("graph values must be sorted by identifier")
+
+ def _validate_inputs(self) -> None:
+ if type(self.inputs) is not tuple:
+ raise GraphValidationError("graph inputs must be a tuple")
+ if not self.inputs:
+ raise GraphValidationError("graph must declare at least one input")
+ if len(self.inputs) > MAX_GRAPH_INPUTS:
+ raise GraphValidationError("graph contains too many inputs")
+ for input_id in self.inputs:
+ _require_identifier(input_id, label="graph input identifier")
+ if len(set(self.inputs)) != len(self.inputs):
+ raise GraphValidationError("graph input identifiers must be unique")
+ declared = {value.value_id for value in self.values}
+ if not set(self.inputs).issubset(declared):
+ raise GraphValidationError("graph input is not a declared value")
+
+ def _validate_nodes_and_topology(self) -> None:
+ if type(self.nodes) is not tuple:
+ raise GraphValidationError("graph nodes must be a tuple")
+ if len(self.nodes) > MAX_GRAPH_NODES:
+ raise GraphValidationError("graph contains too many nodes")
+
+ declared = {value.value_id for value in self.values}
+ available = set(self.inputs)
+ node_ids: set[str] = set()
+ for node in self.nodes:
+ if type(node) is not Node:
+ raise GraphValidationError("graph nodes must be exact Node instances")
+ node.validate()
+ if node.node_id in node_ids:
+ raise GraphValidationError("graph node identifiers must be unique")
+ if node.node_id in declared:
+ raise GraphValidationError("node identifier collides with a value")
+ node_ids.add(node.node_id)
+ if not set(node.inputs).issubset(available):
+ raise GraphValidationError(
+ "node inputs must reference earlier available values"
+ )
+ if node.output not in declared:
+ raise GraphValidationError("node output is not a declared value")
+ if node.output in available:
+ raise GraphValidationError("graph values must have one producer")
+ available.add(node.output)
+ if available != declared:
+ raise GraphValidationError(
+ "every non-input value must have exactly one producer"
+ )
+
+ def _validate_outputs_and_reachability(self) -> None:
+ if type(self.outputs) is not tuple:
+ raise GraphValidationError("graph outputs must be a tuple")
+ if not self.outputs:
+ raise GraphValidationError("graph must declare at least one output")
+ if len(self.outputs) > MAX_GRAPH_OUTPUTS:
+ raise GraphValidationError("graph contains too many outputs")
+ for output_id in self.outputs:
+ _require_identifier(output_id, label="graph output identifier")
+ if len(set(self.outputs)) != len(self.outputs):
+ raise GraphValidationError("graph output identifiers must be unique")
+
+ declared = {value.value_id for value in self.values}
+ if not set(self.outputs).issubset(declared):
+ raise GraphValidationError("graph output is not a declared value")
+
+ producer_inputs = {node.output: node.inputs for node in self.nodes}
+ required = set(self.outputs)
+ pending = list(self.outputs)
+ while pending:
+ value_id = pending.pop()
+ for input_id in producer_inputs.get(value_id, ()):
+ if input_id not in required:
+ required.add(input_id)
+ pending.append(input_id)
+ if required != declared:
+ raise GraphValidationError(
+ "every graph value must contribute to a declared output"
+ )
+
+ def validate(self) -> None:
+ self._validate_structure()
+ digest = getattr(self, "_digest", None)
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise GraphValidationError("graph digest is malformed")
+ if not hmac.compare_digest(digest, self._compute_digest()):
+ raise GraphValidationError("graph digest does not match its contents")
+
+ def _canonical_record_unchecked(self) -> dict[str, object]:
+ return {
+ "graph_id": self.graph_id,
+ "inputs": list(self.inputs),
+ "nodes": [node.canonical_record() for node in self.nodes],
+ "outputs": list(self.outputs),
+ "schema": GRAPH_SCHEMA,
+ "values": [value.canonical_record() for value in self.values],
+ }
+
+ def _compute_digest(self) -> str:
+ return sha256_hex(canonical_json_bytes(self._canonical_record_unchecked()))
+
+ @property
+ def digest(self) -> str:
+ self.validate()
+ return self._digest
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return self._canonical_record_unchecked()
+
+ def canonical_bytes(self) -> bytes:
+ self.validate()
+ return canonical_json_bytes(self._canonical_record_unchecked())
+
+ def value(self, value_id: str) -> ValueSpec:
+ self.validate()
+ lookup_id = _require_identifier(value_id, label="value lookup identifier")
+ for value in self.values:
+ if value.value_id == lookup_id:
+ return value
+ raise GraphValidationError("value identifier is not present in this graph")
+
+ def validate_units(self, registry: UnitRegistry) -> None:
+ """Require graph annotations to name canonical units in one snapshot."""
+
+ self.validate()
+ if type(registry) is not UnitRegistry:
+ raise GraphValidationError("unit registry must be an exact UnitRegistry")
+ registry.validate()
+ unit_ids = tuple(
+ value.unit_id for value in self.values if value.unit_id is not None
+ ) + tuple(
+ node.target_unit_id
+ for node in self.nodes
+ if node.target_unit_id is not None
+ )
+ for unit_id in unit_ids:
+ assert unit_id is not None
+ resolved = registry.resolve(unit_id)
+ if resolved.unit_id != unit_id:
+ raise GraphValidationError(
+ "graph unit annotations must use canonical registry identifiers"
+ )
diff --git a/src/unitsentinel/graph_codec.py b/src/unitsentinel/graph_codec.py
new file mode 100644
index 0000000..e05b6e8
--- /dev/null
+++ b/src/unitsentinel/graph_codec.py
@@ -0,0 +1,213 @@
+"""Strict canonical JSON codec for bounded computation graphs."""
+
+from __future__ import annotations
+
+import re
+from fractions import Fraction
+from typing import cast
+
+from .domain import UnitSentinelError, _fraction_text
+from .graph import (
+ GRAPH_SCHEMA,
+ ComputationGraph,
+ GraphError,
+ Node,
+ Operation,
+ ScalarType,
+ ShapeAxis,
+ ValueSpec,
+)
+from .json_boundary import (
+ CanonicalJSONError,
+ CanonicalJSONLimits,
+ decode_canonical_json,
+)
+
+MAX_GRAPH_BYTES = 1_048_576
+MAX_JSON_DEPTH = 8
+MAX_JSON_CONTAINER_ITEMS = 1_024
+MAX_JSON_TOTAL_VALUES = 32_768
+MAX_JSON_STRING_LENGTH = 128
+MAX_JSON_INTEGER_DIGITS = 10
+MAX_EXPONENT_TEXT_LENGTH = 16
+FRACTION_TEXT = re.compile(r"^(?:0|-?[1-9][0-9]*)(?:/[1-9][0-9]*)?$")
+_GRAPH_JSON_LIMITS = CanonicalJSONLimits(
+ max_bytes=MAX_GRAPH_BYTES,
+ max_depth=MAX_JSON_DEPTH,
+ max_container_items=MAX_JSON_CONTAINER_ITEMS,
+ max_total_values=MAX_JSON_TOTAL_VALUES,
+ max_string_length=MAX_JSON_STRING_LENGTH,
+ max_integer_digits=MAX_JSON_INTEGER_DIGITS,
+)
+
+ROOT_FIELDS = frozenset({"graph_id", "inputs", "nodes", "outputs", "schema", "values"})
+VALUE_FIELDS = frozenset({"dtype", "shape", "unit_id", "value_id"})
+NODE_FIELDS = frozenset({"attributes", "inputs", "node_id", "operation", "output"})
+
+
+class GraphDecodeError(GraphError):
+ """Raised when graph bytes are unsafe, noncanonical, or semantically invalid."""
+
+
+def encode_graph(graph: ComputationGraph) -> bytes:
+ """Return canonical bytes for one exact, currently valid graph."""
+
+ if type(graph) is not ComputationGraph:
+ raise GraphDecodeError("graph encoder requires an exact ComputationGraph")
+ try:
+ return graph.canonical_bytes()
+ except UnitSentinelError as error:
+ raise GraphDecodeError(f"graph encoding failed: {error}") from None
+
+
+def decode_graph(payload: bytes) -> ComputationGraph:
+ """Decode byte-for-byte canonical v1 JSON without coercion or extensions."""
+
+ try:
+ parsed = decode_canonical_json(
+ payload,
+ limits=_GRAPH_JSON_LIMITS,
+ label="graph",
+ )
+ except CanonicalJSONError as error:
+ raise GraphDecodeError(str(error)) from None
+
+ graph = _decode_semantic_graph(parsed)
+ if graph.canonical_bytes() != payload:
+ raise GraphDecodeError("graph payload does not match the canonical graph model")
+ return graph
+
+
+def _decode_semantic_graph(value: object) -> ComputationGraph:
+ root = _expect_object(value, ROOT_FIELDS, label="graph document")
+ schema = _expect_string(root["schema"], label="graph schema")
+ if schema != GRAPH_SCHEMA:
+ raise GraphDecodeError("graph schema is not supported")
+
+ try:
+ graph = ComputationGraph(
+ graph_id=_expect_string(root["graph_id"], label="graph identifier"),
+ values=tuple(
+ _decode_value(item)
+ for item in _expect_array(root["values"], label="graph values")
+ ),
+ inputs=_decode_string_array(root["inputs"], label="graph inputs"),
+ nodes=tuple(
+ _decode_node(item)
+ for item in _expect_array(root["nodes"], label="graph nodes")
+ ),
+ outputs=_decode_string_array(root["outputs"], label="graph outputs"),
+ )
+ except GraphDecodeError:
+ raise
+ except UnitSentinelError as error:
+ raise GraphDecodeError(f"graph semantic contract failed: {error}") from None
+ return graph
+
+
+def _decode_value(value: object) -> ValueSpec:
+ record = _expect_object(value, VALUE_FIELDS, label="value declaration")
+ dtype_text = _expect_string(record["dtype"], label="value dtype")
+ try:
+ dtype = ScalarType(dtype_text)
+ except ValueError:
+ raise GraphDecodeError("value dtype is not supported") from None
+
+ shape_values = _expect_array(record["shape"], label="value shape")
+ shape: list[ShapeAxis] = []
+ for axis in shape_values:
+ if type(axis) not in {int, str}:
+ raise GraphDecodeError("value shape contains an unsupported axis")
+ shape.append(cast(ShapeAxis, axis))
+
+ unit_value = record["unit_id"]
+ if unit_value is not None and type(unit_value) is not str:
+ raise GraphDecodeError("value unit identifier must be text or null")
+ return ValueSpec(
+ value_id=_expect_string(record["value_id"], label="value identifier"),
+ dtype=dtype,
+ shape=tuple(shape),
+ unit_id=unit_value,
+ )
+
+
+def _decode_node(value: object) -> Node:
+ record = _expect_object(value, NODE_FIELDS, label="node declaration")
+ operation_text = _expect_string(record["operation"], label="node operation")
+ try:
+ operation = Operation(operation_text)
+ except ValueError:
+ raise GraphDecodeError("node operation is not supported") from None
+
+ attributes = _expect_object(
+ record["attributes"],
+ _attribute_fields(operation),
+ label="node attributes",
+ )
+ exponent: Fraction | None = None
+ target_unit_id: str | None = None
+ if operation is Operation.POWER:
+ exponent = _decode_exponent(attributes["exponent"])
+ elif operation is Operation.CONVERT:
+ target_unit_id = _expect_string(
+ attributes["unit_id"],
+ label="conversion target unit identifier",
+ )
+
+ return Node(
+ node_id=_expect_string(record["node_id"], label="node identifier"),
+ operation=operation,
+ inputs=_decode_string_array(record["inputs"], label="node inputs"),
+ output=_expect_string(record["output"], label="node output identifier"),
+ exponent=exponent,
+ target_unit_id=target_unit_id,
+ )
+
+
+def _attribute_fields(operation: Operation) -> frozenset[str]:
+ if operation is Operation.POWER:
+ return frozenset({"exponent"})
+ if operation is Operation.CONVERT:
+ return frozenset({"unit_id"})
+ return frozenset()
+
+
+def _decode_exponent(value: object) -> Fraction:
+ text = _expect_string(value, label="power exponent")
+ if len(text) > MAX_EXPONENT_TEXT_LENGTH or FRACTION_TEXT.fullmatch(text) is None:
+ raise GraphDecodeError("power exponent is not a canonical rational")
+ exponent = Fraction(text)
+ if _fraction_text(exponent) != text:
+ raise GraphDecodeError("power exponent is not reduced")
+ return exponent
+
+
+def _expect_object(
+ value: object,
+ fields: frozenset[str],
+ *,
+ label: str,
+) -> dict[str, object]:
+ if type(value) is not dict:
+ raise GraphDecodeError(f"{label} must be an object")
+ record = cast(dict[str, object], value)
+ if set(record) != fields:
+ raise GraphDecodeError(f"{label} has missing or unknown fields")
+ return record
+
+
+def _expect_array(value: object, *, label: str) -> list[object]:
+ if type(value) is not list:
+ raise GraphDecodeError(f"{label} must be an array")
+ return cast(list[object], value)
+
+
+def _decode_string_array(value: object, *, label: str) -> tuple[str, ...]:
+ items = _expect_array(value, label=label)
+ return tuple(_expect_string(item, label=f"{label} entry") for item in items)
+
+
+def _expect_string(value: object, *, label: str) -> str:
+ if type(value) is not str:
+ raise GraphDecodeError(f"{label} must be text")
+ return value
diff --git a/src/unitsentinel/json_boundary.py b/src/unitsentinel/json_boundary.py
new file mode 100644
index 0000000..88bd676
--- /dev/null
+++ b/src/unitsentinel/json_boundary.py
@@ -0,0 +1,334 @@
+"""Reusable bounded canonical-JSON trust boundary."""
+
+from __future__ import annotations
+
+import json
+import re
+from dataclasses import dataclass
+from typing import NoReturn, cast
+
+from .canonical import canonical_json_bytes
+from .domain import UnitSentinelError
+
+_BOUNDARY_LABEL = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
+
+
+class CanonicalJSONError(UnitSentinelError):
+ """Raised when untrusted JSON bytes violate a canonical bounded contract."""
+
+
+@dataclass(frozen=True, slots=True)
+class CanonicalJSONLimits:
+ """Trusted resource limits for one canonical JSON document family."""
+
+ max_bytes: int
+ max_depth: int
+ max_container_items: int
+ max_total_values: int
+ max_string_length: int
+ max_integer_digits: int
+
+ def __post_init__(self) -> None:
+ values = (
+ self.max_bytes,
+ self.max_depth,
+ self.max_container_items,
+ self.max_total_values,
+ self.max_string_length,
+ self.max_integer_digits,
+ )
+ if any(type(value) is not int or value < 1 for value in values):
+ raise CanonicalJSONError(
+ "canonical JSON limits must be positive exact integers"
+ )
+
+
+@dataclass(slots=True)
+class _ContainerFrame:
+ opening: str
+ commas: int = 0
+
+
+def decode_canonical_json(
+ payload: bytes,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> object:
+ """Decode canonical UTF-8 JSON after structural allocation preflight."""
+
+ if type(limits) is not CanonicalJSONLimits:
+ raise CanonicalJSONError(
+ "canonical JSON limits must be an exact CanonicalJSONLimits"
+ )
+ if type(label) is not str or _BOUNDARY_LABEL.fullmatch(label) is None:
+ raise CanonicalJSONError("canonical JSON boundary label is invalid")
+ limits.__post_init__()
+ if type(payload) is not bytes:
+ raise CanonicalJSONError(f"{label} payload must be exact bytes")
+ if not payload:
+ raise CanonicalJSONError(f"{label} payload is empty")
+ if len(payload) > limits.max_bytes:
+ raise CanonicalJSONError(f"{label} payload exceeds the byte limit")
+ if payload.startswith(b"\xef\xbb\xbf"):
+ raise CanonicalJSONError(f"{label} payload must not contain a UTF-8 BOM")
+ try:
+ text = payload.decode("utf-8", errors="strict")
+ except UnicodeDecodeError:
+ raise CanonicalJSONError(f"{label} payload is not valid UTF-8") from None
+
+ _preflight_json_structure(text, limits=limits, label=label)
+ parsed = _parse_json(text, limits=limits, label=label)
+ _validate_json_tree(parsed, limits=limits, label=label)
+ try:
+ canonical = canonical_json_bytes(parsed)
+ except UnicodeEncodeError:
+ raise CanonicalJSONError(
+ f"{label} payload contains an invalid Unicode scalar"
+ ) from None
+ if canonical != payload:
+ raise CanonicalJSONError(f"{label} payload is not canonical JSON")
+ return parsed
+
+
+def _preflight_json_structure(
+ text: str,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> None:
+ """Bound structural expansion before ``json.loads`` allocates a tree."""
+
+ stack: list[_ContainerFrame] = []
+ token_count = 0
+ previous_significant: str | None = None
+ index = 0
+ while index < len(text):
+ character = text[index]
+ if character in " \t\r\n":
+ index += 1
+ continue
+ if character == '"':
+ token_count += 1
+ _require_preflight_token_budget(
+ token_count,
+ limits=limits,
+ label=label,
+ )
+ index = _scan_json_string(text, index + 1, label=label)
+ previous_significant = '"'
+ continue
+ if character in "[{":
+ token_count += 1
+ _require_preflight_token_budget(
+ token_count,
+ limits=limits,
+ label=label,
+ )
+ stack.append(_ContainerFrame(character))
+ if len(stack) > limits.max_depth:
+ raise CanonicalJSONError(f"{label} payload exceeds the nesting limit")
+ previous_significant = character
+ index += 1
+ continue
+ if character in "]}":
+ expected = "[" if character == "]" else "{"
+ if not stack or stack[-1].opening != expected:
+ raise CanonicalJSONError(f"{label} payload is not valid bounded JSON")
+ stack.pop()
+ previous_significant = character
+ index += 1
+ continue
+ if character == ",":
+ if not stack:
+ raise CanonicalJSONError(f"{label} payload is not valid bounded JSON")
+ stack[-1].commas += 1
+ if stack[-1].commas + 1 > limits.max_container_items:
+ kind = "array" if stack[-1].opening == "[" else "object"
+ raise CanonicalJSONError(
+ f"{label} payload {kind} exceeds the item limit"
+ )
+ previous_significant = character
+ index += 1
+ continue
+ if previous_significant is None or previous_significant in "[{,:":
+ token_count += 1
+ _require_preflight_token_budget(
+ token_count,
+ limits=limits,
+ label=label,
+ )
+ previous_significant = character
+ index += 1
+
+ if stack:
+ raise CanonicalJSONError(f"{label} payload is not valid bounded JSON")
+
+
+def _scan_json_string(text: str, index: int, *, label: str) -> int:
+ while index < len(text):
+ character = text[index]
+ if character == '"':
+ return index + 1
+ if character == "\\":
+ index += 1
+ if index >= len(text):
+ break
+ index += 1
+ raise CanonicalJSONError(f"{label} payload is not valid bounded JSON")
+
+
+def _require_preflight_token_budget(
+ token_count: int,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> None:
+ if token_count > limits.max_total_values:
+ raise CanonicalJSONError(f"{label} payload exceeds the JSON value limit")
+
+
+def _parse_json(
+ text: str,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> object:
+ try:
+ return cast(
+ object,
+ json.loads(
+ text,
+ object_pairs_hook=lambda pairs: _object_without_duplicates(
+ pairs,
+ label=label,
+ ),
+ parse_constant=lambda value: _reject_nonfinite_number(
+ value,
+ label=label,
+ ),
+ parse_float=lambda value: _reject_float(
+ value,
+ label=label,
+ ),
+ parse_int=lambda value: _parse_bounded_integer(
+ value,
+ limits=limits,
+ label=label,
+ ),
+ ),
+ )
+ except CanonicalJSONError:
+ raise
+ except (json.JSONDecodeError, RecursionError):
+ raise CanonicalJSONError(f"{label} payload is not valid bounded JSON") from None
+
+
+def _object_without_duplicates(
+ pairs: list[tuple[str, object]],
+ *,
+ label: str,
+) -> dict[str, object]:
+ result: dict[str, object] = {}
+ for key, value in pairs:
+ if key in result:
+ raise CanonicalJSONError(f"{label} payload contains a duplicate object key")
+ result[key] = value
+ return result
+
+
+def _reject_nonfinite_number(_: str, *, label: str) -> NoReturn:
+ raise CanonicalJSONError(f"{label} payload contains a non-finite number")
+
+
+def _reject_float(_: str, *, label: str) -> NoReturn:
+ raise CanonicalJSONError(f"{label} payload contains a floating-point number")
+
+
+def _parse_bounded_integer(
+ text: str,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> int:
+ digits = text[1:] if text.startswith("-") else text
+ if len(digits) > limits.max_integer_digits:
+ raise CanonicalJSONError(f"{label} payload integer exceeds the digit limit")
+ return int(text)
+
+
+def _validate_json_tree(
+ value: object,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> None:
+ counter = [0]
+ _walk_json(
+ value,
+ depth=0,
+ counter=counter,
+ limits=limits,
+ label=label,
+ )
+
+
+def _walk_json(
+ value: object,
+ *,
+ depth: int,
+ counter: list[int],
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> None:
+ counter[0] += 1
+ if counter[0] > limits.max_total_values:
+ raise CanonicalJSONError(f"{label} payload exceeds the JSON value limit")
+ if depth > limits.max_depth:
+ raise CanonicalJSONError(f"{label} payload exceeds the nesting limit")
+
+ if type(value) is dict:
+ mapping = cast(dict[str, object], value)
+ if len(mapping) > limits.max_container_items:
+ raise CanonicalJSONError(f"{label} payload object exceeds the item limit")
+ for key, child in mapping.items():
+ _validate_json_string(key, limits=limits, label=label)
+ _walk_json(
+ child,
+ depth=depth + 1,
+ counter=counter,
+ limits=limits,
+ label=label,
+ )
+ return
+ if type(value) is list:
+ sequence = cast(list[object], value)
+ if len(sequence) > limits.max_container_items:
+ raise CanonicalJSONError(f"{label} payload array exceeds the item limit")
+ for child in sequence:
+ _walk_json(
+ child,
+ depth=depth + 1,
+ counter=counter,
+ limits=limits,
+ label=label,
+ )
+ return
+ if type(value) is str:
+ _validate_json_string(value, limits=limits, label=label)
+ return
+ if value is None or type(value) in {bool, int}:
+ return
+ raise CanonicalJSONError(f"{label} payload contains an unsupported JSON value")
+
+
+def _validate_json_string(
+ value: str,
+ *,
+ limits: CanonicalJSONLimits,
+ label: str,
+) -> None:
+ if len(value) > limits.max_string_length:
+ raise CanonicalJSONError(f"{label} payload string exceeds the length limit")
+ if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
+ raise CanonicalJSONError(f"{label} payload contains an invalid Unicode scalar")
diff --git a/src/unitsentinel/registry.py b/src/unitsentinel/registry.py
new file mode 100644
index 0000000..e186475
--- /dev/null
+++ b/src/unitsentinel/registry.py
@@ -0,0 +1,332 @@
+"""Immutable, content-addressed unit registries.
+
+Registry identifiers are deliberately separate from display symbols. Graphs
+and certificates use canonical ASCII identifiers; symbols remain presentation
+metadata and are never accepted as aliases implicitly.
+"""
+
+from __future__ import annotations
+
+import hmac
+import re
+from dataclasses import dataclass, field
+from fractions import Fraction
+from typing import Final
+
+from .canonical import canonical_json_bytes, sha256_hex
+from .domain import (
+ AMOUNT_OF_SUBSTANCE,
+ DIMENSIONLESS,
+ ELECTRIC_CURRENT,
+ LENGTH,
+ LUMINOUS_INTENSITY,
+ MASS,
+ MAX_UNIT_ID_LENGTH,
+ THERMODYNAMIC_TEMPERATURE,
+ TIME,
+ UNIT_ID,
+ Dimension,
+ QuantityKind,
+ Unit,
+ UnitSentinelError,
+)
+
+REGISTRY_SCHEMA: Final = "unitsentinel.unit-registry/v1"
+MAX_REGISTRY_UNITS: Final = 64
+MAX_REGISTRY_ALIASES: Final = 64
+MAX_REGISTRY_VERSION_LENGTH: Final = 32
+REGISTRY_VERSION: Final = re.compile(
+ r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$"
+)
+SHA256_HEX: Final = re.compile(r"^[0-9a-f]{64}$")
+
+
+class RegistryError(UnitSentinelError):
+ """Raised when a registry snapshot is malformed or has been mutated."""
+
+
+class UnknownUnitError(RegistryError):
+ """Raised when a canonical identifier is absent from a registry."""
+
+
+def _require_registry_identifier(value: object, *, label: str) -> str:
+ if (
+ type(value) is not str
+ or len(value) > MAX_UNIT_ID_LENGTH
+ or UNIT_ID.fullmatch(value) is None
+ ):
+ raise RegistryError(f"{label} is not canonical")
+ return value
+
+
+@dataclass(frozen=True, slots=True)
+class UnitAlias:
+ """One explicit ASCII alias pointing directly to a canonical unit."""
+
+ alias_id: str
+ unit_id: str
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not UnitAlias:
+ raise RegistryError("unit alias must be an exact UnitAlias")
+ alias_id = _require_registry_identifier(self.alias_id, label="alias identifier")
+ unit_id = _require_registry_identifier(self.unit_id, label="alias target")
+ if alias_id == unit_id:
+ raise RegistryError("unit alias cannot point to itself")
+
+ def canonical_record(self) -> dict[str, str]:
+ self.validate()
+ return {"alias_id": self.alias_id, "unit_id": self.unit_id}
+
+
+@dataclass(frozen=True, slots=True)
+class UnitRegistry:
+ """A bounded registry snapshot whose digest detects nested mutation."""
+
+ version: str
+ units: tuple[Unit, ...]
+ aliases: tuple[UnitAlias, ...] = ()
+ _digest: str = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._validate_structure()
+ object.__setattr__(self, "_digest", self._compute_digest())
+ self.validate()
+
+ def _validate_structure(self) -> None:
+ if type(self) is not UnitRegistry:
+ raise RegistryError("registry must be an exact UnitRegistry")
+ if (
+ type(self.version) is not str
+ or len(self.version) > MAX_REGISTRY_VERSION_LENGTH
+ or REGISTRY_VERSION.fullmatch(self.version) is None
+ ):
+ raise RegistryError("registry version must be canonical SemVer")
+ if type(self.units) is not tuple:
+ raise RegistryError("registry units must be a tuple")
+ if not self.units:
+ raise RegistryError("registry must contain at least one unit")
+ if len(self.units) > MAX_REGISTRY_UNITS:
+ raise RegistryError("registry contains too many units")
+
+ unit_ids: list[str] = []
+ unit_symbols: list[str] = []
+ for unit in self.units:
+ if type(unit) is not Unit:
+ raise RegistryError("registry entries must be exact Unit values")
+ unit.validate()
+ unit_ids.append(unit.unit_id)
+ unit_symbols.append(unit.symbol)
+ if len(set(unit_ids)) != len(unit_ids):
+ raise RegistryError("registry unit identifiers must be unique")
+ if len(set(unit_symbols)) != len(unit_symbols):
+ raise RegistryError("registry unit symbols must be unique")
+ if unit_ids != sorted(unit_ids):
+ raise RegistryError("registry units must be sorted by identifier")
+
+ if type(self.aliases) is not tuple:
+ raise RegistryError("registry aliases must be a tuple")
+ if len(self.aliases) > MAX_REGISTRY_ALIASES:
+ raise RegistryError("registry contains too many aliases")
+
+ alias_ids: list[str] = []
+ canonical_ids = set(unit_ids)
+ for alias in self.aliases:
+ if type(alias) is not UnitAlias:
+ raise RegistryError("registry aliases must be exact UnitAlias values")
+ alias.validate()
+ alias_ids.append(alias.alias_id)
+ if alias.alias_id in canonical_ids:
+ raise RegistryError("unit alias collides with a canonical identifier")
+ if alias.unit_id not in canonical_ids:
+ raise RegistryError("unit alias target is not in this registry")
+ if len(set(alias_ids)) != len(alias_ids):
+ raise RegistryError("unit alias identifiers must be unique")
+ if alias_ids != sorted(alias_ids):
+ raise RegistryError("unit aliases must be sorted by identifier")
+
+ def validate(self) -> None:
+ self._validate_structure()
+ digest = getattr(self, "_digest", None)
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise RegistryError("registry digest is malformed")
+ if not hmac.compare_digest(digest, self._compute_digest()):
+ raise RegistryError("registry digest does not match its contents")
+
+ def _canonical_record_unchecked(self) -> dict[str, object]:
+ return {
+ "aliases": [alias.canonical_record() for alias in self.aliases],
+ "schema": REGISTRY_SCHEMA,
+ "units": [unit.canonical_record() for unit in self.units],
+ "version": self.version,
+ }
+
+ def _compute_digest(self) -> str:
+ return sha256_hex(canonical_json_bytes(self._canonical_record_unchecked()))
+
+ @property
+ def digest(self) -> str:
+ self.validate()
+ return self._digest
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return self._canonical_record_unchecked()
+
+ def canonical_bytes(self) -> bytes:
+ self.validate()
+ return canonical_json_bytes(self._canonical_record_unchecked())
+
+ def resolve(self, identifier: str) -> Unit:
+ """Resolve one canonical identifier or explicit alias."""
+
+ self.validate()
+ lookup_id = _require_registry_identifier(identifier, label="unit lookup")
+ for unit in self.units:
+ if unit.unit_id == lookup_id:
+ return unit
+ for alias in self.aliases:
+ if alias.alias_id == lookup_id:
+ return self._resolve_canonical(alias.unit_id)
+ raise UnknownUnitError("unit identifier is not present in this registry")
+
+ def _resolve_canonical(self, unit_id: str) -> Unit:
+ for unit in self.units:
+ if unit.unit_id == unit_id:
+ return unit
+ raise RegistryError("validated alias target disappeared")
+
+ def conversion_targets(self, identifier: str) -> tuple[Unit, ...]:
+ """Return deterministic same-dimension, same-kind conversion targets."""
+
+ source = self.resolve(identifier)
+ return tuple(
+ unit
+ for unit in self.units
+ if unit.dimension == source.dimension and unit.kind is source.kind
+ )
+
+
+def _linear(
+ unit_id: str,
+ symbol: str,
+ dimension: Dimension,
+ scale: Fraction = Fraction(1),
+) -> Unit:
+ return Unit(unit_id, symbol, dimension, scale)
+
+
+def _temperature(
+ unit_id: str,
+ symbol: str,
+ scale: Fraction,
+ offset: Fraction,
+ kind: QuantityKind,
+) -> Unit:
+ return Unit(
+ unit_id,
+ symbol,
+ THERMODYNAMIC_TEMPERATURE,
+ scale,
+ offset,
+ kind,
+ )
+
+
+def _builtin_registry() -> UnitRegistry:
+ velocity = LENGTH.divide(TIME)
+ acceleration = velocity.divide(TIME)
+ force = MASS.multiply(acceleration)
+ energy = force.multiply(LENGTH)
+ power = energy.divide(TIME)
+
+ units = (
+ _linear("ampere", "A", ELECTRIC_CURRENT),
+ _linear("candela", "cd", LUMINOUS_INTENSITY),
+ _linear("centimeter", "cm", LENGTH, Fraction(1, 100)),
+ _linear("coulomb", "C", ELECTRIC_CURRENT.multiply(TIME)),
+ _temperature(
+ "degree-celsius",
+ "°C",
+ Fraction(1),
+ Fraction(27_315, 100),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ),
+ _temperature(
+ "degree-fahrenheit",
+ "°F",
+ Fraction(5, 9),
+ Fraction(45_967, 180),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ),
+ _temperature(
+ "delta-celsius",
+ "Δ°C",
+ Fraction(1),
+ Fraction(0),
+ QuantityKind.TEMPERATURE_DELTA,
+ ),
+ _temperature(
+ "delta-fahrenheit",
+ "Δ°F",
+ Fraction(5, 9),
+ Fraction(0),
+ QuantityKind.TEMPERATURE_DELTA,
+ ),
+ _temperature(
+ "delta-kelvin",
+ "ΔK",
+ Fraction(1),
+ Fraction(0),
+ QuantityKind.TEMPERATURE_DELTA,
+ ),
+ _linear("gram", "g", MASS, Fraction(1, 1_000)),
+ _linear("hertz", "Hz", TIME.power(Fraction(-1))),
+ _linear("hour", "h", TIME, Fraction(3_600)),
+ _linear("joule", "J", energy),
+ _temperature(
+ "kelvin",
+ "K",
+ Fraction(1),
+ Fraction(0),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ),
+ _linear("kilogram", "kg", MASS),
+ _linear("kilometer", "km", LENGTH, Fraction(1_000)),
+ _linear("kilometer-per-hour", "km/h", velocity, Fraction(5, 18)),
+ _linear("meter", "m", LENGTH),
+ _linear("meter-per-second", "m/s", velocity),
+ _linear("meter-per-second-squared", "m/s²", acceleration),
+ _linear("micrometer", "µm", LENGTH, Fraction(1, 1_000_000)),
+ _linear("milligram", "mg", MASS, Fraction(1, 1_000_000)),
+ _linear("millimeter", "mm", LENGTH, Fraction(1, 1_000)),
+ _linear("millisecond", "ms", TIME, Fraction(1, 1_000)),
+ _linear("minute", "min", TIME, Fraction(60)),
+ _linear("mole", "mol", AMOUNT_OF_SUBSTANCE),
+ _linear("newton", "N", force),
+ _linear("one", "1", DIMENSIONLESS),
+ _linear("pascal", "Pa", force.divide(LENGTH.power(Fraction(2)))),
+ _linear("percent", "%", DIMENSIONLESS, Fraction(1, 100)),
+ _linear("second", "s", TIME),
+ _linear("volt", "V", power.divide(ELECTRIC_CURRENT)),
+ _linear("watt", "W", power),
+ )
+ aliases = (
+ UnitAlias("celsius", "degree-celsius"),
+ UnitAlias("celsius-delta", "delta-celsius"),
+ UnitAlias("centimetre", "centimeter"),
+ UnitAlias("fahrenheit", "degree-fahrenheit"),
+ UnitAlias("fahrenheit-delta", "delta-fahrenheit"),
+ UnitAlias("kelvin-delta", "delta-kelvin"),
+ UnitAlias("kilometre", "kilometer"),
+ UnitAlias("metre", "meter"),
+ UnitAlias("micrometre", "micrometer"),
+ UnitAlias("millimetre", "millimeter"),
+ )
+ return UnitRegistry(version="1.0.0", units=units, aliases=aliases)
+
+
+BUILTIN_REGISTRY: Final = _builtin_registry()
diff --git a/src/unitsentinel/replay.py b/src/unitsentinel/replay.py
new file mode 100644
index 0000000..6d9e884
--- /dev/null
+++ b/src/unitsentinel/replay.py
@@ -0,0 +1,657 @@
+"""Deterministic semantic replay for detached proof-certificate claims."""
+
+from __future__ import annotations
+
+import hmac
+from dataclasses import dataclass, field
+from enum import StrEnum
+from typing import Final
+
+import z3 # type: ignore[import-untyped]
+
+from .canonical import canonical_json_bytes, sha256_hex
+from .certificate import (
+ MAX_CERTIFICATE_SOLVER_VERSION_LENGTH,
+ CertificateError,
+ ProofCertificate,
+)
+from .domain import UnitSentinelError
+from .graph import ComputationGraph
+from .registry import (
+ BUILTIN_REGISTRY,
+ MAX_REGISTRY_VERSION_LENGTH,
+ REGISTRY_VERSION,
+ SHA256_HEX,
+ UnitRegistry,
+)
+from .verification import (
+ SOLVER_VERSION,
+ SolverLimits,
+ VerificationResult,
+ VerificationStatus,
+)
+from .verifier import (
+ _replay_claimed_contracts,
+ constraint_catalog,
+ verify_graph,
+)
+from .version import VERSION
+
+CERTIFICATE_REPLAY_SCHEMA: Final = "unitsentinel.certificate-replay/v1"
+_DEFAULT_REPLAY_LIMITS: Final = SolverLimits()
+
+
+class CertificateReplayError(CertificateError):
+ """Raised when replay inputs or the fresh verifier cannot be trusted."""
+
+
+class ReplayStatus(StrEnum):
+ """Stable high-level outcome of current semantic reproduction."""
+
+ REPRODUCED = "reproduced"
+ MISMATCH = "mismatch"
+ INDETERMINATE = "indeterminate"
+
+
+class ReplayReason(StrEnum):
+ """Deterministic first reason why a claim was not reproduced."""
+
+ GRAPH_DIGEST_MISMATCH = "graph-digest-mismatch"
+ REGISTRY_DIGEST_MISMATCH = "registry-digest-mismatch"
+ REGISTRY_VERSION_MISMATCH = "registry-version-mismatch"
+ CONSTRAINT_CATALOG_MISMATCH = "constraint-catalog-mismatch"
+ CONTRACT_COVERAGE_MISMATCH = "contract-coverage-mismatch"
+ CONTRACT_WITNESS_MISMATCH = "contract-witness-mismatch"
+ TOOLCHAIN_MISMATCH = "toolchain-mismatch"
+ FRESH_CONFLICT = "fresh-conflict"
+ FRESH_UNDERCONSTRAINED = "fresh-underconstrained"
+ FRESH_CONTRACT_MISMATCH = "fresh-contract-mismatch"
+ FRESH_UNKNOWN = "fresh-unknown"
+
+
+_EARLY_MISMATCH_REASONS: Final = frozenset(
+ {
+ ReplayReason.GRAPH_DIGEST_MISMATCH,
+ ReplayReason.REGISTRY_DIGEST_MISMATCH,
+ ReplayReason.REGISTRY_VERSION_MISMATCH,
+ ReplayReason.CONSTRAINT_CATALOG_MISMATCH,
+ ReplayReason.CONTRACT_COVERAGE_MISMATCH,
+ ReplayReason.CONTRACT_WITNESS_MISMATCH,
+ ReplayReason.TOOLCHAIN_MISMATCH,
+ }
+)
+_FRESH_MISMATCH_STATUS: Final = {
+ ReplayReason.FRESH_CONFLICT: VerificationStatus.CONFLICT,
+ ReplayReason.FRESH_UNDERCONSTRAINED: VerificationStatus.UNDERCONSTRAINED,
+ ReplayReason.FRESH_CONTRACT_MISMATCH: VerificationStatus.VERIFIED,
+}
+
+
+def _require_semver(value: object, *, label: str) -> str:
+ if (
+ type(value) is not str
+ or len(value) > MAX_REGISTRY_VERSION_LENGTH
+ or REGISTRY_VERSION.fullmatch(value) is None
+ ):
+ raise CertificateReplayError(f"{label} must be canonical SemVer")
+ return value
+
+
+def _require_solver_version(value: object, *, label: str) -> str:
+ if (
+ type(value) is not str
+ or len(value) > MAX_CERTIFICATE_SOLVER_VERSION_LENGTH
+ or SOLVER_VERSION.fullmatch(value) is None
+ ):
+ raise CertificateReplayError(f"{label} is malformed")
+ return value
+
+
+@dataclass(frozen=True, slots=True)
+class CertificateReplay:
+ """A content-addressed report of one current reproduction attempt.
+
+ The digest provides integrity, not authentication. Direct construction is
+ an unsigned claim; use :func:`replay_certificate` to perform the pure and
+ fresh-verifier checks represented by this report.
+ """
+
+ status: ReplayStatus
+ reason: ReplayReason | None
+ certificate_digest: str
+ graph_digest: str
+ registry_digest: str
+ registry_version: str
+ strict_toolchain: bool
+ certificate_verifier_version: str
+ certificate_solver_version: str
+ current_verifier_version: str
+ current_solver_version: str
+ toolchain_match: bool
+ fresh_result: VerificationResult | None
+ _digest: str = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._validate_structure()
+ object.__setattr__(self, "_digest", self._compute_digest())
+ self.validate()
+
+ def _validate_structure(self) -> None:
+ if type(self) is not CertificateReplay:
+ raise CertificateReplayError(
+ "replay report must be an exact CertificateReplay"
+ )
+ if type(self.status) is not ReplayStatus:
+ raise CertificateReplayError("replay status is unknown")
+ if self.reason is not None and type(self.reason) is not ReplayReason:
+ raise CertificateReplayError("replay reason is unknown")
+ for label, digest in (
+ ("replay certificate digest", self.certificate_digest),
+ ("replay graph digest", self.graph_digest),
+ ("replay registry digest", self.registry_digest),
+ ):
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise CertificateReplayError(f"{label} is malformed")
+ _require_semver(self.registry_version, label="replay registry version")
+ _require_semver(
+ self.certificate_verifier_version,
+ label="certificate verifier version",
+ )
+ _require_semver(
+ self.current_verifier_version,
+ label="current verifier version",
+ )
+ _require_solver_version(
+ self.certificate_solver_version,
+ label="certificate solver version",
+ )
+ _require_solver_version(
+ self.current_solver_version,
+ label="current solver version",
+ )
+ if type(self.strict_toolchain) is not bool:
+ raise CertificateReplayError(
+ "strict-toolchain flag must be an exact boolean"
+ )
+ if type(self.toolchain_match) is not bool:
+ raise CertificateReplayError(
+ "toolchain match flag must be an exact boolean"
+ )
+ expected_match = (
+ self.certificate_verifier_version == self.current_verifier_version
+ and self.certificate_solver_version == self.current_solver_version
+ )
+ if self.toolchain_match is not expected_match:
+ raise CertificateReplayError("toolchain match flag is inconsistent")
+
+ self._validate_fresh_result()
+ self._validate_outcome()
+
+ def _validate_fresh_result(self) -> None:
+ if self.fresh_result is None:
+ return
+ if type(self.fresh_result) is not VerificationResult:
+ raise CertificateReplayError(
+ "fresh result must be an exact VerificationResult or null"
+ )
+ try:
+ self.fresh_result.validate()
+ except UnitSentinelError:
+ raise CertificateReplayError(
+ "fresh verification result is malformed or mutated"
+ ) from None
+ if (
+ self.fresh_result.graph_digest != self.graph_digest
+ or self.fresh_result.registry_digest != self.registry_digest
+ ):
+ raise CertificateReplayError(
+ "fresh verification result has inconsistent source bindings"
+ )
+ if self.fresh_result.solver_version != self.current_solver_version:
+ raise CertificateReplayError(
+ "fresh verification result has inconsistent toolchain identity"
+ )
+
+ def _validate_outcome(self) -> None:
+ strict_mismatch = self.strict_toolchain and not self.toolchain_match
+ if strict_mismatch and (
+ self.fresh_result is not None
+ or self.reason is ReplayReason.CONTRACT_WITNESS_MISMATCH
+ ):
+ raise CertificateReplayError(
+ "strict-toolchain replay fields are inconsistent"
+ )
+ if self.status is ReplayStatus.REPRODUCED:
+ if (
+ self.reason is not None
+ or self.fresh_result is None
+ or self.fresh_result.status is not VerificationStatus.VERIFIED
+ or strict_mismatch
+ ):
+ raise CertificateReplayError(
+ "reproduced replay fields are inconsistent"
+ )
+ return
+ if self.status is ReplayStatus.INDETERMINATE:
+ if (
+ self.reason is not ReplayReason.FRESH_UNKNOWN
+ or self.fresh_result is None
+ or self.fresh_result.status is not VerificationStatus.UNKNOWN
+ ):
+ raise CertificateReplayError(
+ "indeterminate replay fields are inconsistent"
+ )
+ return
+ if self.reason in _EARLY_MISMATCH_REASONS:
+ if self.fresh_result is not None:
+ raise CertificateReplayError(
+ "early mismatch cannot contain a fresh result"
+ )
+ if self.reason is ReplayReason.TOOLCHAIN_MISMATCH and (
+ not self.strict_toolchain or self.toolchain_match
+ ):
+ raise CertificateReplayError(
+ "toolchain mismatch fields are inconsistent"
+ )
+ return
+ if self.reason is None:
+ raise CertificateReplayError("mismatch replay fields are inconsistent")
+ expected_status = _FRESH_MISMATCH_STATUS.get(self.reason)
+ if (
+ expected_status is None
+ or self.fresh_result is None
+ or self.fresh_result.status is not expected_status
+ ):
+ raise CertificateReplayError("mismatch replay fields are inconsistent")
+
+ def validate(self) -> None:
+ self._validate_structure()
+ digest = getattr(self, "_digest", None)
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise CertificateReplayError("replay report digest is malformed")
+ if not hmac.compare_digest(digest, self._compute_digest()):
+ raise CertificateReplayError(
+ "replay report digest does not match its contents"
+ )
+
+ def _canonical_record_unchecked(self) -> dict[str, object]:
+ fresh_result: dict[str, object] | None = None
+ if self.fresh_result is not None:
+ fresh_result = {
+ "record": self.fresh_result.canonical_record(),
+ "sha256": self.fresh_result.digest,
+ }
+ return {
+ "certificate_sha256": self.certificate_digest,
+ "fresh_result": fresh_result,
+ "graph_sha256": self.graph_digest,
+ "reason": None if self.reason is None else self.reason.value,
+ "registry": {
+ "sha256": self.registry_digest,
+ "version": self.registry_version,
+ },
+ "schema": CERTIFICATE_REPLAY_SCHEMA,
+ "status": self.status.value,
+ "strict_toolchain": self.strict_toolchain,
+ "toolchain": {
+ "certificate": {
+ "solver_version": self.certificate_solver_version,
+ "verifier_version": self.certificate_verifier_version,
+ },
+ "current": {
+ "solver_version": self.current_solver_version,
+ "verifier_version": self.current_verifier_version,
+ },
+ "match": self.toolchain_match,
+ },
+ }
+
+ def _compute_digest(self) -> str:
+ return sha256_hex(canonical_json_bytes(self._canonical_record_unchecked()))
+
+ @property
+ def digest(self) -> str:
+ self.validate()
+ return self._digest
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return self._canonical_record_unchecked()
+
+ def canonical_bytes(self) -> bytes:
+ self.validate()
+ return canonical_json_bytes(self._canonical_record_unchecked())
+
+
+@dataclass(frozen=True, slots=True)
+class _ReplayPins:
+ certificate_digest: str
+ graph_digest: str
+ registry_digest: str
+ registry_version: str
+ limits_bytes: bytes
+ certificate_verifier_version: str
+ certificate_solver_version: str
+ current_verifier_version: str
+ current_solver_version: str
+ toolchain_match: bool
+
+
+def replay_certificate(
+ certificate: ProofCertificate,
+ graph: ComputationGraph,
+ registry: UnitRegistry = BUILTIN_REGISTRY,
+ *,
+ limits: SolverLimits = _DEFAULT_REPLAY_LIMITS,
+ strict_toolchain: bool = False,
+) -> CertificateReplay:
+ """Reproduce one unsigned claim against exact current source contracts."""
+
+ certificate, graph, registry, limits = _validate_replay_inputs(
+ certificate,
+ graph,
+ registry,
+ limits,
+ strict_toolchain,
+ )
+ pins = _pin_replay_inputs(certificate, graph, registry, limits)
+
+ def finish(
+ status: ReplayStatus,
+ reason: ReplayReason | None,
+ fresh_result: VerificationResult | None = None,
+ ) -> CertificateReplay:
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ return _replay_outcome(
+ pins,
+ status=status,
+ reason=reason,
+ strict_toolchain=strict_toolchain,
+ fresh_result=fresh_result,
+ )
+
+ if not hmac.compare_digest(
+ certificate.result.graph_digest,
+ pins.graph_digest,
+ ):
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.GRAPH_DIGEST_MISMATCH,
+ )
+ if not hmac.compare_digest(
+ certificate.result.registry_digest,
+ pins.registry_digest,
+ ):
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.REGISTRY_DIGEST_MISMATCH,
+ )
+ if certificate.registry_version != pins.registry_version:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.REGISTRY_VERSION_MISMATCH,
+ )
+
+ try:
+ catalog = constraint_catalog(graph, registry)
+ except UnitSentinelError:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.CONSTRAINT_CATALOG_MISMATCH,
+ )
+ except Exception:
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ raise CertificateReplayError("certificate constraint catalog failed") from None
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ if certificate.constraints != catalog:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.CONSTRAINT_CATALOG_MISMATCH,
+ )
+
+ contract_ids = tuple(contract.value_id for contract in certificate.result.contracts)
+ expected_ids = tuple(value.value_id for value in graph.values)
+ if contract_ids != expected_ids:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.CONTRACT_COVERAGE_MISMATCH,
+ )
+ if strict_toolchain and not pins.toolchain_match:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.TOOLCHAIN_MISMATCH,
+ )
+
+ try:
+ witness_matches = _replay_claimed_contracts(
+ graph,
+ registry,
+ certificate.result.contracts,
+ )
+ except UnitSentinelError:
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ witness_matches = False
+ except Exception:
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ raise CertificateReplayError("certificate contract replay failed") from None
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ if not witness_matches:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.CONTRACT_WITNESS_MISMATCH,
+ )
+
+ try:
+ fresh_result = verify_graph(graph, registry=registry, limits=limits)
+ except Exception:
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ raise CertificateReplayError("fresh certificate verification failed") from None
+ _require_replay_inputs_unchanged(
+ certificate,
+ graph,
+ registry,
+ limits,
+ pins,
+ )
+ _validate_fresh_result(fresh_result, limits=limits, pins=pins)
+
+ if fresh_result.status is VerificationStatus.UNKNOWN:
+ return finish(
+ ReplayStatus.INDETERMINATE,
+ ReplayReason.FRESH_UNKNOWN,
+ fresh_result,
+ )
+ if fresh_result.status is VerificationStatus.CONFLICT:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.FRESH_CONFLICT,
+ fresh_result,
+ )
+ if fresh_result.status is VerificationStatus.UNDERCONSTRAINED:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.FRESH_UNDERCONSTRAINED,
+ fresh_result,
+ )
+ if fresh_result.contracts != certificate.result.contracts:
+ return finish(
+ ReplayStatus.MISMATCH,
+ ReplayReason.FRESH_CONTRACT_MISMATCH,
+ fresh_result,
+ )
+ return finish(ReplayStatus.REPRODUCED, None, fresh_result)
+
+
+def _validate_replay_inputs(
+ certificate: object,
+ graph: object,
+ registry: object,
+ limits: object,
+ strict_toolchain: object,
+) -> tuple[ProofCertificate, ComputationGraph, UnitRegistry, SolverLimits]:
+ if type(certificate) is not ProofCertificate:
+ raise CertificateReplayError("replay requires an exact ProofCertificate")
+ if type(graph) is not ComputationGraph:
+ raise CertificateReplayError("replay graph must be an exact ComputationGraph")
+ if type(registry) is not UnitRegistry:
+ raise CertificateReplayError("replay registry must be an exact UnitRegistry")
+ if type(limits) is not SolverLimits:
+ raise CertificateReplayError("replay limits must be an exact SolverLimits")
+ if type(strict_toolchain) is not bool:
+ raise CertificateReplayError("strict-toolchain flag must be an exact boolean")
+ try:
+ certificate.validate()
+ graph.validate()
+ registry.validate()
+ limits.validate()
+ except UnitSentinelError:
+ raise CertificateReplayError("replay inputs are malformed or mutated") from None
+ return certificate, graph, registry, limits
+
+
+def _pin_replay_inputs(
+ certificate: ProofCertificate,
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+ limits: SolverLimits,
+) -> _ReplayPins:
+ try:
+ current_solver_version = z3.get_version_string()
+ _require_solver_version(
+ current_solver_version,
+ label="current solver version",
+ )
+ return _ReplayPins(
+ certificate_digest=certificate.digest,
+ graph_digest=graph.digest,
+ registry_digest=registry.digest,
+ registry_version=registry.version,
+ limits_bytes=canonical_json_bytes(limits.canonical_record()),
+ certificate_verifier_version=certificate.verifier_version,
+ certificate_solver_version=certificate.result.solver_version,
+ current_verifier_version=VERSION,
+ current_solver_version=current_solver_version,
+ toolchain_match=(
+ certificate.verifier_version == VERSION
+ and certificate.result.solver_version == current_solver_version
+ ),
+ )
+ except Exception:
+ raise CertificateReplayError("replay inputs could not be pinned") from None
+
+
+def _require_replay_inputs_unchanged(
+ certificate: ProofCertificate,
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+ limits: SolverLimits,
+ pins: _ReplayPins,
+) -> None:
+ try:
+ certificate.validate()
+ graph.validate()
+ registry.validate()
+ limits.validate()
+ unchanged = (
+ certificate.digest == pins.certificate_digest
+ and graph.digest == pins.graph_digest
+ and registry.digest == pins.registry_digest
+ and registry.version == pins.registry_version
+ and canonical_json_bytes(limits.canonical_record()) == pins.limits_bytes
+ )
+ except Exception:
+ raise CertificateReplayError("replay inputs changed during replay") from None
+ if not unchanged:
+ raise CertificateReplayError("replay inputs changed during replay")
+
+
+def _validate_fresh_result(
+ result: object,
+ *,
+ limits: SolverLimits,
+ pins: _ReplayPins,
+) -> VerificationResult:
+ if type(result) is not VerificationResult:
+ raise CertificateReplayError("fresh verifier returned an invalid result type")
+ try:
+ result.validate()
+ except Exception:
+ raise CertificateReplayError(
+ "fresh verifier returned a malformed result"
+ ) from None
+ if (
+ result.graph_digest != pins.graph_digest
+ or result.registry_digest != pins.registry_digest
+ or result.solver_version != pins.current_solver_version
+ or canonical_json_bytes(result.limits.canonical_record())
+ != canonical_json_bytes(limits.canonical_record())
+ ):
+ raise CertificateReplayError("fresh verifier returned an inconsistent result")
+ return result
+
+
+def _replay_outcome(
+ pins: _ReplayPins,
+ *,
+ status: ReplayStatus,
+ reason: ReplayReason | None,
+ strict_toolchain: bool,
+ fresh_result: VerificationResult | None = None,
+) -> CertificateReplay:
+ return CertificateReplay(
+ status=status,
+ reason=reason,
+ certificate_digest=pins.certificate_digest,
+ graph_digest=pins.graph_digest,
+ registry_digest=pins.registry_digest,
+ registry_version=pins.registry_version,
+ strict_toolchain=strict_toolchain,
+ certificate_verifier_version=pins.certificate_verifier_version,
+ certificate_solver_version=pins.certificate_solver_version,
+ current_verifier_version=pins.current_verifier_version,
+ current_solver_version=pins.current_solver_version,
+ toolchain_match=pins.toolchain_match,
+ fresh_result=fresh_result,
+ )
diff --git a/src/unitsentinel/verification.py b/src/unitsentinel/verification.py
new file mode 100644
index 0000000..325a36e
--- /dev/null
+++ b/src/unitsentinel/verification.py
@@ -0,0 +1,414 @@
+"""Stable result contracts for dimensional verification."""
+
+from __future__ import annotations
+
+import hmac
+import re
+from dataclasses import dataclass, field
+from enum import StrEnum
+from fractions import Fraction
+from typing import Final
+
+from .canonical import canonical_json_bytes, sha256_hex
+from .domain import (
+ MAX_RATIONAL_BITS,
+ MAX_UNIT_ID_LENGTH,
+ THERMODYNAMIC_TEMPERATURE,
+ UNIT_ID,
+ Dimension,
+ QuantityKind,
+ UnitSentinelError,
+)
+from .registry import SHA256_HEX
+
+MAX_SOLVER_TIMEOUT_MS: Final = 10_000
+MAX_TOTAL_TIMEOUT_MS: Final = 60_000
+MAX_SOLVER_MEMORY_MB: Final = 4_096
+MAX_CORE_SHRINK_CHECKS: Final = 1_024
+MAX_UNIQUENESS_CHECKS: Final = 1_024
+MAX_CONSTRAINT_ID_LENGTH: Final = 192
+CONSTRAINT_ID: Final = re.compile(r"^[a-z][a-z0-9]*(?:(?:-|/)[a-z0-9]+)*$")
+SOLVER_VERSION: Final = re.compile(r"^[0-9]+(?:\.[0-9]+){2,3}$")
+
+
+class VerificationError(UnitSentinelError):
+ """Raised when a verifier configuration or result object is malformed."""
+
+
+class VerificationStatus(StrEnum):
+ VERIFIED = "verified"
+ UNDERCONSTRAINED = "underconstrained"
+ CONFLICT = "conflict"
+ UNKNOWN = "unknown"
+
+
+class UnknownReason(StrEnum):
+ CONTRACT_REJECTED = "contract-rejected"
+ INTERNAL_INCONSISTENCY = "internal-inconsistency"
+ MODEL_OUT_OF_DOMAIN = "model-out-of-domain"
+ RESOURCE_LIMIT = "resource-limit"
+ SOLVER_UNKNOWN = "solver-unknown"
+
+
+class ConstraintSource(StrEnum):
+ DECLARATION = "declaration"
+ OPERATION = "operation"
+
+
+def _require_exact_int(value: object, *, label: str) -> int:
+ if type(value) is not int:
+ raise VerificationError(f"{label} must be an exact integer")
+ return value
+
+
+def _require_exact_rational(value: object, *, label: str) -> Fraction:
+ if type(value) is not Fraction:
+ raise VerificationError(f"{label} must be an exact Fraction")
+ if (
+ abs(value.numerator).bit_length() > MAX_RATIONAL_BITS
+ or value.denominator.bit_length() > MAX_RATIONAL_BITS
+ ):
+ raise VerificationError(f"{label} exceeds the rational size limit")
+ return value
+
+
+def _fraction_text(value: Fraction) -> str:
+ return (
+ str(value.numerator)
+ if value.denominator == 1
+ else f"{value.numerator}/{value.denominator}"
+ )
+
+
+def _require_public_identifier(value: object, *, label: str) -> str:
+ if (
+ type(value) is not str
+ or len(value) > MAX_UNIT_ID_LENGTH
+ or UNIT_ID.fullmatch(value) is None
+ ):
+ raise VerificationError(f"{label} is not canonical")
+ return value
+
+
+@dataclass(frozen=True, slots=True)
+class SolverLimits:
+ """Explicit per-check and whole-verification resource limits."""
+
+ per_check_timeout_ms: int = 250
+ total_timeout_ms: int = 5_000
+ max_memory_mb: int = 256
+ max_core_shrink_checks: int = 64
+ max_uniqueness_checks: int = 577
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not SolverLimits:
+ raise VerificationError("solver limits must be an exact SolverLimits")
+ per_check = _require_exact_int(
+ self.per_check_timeout_ms,
+ label="per-check timeout",
+ )
+ total = _require_exact_int(
+ self.total_timeout_ms,
+ label="total timeout",
+ )
+ memory = _require_exact_int(self.max_memory_mb, label="solver memory")
+ core_checks = _require_exact_int(
+ self.max_core_shrink_checks,
+ label="core-shrink check limit",
+ )
+ uniqueness_checks = _require_exact_int(
+ self.max_uniqueness_checks,
+ label="uniqueness check limit",
+ )
+ if per_check < 1 or per_check > MAX_SOLVER_TIMEOUT_MS:
+ raise VerificationError("per-check timeout is out of bounds")
+ if total < per_check or total > MAX_TOTAL_TIMEOUT_MS:
+ raise VerificationError("total timeout is out of bounds")
+ if memory < 32 or memory > MAX_SOLVER_MEMORY_MB:
+ raise VerificationError("solver memory limit is out of bounds")
+ if core_checks < 0 or core_checks > MAX_CORE_SHRINK_CHECKS:
+ raise VerificationError("core-shrink check limit is out of bounds")
+ if uniqueness_checks < 1 or uniqueness_checks > MAX_UNIQUENESS_CHECKS:
+ raise VerificationError("uniqueness check limit is out of bounds")
+
+ def canonical_record(self) -> dict[str, int]:
+ self.validate()
+ return {
+ "max_core_shrink_checks": self.max_core_shrink_checks,
+ "max_memory_mb": self.max_memory_mb,
+ "max_uniqueness_checks": self.max_uniqueness_checks,
+ "per_check_timeout_ms": self.per_check_timeout_ms,
+ "total_timeout_ms": self.total_timeout_ms,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class ConstraintWitness:
+ """A stable public source label for one tracked assertion group."""
+
+ constraint_id: str
+ source: ConstraintSource
+ source_id: str
+ rule: str
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not ConstraintWitness:
+ raise VerificationError(
+ "constraint witness must be an exact ConstraintWitness"
+ )
+ if (
+ type(self.constraint_id) is not str
+ or len(self.constraint_id) > MAX_CONSTRAINT_ID_LENGTH
+ or CONSTRAINT_ID.fullmatch(self.constraint_id) is None
+ ):
+ raise VerificationError("constraint identifier is not canonical")
+ if type(self.source) is not ConstraintSource:
+ raise VerificationError("constraint source is unknown")
+ _require_public_identifier(self.source_id, label="constraint source identifier")
+ _require_public_identifier(self.rule, label="constraint rule")
+
+ def canonical_record(self) -> dict[str, str]:
+ self.validate()
+ return {
+ "constraint_id": self.constraint_id,
+ "rule": self.rule,
+ "source": self.source.value,
+ "source_id": self.source_id,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class InferredContract:
+ """One uniquely inferred dimension, kind, and numeric unit transform."""
+
+ value_id: str
+ dimension: Dimension
+ kind: QuantityKind
+ scale: Fraction
+ offset: Fraction
+
+ def __post_init__(self) -> None:
+ self.validate()
+
+ def validate(self) -> None:
+ if type(self) is not InferredContract:
+ raise VerificationError(
+ "inferred contract must be an exact InferredContract"
+ )
+ _require_public_identifier(self.value_id, label="inferred value identifier")
+ if type(self.dimension) is not Dimension:
+ raise VerificationError("inferred dimension must be an exact Dimension")
+ self.dimension.validate()
+ if type(self.kind) is not QuantityKind:
+ raise VerificationError("inferred quantity kind is unknown")
+ scale = _require_exact_rational(self.scale, label="inferred scale")
+ offset = _require_exact_rational(self.offset, label="inferred offset")
+ if scale <= 0:
+ raise VerificationError("inferred scale must be positive")
+ pure_temperature = self.dimension == THERMODYNAMIC_TEMPERATURE
+ temperature_kind = self.kind in {
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA,
+ }
+ if pure_temperature != temperature_kind:
+ raise VerificationError(
+ "inferred quantity kind is incoherent with its dimension"
+ )
+ if self.kind is not QuantityKind.ABSOLUTE_TEMPERATURE and offset != 0:
+ raise VerificationError(
+ "only an absolute temperature may have an inferred offset"
+ )
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return {
+ "dimension": [
+ {"base": base, "exponent": exponent}
+ for base, exponent in self.dimension.canonical_pairs()
+ ],
+ "kind": self.kind.value,
+ "offset": _fraction_text(self.offset),
+ "scale": _fraction_text(self.scale),
+ "value_id": self.value_id,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class VerificationResult:
+ """A content-addressed, outcome-specific verifier result."""
+
+ status: VerificationStatus
+ graph_digest: str
+ registry_digest: str
+ solver_version: str
+ limits: SolverLimits
+ checks_performed: int
+ contracts: tuple[InferredContract, ...] = ()
+ underconstrained_values: tuple[str, ...] = ()
+ conflict_core: tuple[ConstraintWitness, ...] = ()
+ core_minimal: bool | None = None
+ unknown_reason: UnknownReason | None = None
+ _digest: str = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ self._validate_structure()
+ object.__setattr__(self, "_digest", self._compute_digest())
+ self.validate()
+
+ def _validate_structure(self) -> None:
+ if type(self) is not VerificationResult:
+ raise VerificationError(
+ "verification result must be an exact VerificationResult"
+ )
+ if type(self.status) is not VerificationStatus:
+ raise VerificationError("verification status is unknown")
+ for label, digest in (
+ ("graph digest", self.graph_digest),
+ ("registry digest", self.registry_digest),
+ ):
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise VerificationError(f"{label} is malformed")
+ if (
+ type(self.solver_version) is not str
+ or SOLVER_VERSION.fullmatch(self.solver_version) is None
+ ):
+ raise VerificationError("solver version is malformed")
+ if type(self.limits) is not SolverLimits:
+ raise VerificationError("result limits must be an exact SolverLimits")
+ self.limits.validate()
+ checks = _require_exact_int(self.checks_performed, label="solver check count")
+ if checks < 0:
+ raise VerificationError("solver check count cannot be negative")
+ self._validate_collections()
+ self._validate_outcome_shape()
+
+ def _validate_collections(self) -> None:
+ if type(self.contracts) is not tuple:
+ raise VerificationError("inferred contracts must be a tuple")
+ contract_ids: list[str] = []
+ for contract in self.contracts:
+ if type(contract) is not InferredContract:
+ raise VerificationError(
+ "contracts must contain exact InferredContract values"
+ )
+ contract.validate()
+ contract_ids.append(contract.value_id)
+ if contract_ids != sorted(set(contract_ids)):
+ raise VerificationError("inferred contracts must be sorted and unique")
+
+ if type(self.underconstrained_values) is not tuple:
+ raise VerificationError("underconstrained values must be a tuple")
+ for value_id in self.underconstrained_values:
+ _require_public_identifier(
+ value_id,
+ label="underconstrained value identifier",
+ )
+ if list(self.underconstrained_values) != sorted(
+ set(self.underconstrained_values)
+ ):
+ raise VerificationError("underconstrained values must be sorted and unique")
+
+ if type(self.conflict_core) is not tuple:
+ raise VerificationError("conflict core must be a tuple")
+ constraint_ids: set[str] = set()
+ for witness in self.conflict_core:
+ if type(witness) is not ConstraintWitness:
+ raise VerificationError(
+ "conflict core must contain exact ConstraintWitness values"
+ )
+ witness.validate()
+ if witness.constraint_id in constraint_ids:
+ raise VerificationError("conflict core identifiers must be unique")
+ constraint_ids.add(witness.constraint_id)
+
+ def _validate_outcome_shape(self) -> None:
+ if self.status is VerificationStatus.VERIFIED:
+ if (
+ not self.contracts
+ or self.underconstrained_values
+ or self.conflict_core
+ or self.core_minimal is not None
+ or self.unknown_reason is not None
+ ):
+ raise VerificationError("verified result fields are inconsistent")
+ return
+ if self.status is VerificationStatus.UNDERCONSTRAINED:
+ if (
+ not self.underconstrained_values
+ or self.conflict_core
+ or self.core_minimal is not None
+ or self.unknown_reason is not None
+ ):
+ raise VerificationError(
+ "underconstrained result fields are inconsistent"
+ )
+ return
+ if self.status is VerificationStatus.CONFLICT:
+ if (
+ self.contracts
+ or self.underconstrained_values
+ or not self.conflict_core
+ or type(self.core_minimal) is not bool
+ or self.unknown_reason is not None
+ ):
+ raise VerificationError("conflict result fields are inconsistent")
+ return
+ if (
+ self.contracts
+ or self.underconstrained_values
+ or self.conflict_core
+ or self.core_minimal is not None
+ or type(self.unknown_reason) is not UnknownReason
+ ):
+ raise VerificationError("unknown result fields are inconsistent")
+
+ def validate(self) -> None:
+ self._validate_structure()
+ digest = getattr(self, "_digest", None)
+ if type(digest) is not str or SHA256_HEX.fullmatch(digest) is None:
+ raise VerificationError("verification result digest is malformed")
+ if not hmac.compare_digest(digest, self._compute_digest()):
+ raise VerificationError(
+ "verification result digest does not match its contents"
+ )
+
+ def _canonical_record_unchecked(self) -> dict[str, object]:
+ return {
+ "checks_performed": self.checks_performed,
+ "conflict_core": [
+ witness.canonical_record() for witness in self.conflict_core
+ ],
+ "contracts": [contract.canonical_record() for contract in self.contracts],
+ "core_minimal": self.core_minimal,
+ "graph_digest": self.graph_digest,
+ "limits": self.limits.canonical_record(),
+ "registry_digest": self.registry_digest,
+ "solver_version": self.solver_version,
+ "status": self.status.value,
+ "underconstrained_values": list(self.underconstrained_values),
+ "unknown_reason": (
+ None if self.unknown_reason is None else self.unknown_reason.value
+ ),
+ }
+
+ def _compute_digest(self) -> str:
+ return sha256_hex(canonical_json_bytes(self._canonical_record_unchecked()))
+
+ @property
+ def digest(self) -> str:
+ self.validate()
+ return self._digest
+
+ def canonical_record(self) -> dict[str, object]:
+ self.validate()
+ return self._canonical_record_unchecked()
+
+ def canonical_bytes(self) -> bytes:
+ self.validate()
+ return canonical_json_bytes(self._canonical_record_unchecked())
diff --git a/src/unitsentinel/verifier.py b/src/unitsentinel/verifier.py
new file mode 100644
index 0000000..46b5b81
--- /dev/null
+++ b/src/unitsentinel/verifier.py
@@ -0,0 +1,1673 @@
+"""Fail-closed dimensional and unit-transform verification.
+
+The public result deliberately exposes only trusted domain values. Solver
+symbols, models, diagnostics, and implementation-specific assertion names never
+cross this module's boundary.
+"""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Sequence
+from dataclasses import dataclass
+from enum import Enum, auto
+from fractions import Fraction
+from typing import Any, Final
+
+import z3 # type: ignore[import-untyped]
+
+from .domain import (
+ BASE_DIMENSION_COUNT,
+ MAX_RATIONAL_BITS,
+ THERMODYNAMIC_TEMPERATURE,
+ Dimension,
+ DimensionError,
+ QuantityKind,
+ Unit,
+ UnitSentinelError,
+)
+from .graph import ComputationGraph, GraphValidationError, Node, Operation
+from .registry import BUILTIN_REGISTRY, UnitRegistry
+from .verification import (
+ ConstraintSource,
+ ConstraintWitness,
+ InferredContract,
+ SolverLimits,
+ UnknownReason,
+ VerificationError,
+ VerificationResult,
+ VerificationStatus,
+)
+
+_LINEAR: Final = 0
+_ABSOLUTE_TEMPERATURE: Final = 1
+_TEMPERATURE_DELTA: Final = 2
+_KIND_CODES: Final = {
+ QuantityKind.LINEAR: _LINEAR,
+ QuantityKind.ABSOLUTE_TEMPERATURE: _ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA: _TEMPERATURE_DELTA,
+}
+_CODE_KINDS: Final = {code: kind for kind, code in _KIND_CODES.items()}
+_RESOURCE_MARKERS: Final = (
+ "canceled",
+ "max. memory",
+ "memout",
+ "rlimit",
+ "timeout",
+)
+_DEFAULT_SOLVER_LIMITS: Final = SolverLimits()
+_CONSTRAINT_ASPECTS: Final = ("dimension", "kind", "unit-transform")
+
+
+@dataclass(frozen=True, slots=True)
+class _ValueTerms:
+ dimension: tuple[Any, ...]
+ kind: Any
+ scale: Any
+ offset: Any
+
+
+@dataclass(frozen=True, slots=True)
+class _TrackedConstraint:
+ witness: ConstraintWitness
+ expression: Any
+ track_index: int = -1
+
+
+@dataclass(frozen=True, slots=True)
+class _CompiledProblem:
+ context: Any
+ terms: dict[str, _ValueTerms]
+ background: tuple[Any, ...]
+ constraints: tuple[_TrackedConstraint, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class _ModelValue:
+ dimension: Dimension
+ kind: QuantityKind
+ scale: Fraction
+ offset: Fraction
+
+
+class _CheckState(Enum):
+ SAT = auto()
+ UNSAT = auto()
+ RESOURCE_LIMIT = auto()
+ SOLVER_UNKNOWN = auto()
+
+
+@dataclass(frozen=True, slots=True)
+class _CheckResult:
+ state: _CheckState
+ solver: Any | None = None
+
+
+class _CheckBudget:
+ """One monotonic deadline shared by every solver check in a run."""
+
+ def __init__(
+ self,
+ *,
+ context: Any,
+ background: tuple[Any, ...],
+ limits: SolverLimits,
+ started_at: float,
+ ) -> None:
+ self._context = context
+ self._background = background
+ self._limits = limits
+ self._deadline = started_at + limits.total_timeout_ms / 1_000
+ self._checks_performed = 0
+
+ @property
+ def checks_performed(self) -> int:
+ return self._checks_performed
+
+ @property
+ def expired(self) -> bool:
+ return time.monotonic() >= self._deadline
+
+ def check(
+ self,
+ constraints: Sequence[_TrackedConstraint],
+ *,
+ extra: Any | None = None,
+ ) -> _CheckResult:
+ remaining_ms = int((self._deadline - time.monotonic()) * 1_000)
+ if remaining_ms < 1:
+ return _CheckResult(_CheckState.RESOURCE_LIMIT)
+
+ try:
+ solver = z3.Solver(ctx=self._context)
+ solver.set(
+ max_memory=self._limits.max_memory_mb,
+ random_seed=0,
+ threads=1,
+ )
+ solver.add(*self._background)
+ for constraint in constraints:
+ solver.assert_and_track(
+ constraint.expression,
+ z3.Bool(
+ f"tracked_{constraint.track_index:04d}",
+ ctx=self._context,
+ ),
+ )
+ if extra is not None:
+ solver.add(extra)
+
+ remaining_ms = int((self._deadline - time.monotonic()) * 1_000)
+ if remaining_ms < 1:
+ return _CheckResult(_CheckState.RESOURCE_LIMIT)
+ solver.set(
+ timeout=max(
+ 1,
+ min(self._limits.per_check_timeout_ms, remaining_ms),
+ )
+ )
+ self._checks_performed += 1
+ status = solver.check()
+ if time.monotonic() >= self._deadline:
+ return _CheckResult(_CheckState.RESOURCE_LIMIT)
+ if status == z3.sat:
+ return _CheckResult(_CheckState.SAT, solver)
+ if status == z3.unsat:
+ return _CheckResult(_CheckState.UNSAT, solver)
+ reason = solver.reason_unknown().lower()
+ except MemoryError:
+ return _CheckResult(_CheckState.RESOURCE_LIMIT)
+ except z3.Z3Exception:
+ reason = ""
+ if time.monotonic() >= self._deadline or any(
+ marker in reason for marker in _RESOURCE_MARKERS
+ ):
+ return _CheckResult(_CheckState.RESOURCE_LIMIT)
+ return _CheckResult(_CheckState.SOLVER_UNKNOWN)
+
+
+def _rational(value: Fraction, context: Any) -> Any:
+ return z3.Q(value.numerator, value.denominator, context)
+
+
+def _integer(value: int, context: Any) -> Any:
+ return z3.IntVal(value, ctx=context)
+
+
+def _kind_code(kind: QuantityKind) -> int:
+ return _KIND_CODES[kind]
+
+
+def _dimension_equals(
+ terms: _ValueTerms,
+ dimension: Dimension,
+ context: Any,
+) -> list[Any]:
+ return [
+ term == _rational(exponent, context)
+ for term, exponent in zip(
+ terms.dimension,
+ dimension.exponents,
+ strict=True,
+ )
+ ]
+
+
+def _same_dimension(left: _ValueTerms, right: _ValueTerms) -> list[Any]:
+ return [
+ left_term == right_term
+ for left_term, right_term in zip(
+ left.dimension,
+ right.dimension,
+ strict=True,
+ )
+ ]
+
+
+def _declaration_witness(value_id: str) -> ConstraintWitness:
+ return ConstraintWitness(
+ constraint_id=f"declaration/{value_id}/unit",
+ source=ConstraintSource.DECLARATION,
+ source_id=value_id,
+ rule="unit-annotation",
+ )
+
+
+def _operation_witness(node: Node, aspect: str) -> ConstraintWitness:
+ return ConstraintWitness(
+ constraint_id=f"operation/{node.node_id}/{aspect}",
+ source=ConstraintSource.OPERATION,
+ source_id=node.node_id,
+ rule=f"{node.operation.value}-{aspect}",
+ )
+
+
+def constraint_catalog(
+ graph: ComputationGraph,
+ registry: UnitRegistry = BUILTIN_REGISTRY,
+) -> tuple[ConstraintWitness, ...]:
+ """Return the stable public manifest compiled for one exact graph."""
+
+ if type(graph) is not ComputationGraph:
+ raise VerificationError("graph must be an exact ComputationGraph")
+ if type(registry) is not UnitRegistry:
+ raise VerificationError("registry must be an exact UnitRegistry")
+ try:
+ graph.validate()
+ registry.validate()
+ graph.validate_units(registry)
+ except UnitSentinelError:
+ raise VerificationError(
+ "graph or registry contract is rejected or mutated"
+ ) from None
+ witnesses = [
+ _declaration_witness(value.value_id)
+ for value in graph.values
+ if value.unit_id is not None
+ ]
+ witnesses.extend(
+ _operation_witness(node, aspect)
+ for node in graph.nodes
+ for aspect in _CONSTRAINT_ASPECTS
+ )
+ witnesses.sort(key=lambda witness: witness.constraint_id)
+ return tuple(witnesses)
+
+
+def _declaration_constraint(
+ *,
+ value_id: str,
+ terms: _ValueTerms,
+ unit: Unit,
+ context: Any,
+) -> _TrackedConstraint:
+ expressions = _dimension_equals(terms, unit.dimension, context)
+ expressions.extend(
+ (
+ terms.kind == _integer(_kind_code(unit.kind), context),
+ terms.scale == _rational(unit.scale, context),
+ terms.offset == _rational(unit.offset, context),
+ )
+ )
+ return _TrackedConstraint(
+ witness=_declaration_witness(value_id),
+ expression=z3.And(*expressions),
+ )
+
+
+def _operation_constraint(
+ *,
+ node: Node,
+ aspect: str,
+ expression: Any,
+) -> _TrackedConstraint:
+ return _TrackedConstraint(
+ witness=_operation_witness(node, aspect),
+ expression=expression,
+ )
+
+
+def _kind_is(terms: _ValueTerms, code: int, context: Any) -> Any:
+ return terms.kind == _integer(code, context)
+
+
+def _kind_tuple(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+ values: tuple[int, int, int],
+ context: Any,
+) -> Any:
+ return z3.And(
+ _kind_is(left, values[0], context),
+ _kind_is(right, values[1], context),
+ _kind_is(output, values[2], context),
+ )
+
+
+def _equal_scales(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+) -> Any:
+ return z3.And(left.scale == right.scale, output.scale == left.scale)
+
+
+def _add_kind_expression(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+ context: Any,
+) -> Any:
+ allowed = (
+ (_LINEAR, _LINEAR, _LINEAR),
+ (_ABSOLUTE_TEMPERATURE, _TEMPERATURE_DELTA, _ABSOLUTE_TEMPERATURE),
+ (_TEMPERATURE_DELTA, _ABSOLUTE_TEMPERATURE, _ABSOLUTE_TEMPERATURE),
+ (_TEMPERATURE_DELTA, _TEMPERATURE_DELTA, _TEMPERATURE_DELTA),
+ )
+ return z3.Or(
+ *(_kind_tuple(left, right, output, values, context) for values in allowed)
+ )
+
+
+def _subtract_kind_expression(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+ context: Any,
+) -> Any:
+ allowed = (
+ (_LINEAR, _LINEAR, _LINEAR),
+ (_ABSOLUTE_TEMPERATURE, _ABSOLUTE_TEMPERATURE, _TEMPERATURE_DELTA),
+ (_ABSOLUTE_TEMPERATURE, _TEMPERATURE_DELTA, _ABSOLUTE_TEMPERATURE),
+ (_TEMPERATURE_DELTA, _TEMPERATURE_DELTA, _TEMPERATURE_DELTA),
+ )
+ return z3.Or(
+ *(_kind_tuple(left, right, output, values, context) for values in allowed)
+ )
+
+
+def _add_transform_expression(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+ context: Any,
+) -> Any:
+ same_scale = _equal_scales(left, right, output)
+ cases = (
+ (
+ (_LINEAR, _LINEAR, _LINEAR),
+ z3.And(
+ same_scale,
+ left.offset == 0,
+ right.offset == 0,
+ output.offset == 0,
+ ),
+ ),
+ (
+ (
+ _ABSOLUTE_TEMPERATURE,
+ _TEMPERATURE_DELTA,
+ _ABSOLUTE_TEMPERATURE,
+ ),
+ z3.And(same_scale, output.offset == left.offset),
+ ),
+ (
+ (
+ _TEMPERATURE_DELTA,
+ _ABSOLUTE_TEMPERATURE,
+ _ABSOLUTE_TEMPERATURE,
+ ),
+ z3.And(same_scale, output.offset == right.offset),
+ ),
+ (
+ (
+ _TEMPERATURE_DELTA,
+ _TEMPERATURE_DELTA,
+ _TEMPERATURE_DELTA,
+ ),
+ z3.And(same_scale, output.offset == 0),
+ ),
+ )
+ return z3.And(
+ *(
+ z3.Implies(
+ _kind_tuple(left, right, output, kinds, context),
+ transform,
+ )
+ for kinds, transform in cases
+ )
+ )
+
+
+def _subtract_transform_expression(
+ left: _ValueTerms,
+ right: _ValueTerms,
+ output: _ValueTerms,
+ context: Any,
+) -> Any:
+ same_scale = _equal_scales(left, right, output)
+ cases = (
+ (
+ (_LINEAR, _LINEAR, _LINEAR),
+ z3.And(
+ same_scale,
+ left.offset == 0,
+ right.offset == 0,
+ output.offset == 0,
+ ),
+ ),
+ (
+ (
+ _ABSOLUTE_TEMPERATURE,
+ _ABSOLUTE_TEMPERATURE,
+ _TEMPERATURE_DELTA,
+ ),
+ z3.And(
+ same_scale,
+ left.offset == right.offset,
+ output.offset == 0,
+ ),
+ ),
+ (
+ (
+ _ABSOLUTE_TEMPERATURE,
+ _TEMPERATURE_DELTA,
+ _ABSOLUTE_TEMPERATURE,
+ ),
+ z3.And(same_scale, output.offset == left.offset),
+ ),
+ (
+ (
+ _TEMPERATURE_DELTA,
+ _TEMPERATURE_DELTA,
+ _TEMPERATURE_DELTA,
+ ),
+ z3.And(same_scale, output.offset == 0),
+ ),
+ )
+ return z3.And(
+ *(
+ z3.Implies(
+ _kind_tuple(left, right, output, kinds, context),
+ transform,
+ )
+ for kinds, transform in cases
+ )
+ )
+
+
+def _positive_integer_power(expression: Any, exponent: int, context: Any) -> Any:
+ if exponent == 0:
+ return _rational(Fraction(1), context)
+ return expression**exponent
+
+
+def _power_scale_expression(
+ source: _ValueTerms,
+ output: _ValueTerms,
+ exponent: Fraction,
+ context: Any,
+) -> Any:
+ numerator = exponent.numerator
+ denominator = exponent.denominator
+ output_power = _positive_integer_power(output.scale, denominator, context)
+ if numerator >= 0:
+ return output_power == _positive_integer_power(
+ source.scale,
+ numerator,
+ context,
+ )
+ return output_power * _positive_integer_power(
+ source.scale, -numerator, context
+ ) == _rational(Fraction(1), context)
+
+
+def _node_dimension_expression(
+ node: Node,
+ terms: dict[str, _ValueTerms],
+ registry: UnitRegistry,
+ context: Any,
+) -> Any:
+ inputs = tuple(terms[value_id] for value_id in node.inputs)
+ output = terms[node.output]
+ if node.operation in {
+ Operation.IDENTITY,
+ Operation.ADD,
+ Operation.SUBTRACT,
+ Operation.MINIMUM,
+ Operation.MAXIMUM,
+ }:
+ equations = _same_dimension(inputs[0], output)
+ if len(inputs) == 2:
+ equations.extend(_same_dimension(inputs[0], inputs[1]))
+ return z3.And(*equations)
+ if node.operation in {Operation.MULTIPLY, Operation.MATMUL}:
+ return z3.And(
+ *(
+ out == left + right
+ for out, left, right in zip(
+ output.dimension,
+ inputs[0].dimension,
+ inputs[1].dimension,
+ strict=True,
+ )
+ )
+ )
+ if node.operation is Operation.DIVIDE:
+ return z3.And(
+ *(
+ out == left - right
+ for out, left, right in zip(
+ output.dimension,
+ inputs[0].dimension,
+ inputs[1].dimension,
+ strict=True,
+ )
+ )
+ )
+ if node.operation is Operation.POWER:
+ assert node.exponent is not None
+ factor = _rational(node.exponent, context)
+ return z3.And(
+ *(
+ out == source * factor
+ for out, source in zip(
+ output.dimension,
+ inputs[0].dimension,
+ strict=True,
+ )
+ )
+ )
+ if node.operation in {
+ Operation.EXP,
+ Operation.LOG,
+ Operation.SIGMOID,
+ Operation.SOFTMAX,
+ }:
+ equations = _dimension_equals(
+ inputs[0],
+ Dimension.dimensionless(),
+ context,
+ )
+ equations.extend(_dimension_equals(output, Dimension.dimensionless(), context))
+ return z3.And(*equations)
+ assert node.operation is Operation.CONVERT
+ assert node.target_unit_id is not None
+ target = registry.resolve(node.target_unit_id)
+ equations = _dimension_equals(inputs[0], target.dimension, context)
+ equations.extend(_dimension_equals(output, target.dimension, context))
+ return z3.And(*equations)
+
+
+def _node_kind_expression(
+ node: Node,
+ terms: dict[str, _ValueTerms],
+ registry: UnitRegistry,
+ context: Any,
+) -> Any:
+ inputs = tuple(terms[value_id] for value_id in node.inputs)
+ output = terms[node.output]
+ if node.operation is Operation.IDENTITY:
+ return output.kind == inputs[0].kind
+ if node.operation is Operation.ADD:
+ return _add_kind_expression(inputs[0], inputs[1], output, context)
+ if node.operation is Operation.SUBTRACT:
+ return _subtract_kind_expression(inputs[0], inputs[1], output, context)
+ if node.operation in {Operation.MINIMUM, Operation.MAXIMUM}:
+ return z3.And(
+ inputs[0].kind == inputs[1].kind,
+ output.kind == inputs[0].kind,
+ )
+ if node.operation in {
+ Operation.MULTIPLY,
+ Operation.MATMUL,
+ Operation.DIVIDE,
+ Operation.POWER,
+ }:
+ return z3.And(
+ *(
+ value.kind != _integer(_ABSOLUTE_TEMPERATURE, context)
+ for value in inputs
+ ),
+ output.kind != _integer(_ABSOLUTE_TEMPERATURE, context),
+ )
+ if node.operation in {
+ Operation.EXP,
+ Operation.LOG,
+ Operation.SIGMOID,
+ Operation.SOFTMAX,
+ }:
+ return z3.And(
+ _kind_is(inputs[0], _LINEAR, context),
+ _kind_is(output, _LINEAR, context),
+ )
+ assert node.operation is Operation.CONVERT
+ assert node.target_unit_id is not None
+ target_code = _kind_code(registry.resolve(node.target_unit_id).kind)
+ return z3.And(
+ _kind_is(inputs[0], target_code, context),
+ _kind_is(output, target_code, context),
+ )
+
+
+def _node_transform_expression(
+ node: Node,
+ terms: dict[str, _ValueTerms],
+ registry: UnitRegistry,
+ graph: ComputationGraph,
+ context: Any,
+) -> Any:
+ inputs = tuple(terms[value_id] for value_id in node.inputs)
+ output = terms[node.output]
+ if node.operation is Operation.IDENTITY:
+ return z3.And(
+ output.scale == inputs[0].scale,
+ output.offset == inputs[0].offset,
+ )
+ if node.operation is Operation.ADD:
+ return _add_transform_expression(inputs[0], inputs[1], output, context)
+ if node.operation is Operation.SUBTRACT:
+ return _subtract_transform_expression(
+ inputs[0],
+ inputs[1],
+ output,
+ context,
+ )
+ if node.operation in {Operation.MINIMUM, Operation.MAXIMUM}:
+ return z3.And(
+ inputs[0].scale == inputs[1].scale,
+ output.scale == inputs[0].scale,
+ inputs[0].offset == inputs[1].offset,
+ output.offset == inputs[0].offset,
+ )
+ if node.operation in {Operation.MULTIPLY, Operation.MATMUL}:
+ return output.scale == inputs[0].scale * inputs[1].scale
+ if node.operation is Operation.DIVIDE:
+ return output.scale * inputs[1].scale == inputs[0].scale
+ if node.operation is Operation.POWER:
+ assert node.exponent is not None
+ return _power_scale_expression(
+ inputs[0],
+ output,
+ node.exponent,
+ context,
+ )
+ if node.operation in {
+ Operation.EXP,
+ Operation.LOG,
+ Operation.SIGMOID,
+ Operation.SOFTMAX,
+ }:
+ one = _rational(Fraction(1), context)
+ zero = _rational(Fraction(0), context)
+ return z3.And(
+ inputs[0].scale == one,
+ inputs[0].offset == zero,
+ output.scale == one,
+ output.offset == zero,
+ )
+
+ assert node.operation is Operation.CONVERT
+ assert node.target_unit_id is not None
+ target = registry.resolve(node.target_unit_id)
+ output_annotation = graph.value(node.output).unit_id
+ exact_output_contract = (
+ output_annotation is None or output_annotation == target.unit_id
+ )
+ return z3.And(
+ output.scale == _rational(target.scale, context),
+ output.offset == _rational(target.offset, context),
+ z3.BoolVal(exact_output_contract, ctx=context),
+ )
+
+
+def _compile_problem(
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+ context: Any,
+) -> _CompiledProblem:
+ terms: dict[str, _ValueTerms] = {}
+ background: list[Any] = []
+ constraints: list[_TrackedConstraint] = []
+ temperature = THERMODYNAMIC_TEMPERATURE.exponents
+
+ for index, value in enumerate(graph.values):
+ value_terms = _ValueTerms(
+ dimension=tuple(
+ z3.Real(f"d_{index:03d}_{axis}", ctx=context)
+ for axis in range(BASE_DIMENSION_COUNT)
+ ),
+ kind=z3.Int(f"k_{index:03d}", ctx=context),
+ scale=z3.Real(f"s_{index:03d}", ctx=context),
+ offset=z3.Real(f"o_{index:03d}", ctx=context),
+ )
+ terms[value.value_id] = value_terms
+ pure_temperature = z3.And(
+ *(
+ term == _rational(exponent, context)
+ for term, exponent in zip(
+ value_terms.dimension,
+ temperature,
+ strict=True,
+ )
+ )
+ )
+ temperature_kind = z3.Or(
+ _kind_is(value_terms, _ABSOLUTE_TEMPERATURE, context),
+ _kind_is(value_terms, _TEMPERATURE_DELTA, context),
+ )
+ background.extend(
+ (
+ z3.Or(
+ _kind_is(value_terms, _LINEAR, context),
+ _kind_is(value_terms, _ABSOLUTE_TEMPERATURE, context),
+ _kind_is(value_terms, _TEMPERATURE_DELTA, context),
+ ),
+ temperature_kind == pure_temperature,
+ value_terms.scale > _rational(Fraction(0), context),
+ z3.Implies(
+ value_terms.kind
+ != _integer(
+ _ABSOLUTE_TEMPERATURE,
+ context,
+ ),
+ value_terms.offset == _rational(Fraction(0), context),
+ ),
+ )
+ )
+ if value.unit_id is not None:
+ constraints.append(
+ _declaration_constraint(
+ value_id=value.value_id,
+ terms=value_terms,
+ unit=registry.resolve(value.unit_id),
+ context=context,
+ )
+ )
+
+ for node in graph.nodes:
+ expressions = (
+ _node_dimension_expression(
+ node,
+ terms,
+ registry,
+ context,
+ ),
+ _node_kind_expression(
+ node,
+ terms,
+ registry,
+ context,
+ ),
+ _node_transform_expression(
+ node,
+ terms,
+ registry,
+ graph,
+ context,
+ ),
+ )
+ constraints.extend(
+ _operation_constraint(
+ node=node,
+ aspect=aspect,
+ expression=expression,
+ )
+ for aspect, expression in zip(
+ _CONSTRAINT_ASPECTS,
+ expressions,
+ strict=True,
+ )
+ )
+
+ constraints.sort(key=lambda item: item.witness.constraint_id)
+ indexed_constraints = tuple(
+ _TrackedConstraint(
+ witness=constraint.witness,
+ expression=constraint.expression,
+ track_index=index,
+ )
+ for index, constraint in enumerate(constraints)
+ )
+ return _CompiledProblem(
+ context=context,
+ terms=terms,
+ background=tuple(background),
+ constraints=indexed_constraints,
+ )
+
+
+def _fraction_from_solver(value: Any) -> Fraction:
+ if not z3.is_rational_value(value):
+ raise VerificationError("solver model contains a non-rational value")
+ numerator = value.numerator_as_long()
+ denominator = value.denominator_as_long()
+ result = Fraction(numerator, denominator)
+ if (
+ abs(result.numerator).bit_length() > MAX_RATIONAL_BITS
+ or result.denominator.bit_length() > MAX_RATIONAL_BITS
+ ):
+ raise VerificationError("solver rational exceeds the domain size limit")
+ return result
+
+
+def _extract_model(
+ problem: _CompiledProblem,
+ solver: Any,
+) -> dict[str, _ModelValue]:
+ model = solver.model()
+ extracted: dict[str, _ModelValue] = {}
+ for value_id, terms in problem.terms.items():
+ dimension_values = tuple(
+ _fraction_from_solver(model.eval(term, model_completion=True))
+ for term in terms.dimension
+ )
+ kind_value = model.eval(terms.kind, model_completion=True)
+ if not z3.is_int_value(kind_value):
+ raise VerificationError("solver model contains a non-integer kind")
+ kind = _CODE_KINDS.get(kind_value.as_long())
+ if kind is None:
+ raise VerificationError("solver model contains an unknown kind")
+ try:
+ dimension = Dimension(dimension_values)
+ except DimensionError as error:
+ raise VerificationError(
+ "solver dimension is outside the trusted domain"
+ ) from error
+ extracted[value_id] = _ModelValue(
+ dimension=dimension,
+ kind=kind,
+ scale=_fraction_from_solver(model.eval(terms.scale, model_completion=True)),
+ offset=_fraction_from_solver(
+ model.eval(terms.offset, model_completion=True)
+ ),
+ )
+ return extracted
+
+
+def _model_difference(
+ problem: _CompiledProblem,
+ model_values: dict[str, _ModelValue],
+ value_ids: Sequence[str],
+) -> Any:
+ alternatives: list[Any] = []
+ for value_id in value_ids:
+ terms = problem.terms[value_id]
+ baseline = model_values[value_id]
+ alternatives.extend(
+ term != _rational(exponent, problem.context)
+ for term, exponent in zip(
+ terms.dimension,
+ baseline.dimension.exponents,
+ strict=True,
+ )
+ )
+ alternatives.extend(
+ (
+ terms.kind != _integer(_kind_code(baseline.kind), problem.context),
+ terms.scale != _rational(baseline.scale, problem.context),
+ terms.offset != _rational(baseline.offset, problem.context),
+ )
+ )
+ return z3.Or(*alternatives)
+
+
+def _coherent_model_value(value: _ModelValue) -> bool:
+ pure_temperature = value.dimension == THERMODYNAMIC_TEMPERATURE
+ temperature_kind = value.kind in {
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA,
+ }
+ if pure_temperature != temperature_kind or value.scale <= 0:
+ return False
+ return not (
+ value.kind is not QuantityKind.ABSOLUTE_TEMPERATURE and value.offset != 0
+ )
+
+
+def _unit_matches(value: _ModelValue, unit: Unit) -> bool:
+ return (
+ value.dimension == unit.dimension
+ and value.kind is unit.kind
+ and value.scale == unit.scale
+ and value.offset == unit.offset
+ )
+
+
+def _same_transform(*values: _ModelValue) -> bool:
+ first = values[0]
+ return all(
+ value.scale == first.scale and value.offset == first.offset
+ for value in values[1:]
+ )
+
+
+def _replay_add(
+ left: _ModelValue,
+ right: _ModelValue,
+ output: _ModelValue,
+) -> bool:
+ if not (left.dimension == right.dimension == output.dimension):
+ return False
+ kinds = (left.kind, right.kind, output.kind)
+ if kinds == (
+ QuantityKind.LINEAR,
+ QuantityKind.LINEAR,
+ QuantityKind.LINEAR,
+ ):
+ return _same_transform(left, right, output)
+ if kinds == (
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ):
+ return (
+ left.scale == right.scale == output.scale and output.offset == left.offset
+ )
+ if kinds == (
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ):
+ return (
+ left.scale == right.scale == output.scale and output.offset == right.offset
+ )
+ if kinds == (
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.TEMPERATURE_DELTA,
+ ):
+ return left.scale == right.scale == output.scale
+ return False
+
+
+def _replay_subtract(
+ left: _ModelValue,
+ right: _ModelValue,
+ output: _ModelValue,
+) -> bool:
+ if not (left.dimension == right.dimension == output.dimension):
+ return False
+ kinds = (left.kind, right.kind, output.kind)
+ if kinds == (
+ QuantityKind.LINEAR,
+ QuantityKind.LINEAR,
+ QuantityKind.LINEAR,
+ ):
+ return _same_transform(left, right, output)
+ if kinds == (
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA,
+ ):
+ return (
+ left.scale == right.scale == output.scale
+ and left.offset == right.offset
+ and output.offset == 0
+ )
+ if kinds == (
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ):
+ return (
+ left.scale == right.scale == output.scale and output.offset == left.offset
+ )
+ if kinds == (
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.TEMPERATURE_DELTA,
+ QuantityKind.TEMPERATURE_DELTA,
+ ):
+ return left.scale == right.scale == output.scale
+ return False
+
+
+def _replay_power_scale(
+ source: Fraction,
+ output: Fraction,
+ exponent: Fraction,
+) -> bool:
+ numerator = exponent.numerator
+ denominator = exponent.denominator
+ if numerator >= 0:
+ return output**denominator == source**numerator
+ return output**denominator * source ** (-numerator) == 1
+
+
+def _replay_node(
+ node: Node,
+ values: dict[str, _ModelValue],
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+) -> bool:
+ inputs = tuple(values[value_id] for value_id in node.inputs)
+ output = values[node.output]
+ if node.operation is Operation.IDENTITY:
+ return (
+ inputs[0].dimension == output.dimension
+ and inputs[0].kind is output.kind
+ and _same_transform(inputs[0], output)
+ )
+ if node.operation is Operation.ADD:
+ return _replay_add(inputs[0], inputs[1], output)
+ if node.operation is Operation.SUBTRACT:
+ return _replay_subtract(inputs[0], inputs[1], output)
+ if node.operation in {Operation.MINIMUM, Operation.MAXIMUM}:
+ return (
+ inputs[0].dimension == inputs[1].dimension == output.dimension
+ and inputs[0].kind is inputs[1].kind is output.kind
+ and _same_transform(inputs[0], inputs[1], output)
+ )
+ if node.operation in {Operation.MULTIPLY, Operation.MATMUL}:
+ return (
+ output.dimension == inputs[0].dimension.multiply(inputs[1].dimension)
+ and all(
+ value.kind is not QuantityKind.ABSOLUTE_TEMPERATURE
+ for value in (*inputs, output)
+ )
+ and output.scale == inputs[0].scale * inputs[1].scale
+ )
+ if node.operation is Operation.DIVIDE:
+ return (
+ output.dimension == inputs[0].dimension.divide(inputs[1].dimension)
+ and all(
+ value.kind is not QuantityKind.ABSOLUTE_TEMPERATURE
+ for value in (*inputs, output)
+ )
+ and output.scale * inputs[1].scale == inputs[0].scale
+ )
+ if node.operation is Operation.POWER:
+ assert node.exponent is not None
+ try:
+ expected_dimension = inputs[0].dimension.power(node.exponent)
+ except DimensionError:
+ return False
+ return (
+ output.dimension == expected_dimension
+ and inputs[0].kind is not QuantityKind.ABSOLUTE_TEMPERATURE
+ and output.kind is not QuantityKind.ABSOLUTE_TEMPERATURE
+ and _replay_power_scale(
+ inputs[0].scale,
+ output.scale,
+ node.exponent,
+ )
+ )
+ if node.operation in {
+ Operation.EXP,
+ Operation.LOG,
+ Operation.SIGMOID,
+ Operation.SOFTMAX,
+ }:
+ return all(
+ value.dimension.is_dimensionless
+ and value.kind is QuantityKind.LINEAR
+ and value.scale == 1
+ and value.offset == 0
+ for value in (inputs[0], output)
+ )
+
+ assert node.operation is Operation.CONVERT
+ assert node.target_unit_id is not None
+ target = registry.resolve(node.target_unit_id)
+ output_annotation = graph.value(node.output).unit_id
+ return (
+ inputs[0].dimension == target.dimension
+ and inputs[0].kind is target.kind
+ and _unit_matches(output, target)
+ and (output_annotation is None or output_annotation == target.unit_id)
+ )
+
+
+def _replay_model(
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+ model_values: dict[str, _ModelValue],
+) -> bool:
+ if not all(_coherent_model_value(value) for value in model_values.values()):
+ return False
+ for value_spec in graph.values:
+ if value_spec.unit_id is not None and not _unit_matches(
+ model_values[value_spec.value_id],
+ registry.resolve(value_spec.unit_id),
+ ):
+ return False
+ try:
+ return all(
+ _replay_node(node, model_values, graph, registry) for node in graph.nodes
+ )
+ except (DimensionError, UnitSentinelError):
+ return False
+
+
+def _replay_claimed_contracts(
+ graph: ComputationGraph,
+ registry: UnitRegistry,
+ contracts: tuple[InferredContract, ...],
+) -> bool:
+ """Check one complete assignment without making a uniqueness claim."""
+
+ if type(graph) is not ComputationGraph:
+ raise VerificationError("replay graph must be an exact ComputationGraph")
+ if type(registry) is not UnitRegistry:
+ raise VerificationError("replay registry must be an exact UnitRegistry")
+ if type(contracts) is not tuple:
+ raise VerificationError("replay contracts must be a tuple")
+ try:
+ graph.validate()
+ registry.validate()
+ graph.validate_units(registry)
+ except UnitSentinelError:
+ raise VerificationError(
+ "replay source contract is rejected or mutated"
+ ) from None
+
+ contract_ids: list[str] = []
+ model_values: dict[str, _ModelValue] = {}
+ for contract in contracts:
+ if type(contract) is not InferredContract:
+ raise VerificationError(
+ "replay contracts must contain exact InferredContract values"
+ )
+ try:
+ contract.validate()
+ except UnitSentinelError:
+ raise VerificationError(
+ "replay contract assignment is malformed or mutated"
+ ) from None
+ contract_ids.append(contract.value_id)
+ model_values[contract.value_id] = _ModelValue(
+ dimension=contract.dimension,
+ kind=contract.kind,
+ scale=contract.scale,
+ offset=contract.offset,
+ )
+ if contract_ids != sorted(set(contract_ids)):
+ raise VerificationError("replay contract identifiers must be sorted and unique")
+ expected_ids = tuple(value.value_id for value in graph.values)
+ if tuple(contract_ids) != expected_ids:
+ return False
+ return _replay_model(graph, registry, model_values)
+
+
+def _contracts(
+ value_ids: Sequence[str],
+ model_values: dict[str, _ModelValue],
+) -> tuple[InferredContract, ...]:
+ return tuple(
+ InferredContract(
+ value_id=value_id,
+ dimension=model_values[value_id].dimension,
+ kind=model_values[value_id].kind,
+ scale=model_values[value_id].scale,
+ offset=model_values[value_id].offset,
+ )
+ for value_id in sorted(value_ids)
+ )
+
+
+def _unknown_result(
+ *,
+ graph_digest: str,
+ registry_digest: str,
+ solver_version: str,
+ limits: SolverLimits,
+ checks_performed: int,
+ reason: UnknownReason,
+) -> VerificationResult:
+ return VerificationResult(
+ status=VerificationStatus.UNKNOWN,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=checks_performed,
+ unknown_reason=reason,
+ )
+
+
+def _unknown_from_check(
+ *,
+ check: _CheckResult,
+ graph_digest: str,
+ registry_digest: str,
+ solver_version: str,
+ limits: SolverLimits,
+ checks_performed: int,
+) -> VerificationResult:
+ reason = (
+ UnknownReason.RESOURCE_LIMIT
+ if check.state is _CheckState.RESOURCE_LIMIT
+ else UnknownReason.SOLVER_UNKNOWN
+ )
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=checks_performed,
+ reason=reason,
+ )
+
+
+def _tracked_conflict_seed(
+ problem: _CompiledProblem,
+ solver: Any,
+) -> tuple[_TrackedConstraint, ...]:
+ by_token = {
+ f"tracked_{constraint.track_index:04d}": constraint
+ for constraint in problem.constraints
+ }
+ selected: list[_TrackedConstraint] = []
+ seen: set[str] = set()
+ for token in solver.unsat_core():
+ token_name = token.decl().name()
+ constraint = by_token.get(token_name)
+ if constraint is None or token_name in seen:
+ raise VerificationError("solver returned an invalid tracked core")
+ seen.add(token_name)
+ selected.append(constraint)
+ selected.sort(key=lambda item: item.witness.constraint_id)
+ if not selected:
+ raise VerificationError("solver returned an empty tracked core")
+ return tuple(selected)
+
+
+def _shrink_conflict(
+ seed: tuple[_TrackedConstraint, ...],
+ budget: _CheckBudget,
+ limits: SolverLimits,
+) -> tuple[tuple[_TrackedConstraint, ...], bool]:
+ core = list(seed)
+ index = 0
+ shrink_checks = 0
+ complete = True
+ while index < len(core):
+ if shrink_checks >= limits.max_core_shrink_checks:
+ complete = False
+ break
+ candidate = core[:index] + core[index + 1 :]
+ check = budget.check(candidate)
+ shrink_checks += 1
+ if check.state is _CheckState.UNSAT:
+ core = candidate
+ continue
+ if check.state in {
+ _CheckState.RESOURCE_LIMIT,
+ _CheckState.SOLVER_UNKNOWN,
+ }:
+ complete = False
+ index += 1
+ return tuple(core), complete
+
+
+def _validate_inputs(
+ graph: object,
+ registry: object,
+ limits: object,
+) -> tuple[ComputationGraph, UnitRegistry, SolverLimits]:
+ if type(graph) is not ComputationGraph:
+ raise VerificationError("graph must be an exact ComputationGraph")
+ if type(registry) is not UnitRegistry:
+ raise VerificationError("registry must be an exact UnitRegistry")
+ if type(limits) is not SolverLimits:
+ raise VerificationError("limits must be an exact SolverLimits")
+ try:
+ graph.validate()
+ except UnitSentinelError as error:
+ raise VerificationError("graph contract is malformed or mutated") from error
+ try:
+ registry.validate()
+ except UnitSentinelError as error:
+ raise VerificationError("unit registry is malformed or mutated") from error
+ limits.validate()
+ return graph, registry, limits
+
+
+def verify_graph(
+ graph: ComputationGraph,
+ registry: UnitRegistry = BUILTIN_REGISTRY,
+ limits: SolverLimits = _DEFAULT_SOLVER_LIMITS,
+) -> VerificationResult:
+ """Verify one immutable graph against one immutable registry snapshot.
+
+ ``verified`` means dimensions, quantity kinds, numeric scales, and affine
+ offsets are all uniquely determined and independently replayed. Any solver
+ ambiguity, resource exhaustion, unsupported model value, or invalid unit
+ provenance fails closed.
+ """
+
+ graph, registry, limits = _validate_inputs(graph, registry, limits)
+ graph_digest = graph.digest
+ registry_digest = registry.digest
+ solver_version = z3.get_version_string()
+
+ try:
+ graph.validate_units(registry)
+ except (GraphValidationError, UnitSentinelError):
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=0,
+ reason=UnknownReason.CONTRACT_REJECTED,
+ )
+
+ started_at = time.monotonic()
+ try:
+ context = z3.Context()
+ problem = _compile_problem(graph, registry, context)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=0,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except (UnitSentinelError, z3.Z3Exception):
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=0,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+
+ budget = _CheckBudget(
+ context=problem.context,
+ background=problem.background,
+ limits=limits,
+ started_at=started_at,
+ )
+ initial = budget.check(problem.constraints)
+ if initial.state in {
+ _CheckState.RESOURCE_LIMIT,
+ _CheckState.SOLVER_UNKNOWN,
+ }:
+ return _unknown_from_check(
+ check=initial,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ )
+
+ if initial.state is _CheckState.UNSAT:
+ assert initial.solver is not None
+ try:
+ seed = _tracked_conflict_seed(problem, initial.solver)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except (VerificationError, z3.Z3Exception):
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ if budget.expired:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ core, core_minimal = _shrink_conflict(seed, budget, limits)
+ if budget.expired:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ if not core:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ return VerificationResult(
+ status=VerificationStatus.CONFLICT,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ conflict_core=tuple(constraint.witness for constraint in core),
+ core_minimal=core_minimal,
+ )
+
+ assert initial.solver is not None
+ try:
+ model_values = _extract_model(problem, initial.solver)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except z3.Z3Exception:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.SOLVER_UNKNOWN,
+ )
+ except VerificationError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.MODEL_OUT_OF_DOMAIN,
+ )
+ try:
+ replay_valid = _replay_model(graph, registry, model_values)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ if not replay_valid:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+
+ value_ids = tuple(sorted(problem.terms))
+ uniqueness_checks = 0
+ try:
+ global_difference = _model_difference(problem, model_values, value_ids)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except z3.Z3Exception:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ unique = budget.check(problem.constraints, extra=global_difference)
+ uniqueness_checks += 1
+ if unique.state in {
+ _CheckState.RESOURCE_LIMIT,
+ _CheckState.SOLVER_UNKNOWN,
+ }:
+ return _unknown_from_check(
+ check=unique,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ )
+ if unique.state is _CheckState.UNSAT:
+ try:
+ verified_contracts = _contracts(value_ids, model_values)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except VerificationError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ if budget.expired:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ return VerificationResult(
+ status=VerificationStatus.VERIFIED,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ contracts=verified_contracts,
+ )
+
+ underconstrained: list[str] = []
+ uniquely_inferred: list[str] = []
+ for value_id in value_ids:
+ if uniqueness_checks >= limits.max_uniqueness_checks:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ try:
+ value_difference = _model_difference(
+ problem,
+ model_values,
+ (value_id,),
+ )
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except z3.Z3Exception:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ classification = budget.check(
+ problem.constraints,
+ extra=value_difference,
+ )
+ uniqueness_checks += 1
+ if classification.state in {
+ _CheckState.RESOURCE_LIMIT,
+ _CheckState.SOLVER_UNKNOWN,
+ }:
+ return _unknown_from_check(
+ check=classification,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ )
+ if classification.state is _CheckState.SAT:
+ underconstrained.append(value_id)
+ else:
+ uniquely_inferred.append(value_id)
+
+ if not underconstrained:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ try:
+ inferred_contracts = _contracts(uniquely_inferred, model_values)
+ except MemoryError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ except VerificationError:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.INTERNAL_INCONSISTENCY,
+ )
+ if budget.expired:
+ return _unknown_result(
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ return VerificationResult(
+ status=VerificationStatus.UNDERCONSTRAINED,
+ graph_digest=graph_digest,
+ registry_digest=registry_digest,
+ solver_version=solver_version,
+ limits=limits,
+ checks_performed=budget.checks_performed,
+ contracts=inferred_contracts,
+ underconstrained_values=tuple(underconstrained),
+ )
diff --git a/src/unitsentinel/version.py b/src/unitsentinel/version.py
new file mode 100644
index 0000000..1bc9b90
--- /dev/null
+++ b/src/unitsentinel/version.py
@@ -0,0 +1,5 @@
+"""Single source of truth for the package implementation version."""
+
+from typing import Final
+
+VERSION: Final = "0.1.0"
diff --git a/tests/test_certificate.py b/tests/test_certificate.py
new file mode 100644
index 0000000..e6f9c4f
--- /dev/null
+++ b/tests/test_certificate.py
@@ -0,0 +1,593 @@
+from __future__ import annotations
+
+import unittest
+from dataclasses import FrozenInstanceError
+from fractions import Fraction
+from typing import cast
+from unittest.mock import patch
+
+from examples.build_speed_contract import build_graph
+from unitsentinel import certificate as certificate_module
+from unitsentinel.certificate import (
+ CERTIFICATE_SCHEMA,
+ MAX_CERTIFICATE_CHECKS,
+ SOLVER_IMPLEMENTATION,
+ VERIFIER_IMPLEMENTATION,
+ VERIFIER_SEMANTICS,
+ CertificateError,
+ ProofCertificate,
+ create_certificate,
+ encode_certificate,
+)
+from unitsentinel.domain import LENGTH, Unit
+from unitsentinel.graph import (
+ GRAPH_SCHEMA,
+ ComputationGraph,
+ Node,
+ Operation,
+ ScalarType,
+ ValueSpec,
+)
+from unitsentinel.registry import BUILTIN_REGISTRY, REGISTRY_SCHEMA, UnitRegistry
+from unitsentinel.verification import (
+ SolverLimits,
+ VerificationError,
+ VerificationResult,
+ VerificationStatus,
+)
+from unitsentinel.verifier import constraint_catalog, verify_graph
+from unitsentinel.version import VERSION
+
+
+def ambiguous_graph() -> ComputationGraph:
+ return ComputationGraph(
+ graph_id="ambiguous-identity",
+ values=(
+ ValueSpec("input", ScalarType.FLOAT64, ()),
+ ValueSpec("output", ScalarType.FLOAT64, ()),
+ ),
+ inputs=("input",),
+ nodes=(
+ Node(
+ "copy-input",
+ Operation.IDENTITY,
+ ("input",),
+ "output",
+ ),
+ ),
+ outputs=("output",),
+ )
+
+
+class ConstraintCatalogTests(unittest.TestCase):
+ def test_catalog_matches_the_compiler_source_manifest(self) -> None:
+ graph = build_graph()
+
+ catalog = constraint_catalog(graph)
+
+ self.assertEqual(
+ tuple(witness.constraint_id for witness in catalog),
+ (
+ "declaration/raw-speed/unit",
+ "declaration/si-speed/unit",
+ "operation/normalize-speed/dimension",
+ "operation/normalize-speed/kind",
+ "operation/normalize-speed/unit-transform",
+ ),
+ )
+ self.assertEqual(
+ tuple(witness.rule for witness in catalog),
+ (
+ "unit-annotation",
+ "unit-annotation",
+ "convert-dimension",
+ "convert-kind",
+ "convert-unit-transform",
+ ),
+ )
+
+ def test_catalog_rejects_wrong_and_mutated_graphs(self) -> None:
+ with self.assertRaisesRegex(VerificationError, "exact ComputationGraph"):
+ constraint_catalog("graph") # type: ignore[arg-type]
+
+ graph = build_graph()
+ object.__setattr__(graph.values[0], "unit_id", "meter-per-second")
+ with self.assertRaisesRegex(VerificationError, "rejected or mutated"):
+ constraint_catalog(graph)
+
+ def test_catalog_honors_the_supplied_registry(self) -> None:
+ registry = UnitRegistry(
+ version="1.0.0",
+ units=(Unit("smoot", "smoot", LENGTH, Fraction(17_018, 10_000)),),
+ )
+ graph = ComputationGraph(
+ graph_id="custom-length",
+ values=(ValueSpec("distance", ScalarType.FLOAT64, (), "smoot"),),
+ inputs=("distance",),
+ nodes=(),
+ outputs=("distance",),
+ )
+
+ catalog = constraint_catalog(graph, registry)
+ certificate = create_certificate(graph, registry)
+
+ self.assertEqual(
+ tuple(witness.constraint_id for witness in catalog),
+ ("declaration/distance/unit",),
+ )
+ self.assertEqual(certificate.constraints, catalog)
+ self.assertEqual(certificate.result.registry_digest, registry.digest)
+ self.assertEqual(certificate.registry_version, registry.version)
+
+
+class ProofCertificateTests(unittest.TestCase):
+ def test_internal_attempt_verifies_once_and_preserves_negative_results(
+ self,
+ ) -> None:
+ positive_graph = build_graph()
+ real_verify = certificate_module.verify_graph
+ with patch.object(
+ certificate_module,
+ "verify_graph",
+ wraps=real_verify,
+ ) as verify:
+ positive, certificate = certificate_module._create_certificate_attempt(
+ positive_graph
+ )
+
+ self.assertEqual(verify.call_count, 1)
+ self.assertIsNotNone(certificate)
+ self.assertIs(certificate.result, positive)
+
+ negative_graph = ambiguous_graph()
+ with patch.object(
+ certificate_module,
+ "verify_graph",
+ wraps=real_verify,
+ ) as verify:
+ negative, certificate = certificate_module._create_certificate_attempt(
+ negative_graph
+ )
+
+ self.assertEqual(verify.call_count, 1)
+ self.assertEqual(negative.status, VerificationStatus.UNDERCONSTRAINED)
+ self.assertIsNone(certificate)
+
+ def test_positive_certificate_is_canonical_and_content_addressed(self) -> None:
+ graph = build_graph()
+
+ certificate = create_certificate(graph)
+ record = certificate.canonical_record()
+
+ self.assertEqual(certificate.result.status, VerificationStatus.VERIFIED)
+ self.assertEqual(record["schema"], CERTIFICATE_SCHEMA)
+ self.assertEqual(
+ record["graph"],
+ {"schema": GRAPH_SCHEMA, "sha256": graph.digest},
+ )
+ self.assertEqual(
+ record["registry"],
+ {
+ "schema": REGISTRY_SCHEMA,
+ "sha256": BUILTIN_REGISTRY.digest,
+ "version": BUILTIN_REGISTRY.version,
+ },
+ )
+ self.assertEqual(
+ record["solver"],
+ {
+ "implementation": SOLVER_IMPLEMENTATION,
+ "version": "4.16.0",
+ },
+ )
+ self.assertEqual(
+ record["verifier"],
+ {
+ "implementation": VERIFIER_IMPLEMENTATION,
+ "semantics": VERIFIER_SEMANTICS,
+ "version": VERSION,
+ },
+ )
+ proof = record["proof"]
+ self.assertIs(type(proof), dict)
+ proof_record = cast(dict[str, object], proof)
+ self.assertEqual(proof_record["outcome"], "verified")
+ self.assertEqual(
+ proof_record["verification_result_sha256"],
+ certificate.result.digest,
+ )
+ contracts = cast(list[dict[str, object]], proof_record["contracts"])
+ self.assertIs(type(contracts), list)
+ self.assertEqual(contracts[0]["scale"], "5/18")
+ self.assertEqual(contracts[1]["scale"], "1")
+ run_record = cast(dict[str, object], record["run"])
+ self.assertEqual(run_record["checks_performed"], 2)
+ self.assertEqual(encode_certificate(certificate), certificate.canonical_bytes())
+ self.assertEqual(certificate.canonical_bytes()[-1:], b"}")
+ self.assertEqual(len(certificate.canonical_bytes()), 1_721)
+ self.assertEqual(
+ certificate.digest,
+ "c5ab1819a73c57111e3977fc69a109a11d43cfa0456e91e3eb38f862779f81b7",
+ )
+
+ repeated = create_certificate(graph)
+ self.assertEqual(certificate.canonical_bytes(), repeated.canonical_bytes())
+ self.assertEqual(certificate.digest, repeated.digest)
+
+ def test_nonverified_graph_never_receives_a_positive_certificate(self) -> None:
+ graph = ambiguous_graph()
+ result = verify_graph(graph)
+ self.assertEqual(result.status, VerificationStatus.UNDERCONSTRAINED)
+
+ with self.assertRaisesRegex(
+ CertificateError,
+ "requires verified, got underconstrained",
+ ):
+ create_certificate(graph)
+
+ def test_registry_mutation_during_verification_blocks_issuance(self) -> None:
+ graph = build_graph()
+ registry = UnitRegistry(
+ BUILTIN_REGISTRY.version,
+ BUILTIN_REGISTRY.units,
+ BUILTIN_REGISTRY.aliases,
+ )
+ real_verify = certificate_module.verify_graph
+
+ def mutate_registry_after_verification(
+ candidate: ComputationGraph,
+ *,
+ registry: UnitRegistry,
+ limits: SolverLimits,
+ ) -> VerificationResult:
+ result = real_verify(candidate, registry=registry, limits=limits)
+ object.__setattr__(registry, "version", "9.9.9")
+ return result
+
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ side_effect=mutate_registry_after_verification,
+ ),
+ self.assertRaisesRegex(
+ CertificateError,
+ "source changed during verification",
+ ),
+ ):
+ create_certificate(graph, registry)
+
+ def test_graph_and_limits_mutation_during_verification_blocks_issuance(
+ self,
+ ) -> None:
+ graph = build_graph()
+ limits = SolverLimits()
+ real_verify = certificate_module.verify_graph
+
+ def mutate_graph_after_verification(
+ candidate: ComputationGraph,
+ *,
+ registry: UnitRegistry,
+ limits: SolverLimits,
+ ) -> VerificationResult:
+ result = real_verify(candidate, registry=registry, limits=limits)
+ object.__setattr__(candidate, "graph_id", "changed-speed-contract")
+ return result
+
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ side_effect=mutate_graph_after_verification,
+ ),
+ self.assertRaisesRegex(
+ CertificateError,
+ "source changed during verification",
+ ),
+ ):
+ create_certificate(graph, limits=limits)
+
+ graph = build_graph()
+
+ def mutate_limits_after_verification(
+ candidate: ComputationGraph,
+ *,
+ registry: UnitRegistry,
+ limits: SolverLimits,
+ ) -> VerificationResult:
+ result = real_verify(candidate, registry=registry, limits=limits)
+ object.__setattr__(limits, "total_timeout_ms", 6_000)
+ return result
+
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ side_effect=mutate_limits_after_verification,
+ ),
+ self.assertRaisesRegex(
+ CertificateError,
+ "source changed during verification",
+ ),
+ ):
+ create_certificate(graph, limits=limits)
+
+ def test_certificate_value_requires_exact_sorted_positive_evidence(
+ self,
+ ) -> None:
+ certificate = create_certificate(build_graph())
+
+ class DerivedCertificate(ProofCertificate):
+ pass
+
+ with self.assertRaisesRegex(CertificateError, "exact ProofCertificate"):
+ DerivedCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ certificate.result,
+ )
+ with self.assertRaisesRegex(CertificateError, "canonical SemVer"):
+ ProofCertificate(
+ "latest",
+ certificate.verifier_version,
+ certificate.constraints,
+ certificate.result,
+ )
+ with self.assertRaisesRegex(CertificateError, "must be a tuple"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ list(certificate.constraints), # type: ignore[arg-type]
+ certificate.result,
+ )
+ with self.assertRaisesRegex(CertificateError, "cannot be empty"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ (),
+ certificate.result,
+ )
+ with self.assertRaisesRegex(CertificateError, "sorted and unique"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ tuple(reversed(certificate.constraints)),
+ certificate.result,
+ )
+ with self.assertRaisesRegex(CertificateError, "exact VerificationResult"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ "result", # type: ignore[arg-type]
+ )
+ with self.assertRaisesRegex(CertificateError, "verified result"):
+ ProofCertificate(
+ BUILTIN_REGISTRY.version,
+ VERSION,
+ constraint_catalog(ambiguous_graph()),
+ verify_graph(ambiguous_graph()),
+ )
+
+ def test_certificate_enforces_manifest_and_contract_bounds(self) -> None:
+ certificate = create_certificate(build_graph())
+
+ with (
+ patch.object(certificate_module, "MAX_CERTIFICATE_CONSTRAINTS", 0),
+ self.assertRaisesRegex(CertificateError, "manifest exceeds"),
+ ):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ certificate.result,
+ )
+ with (
+ patch.object(certificate_module, "MAX_CERTIFICATE_CONTRACTS", 0),
+ self.assertRaisesRegex(CertificateError, "contracts exceed"),
+ ):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ certificate.result,
+ )
+
+ def test_certificate_bounds_solver_provenance(self) -> None:
+ certificate = create_certificate(build_graph())
+ result = certificate.result
+ long_version = VerificationResult(
+ status=result.status,
+ graph_digest=result.graph_digest,
+ registry_digest=result.registry_digest,
+ solver_version="1.0." + ("0" * 29),
+ limits=result.limits,
+ checks_performed=result.checks_performed,
+ contracts=result.contracts,
+ )
+ with self.assertRaisesRegex(CertificateError, "solver version exceeds"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ long_version,
+ )
+
+ too_many_checks = VerificationResult(
+ status=result.status,
+ graph_digest=result.graph_digest,
+ registry_digest=result.registry_digest,
+ solver_version=result.solver_version,
+ limits=result.limits,
+ checks_performed=MAX_CERTIFICATE_CHECKS + 1,
+ contracts=result.contracts,
+ )
+ with self.assertRaisesRegex(CertificateError, "check count exceeds"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ too_many_checks,
+ )
+
+ def test_certificate_rejects_invalid_nested_witnesses(self) -> None:
+ certificate = create_certificate(build_graph())
+ with self.assertRaisesRegex(CertificateError, "exact witnesses"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ ("witness",), # type: ignore[arg-type]
+ certificate.result,
+ )
+
+ certificate = create_certificate(build_graph())
+ object.__setattr__(certificate.constraints[0], "constraint_id", "INVALID")
+ with self.assertRaisesRegex(CertificateError, "invalid constraint witness"):
+ ProofCertificate(
+ certificate.registry_version,
+ certificate.verifier_version,
+ certificate.constraints,
+ certificate.result,
+ )
+
+ def test_certificate_digest_detects_malformed_and_changed_claims(self) -> None:
+ certificate = create_certificate(build_graph())
+ object.__setattr__(certificate, "_digest", "not-a-digest")
+ with self.assertRaisesRegex(CertificateError, "digest is malformed"):
+ certificate.validate()
+
+ certificate = create_certificate(build_graph())
+ object.__setattr__(certificate, "verifier_version", "0.1.1")
+ with self.assertRaisesRegex(CertificateError, "does not match"):
+ certificate.validate()
+
+ def test_nested_mutation_invalidates_certificate_evidence(self) -> None:
+ certificate = create_certificate(build_graph())
+ object.__setattr__(
+ certificate.result.contracts[0],
+ "scale",
+ Fraction(1),
+ )
+
+ with self.assertRaisesRegex(CertificateError, "verification result"):
+ certificate.canonical_record()
+ with self.assertRaisesRegex(CertificateError, "encoding failed"):
+ encode_certificate(certificate)
+
+ def test_encoder_and_values_are_exact_and_immutable(self) -> None:
+ certificate = create_certificate(build_graph())
+
+ with self.assertRaisesRegex(CertificateError, "exact ProofCertificate"):
+ encode_certificate("certificate") # type: ignore[arg-type]
+ with self.assertRaises(FrozenInstanceError):
+ certificate.registry_version = "2.0.0" # type: ignore[misc]
+
+ def test_factory_rejects_wrong_and_mutated_sources(self) -> None:
+ graph = build_graph()
+ with self.assertRaisesRegex(CertificateError, "exact ComputationGraph"):
+ create_certificate("graph") # type: ignore[arg-type]
+ with self.assertRaisesRegex(CertificateError, "exact UnitRegistry"):
+ create_certificate(graph, "registry") # type: ignore[arg-type]
+ with self.assertRaisesRegex(CertificateError, "exact SolverLimits"):
+ create_certificate(graph, limits="limits") # type: ignore[arg-type]
+
+ object.__setattr__(graph.values[0], "unit_id", "meter-per-second")
+ with self.assertRaisesRegex(CertificateError, "rejected or mutated"):
+ create_certificate(graph)
+
+ def test_factory_wraps_verifier_and_certificate_failures(self) -> None:
+ graph = build_graph()
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ side_effect=VerificationError("injected verifier failure"),
+ ),
+ self.assertRaisesRegex(CertificateError, "verification failed"),
+ ):
+ create_certificate(graph)
+
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ return_value=object(),
+ ),
+ self.assertRaisesRegex(CertificateError, "invalid result"),
+ ):
+ create_certificate(graph)
+
+ with (
+ patch.object(
+ certificate_module,
+ "ProofCertificate",
+ side_effect=CertificateError("injected constructor failure"),
+ ),
+ self.assertRaisesRegex(CertificateError, "could not be certified"),
+ ):
+ create_certificate(graph)
+
+ def test_factory_revalidates_sources_after_certificate_construction(
+ self,
+ ) -> None:
+ graph = build_graph()
+ real_validate = ProofCertificate.validate
+
+ def mutate_graph_after_validation(certificate: ProofCertificate) -> None:
+ real_validate(certificate)
+ object.__setattr__(graph, "graph_id", "changed-after-certification")
+
+ with (
+ patch.object(
+ ProofCertificate,
+ "validate",
+ new=mutate_graph_after_validation,
+ ),
+ self.assertRaisesRegex(CertificateError, "during certification"),
+ ):
+ create_certificate(graph)
+
+ def test_factory_rejects_results_for_another_or_partial_graph(self) -> None:
+ graph = build_graph()
+ other_graph = ComputationGraph(
+ graph_id="other-speed-contract",
+ values=graph.values,
+ inputs=graph.inputs,
+ nodes=graph.nodes,
+ outputs=graph.outputs,
+ )
+ other_result = verify_graph(other_graph)
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ return_value=other_result,
+ ),
+ self.assertRaisesRegex(CertificateError, "source changed"),
+ ):
+ create_certificate(graph)
+
+ full_result = verify_graph(graph)
+ partial_result = VerificationResult(
+ status=full_result.status,
+ graph_digest=full_result.graph_digest,
+ registry_digest=full_result.registry_digest,
+ solver_version=full_result.solver_version,
+ limits=full_result.limits,
+ checks_performed=full_result.checks_performed,
+ contracts=full_result.contracts[:1],
+ )
+ with (
+ patch.object(
+ certificate_module,
+ "verify_graph",
+ return_value=partial_result,
+ ),
+ self.assertRaisesRegex(CertificateError, "cover every graph value"),
+ ):
+ create_certificate(graph)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_certificate_codec.py b/tests/test_certificate_codec.py
new file mode 100644
index 0000000..a1677c2
--- /dev/null
+++ b/tests/test_certificate_codec.py
@@ -0,0 +1,442 @@
+from __future__ import annotations
+
+import copy
+import unittest
+from collections.abc import Callable
+from typing import cast
+
+from examples.build_speed_contract import build_graph
+from unitsentinel.canonical import canonical_json_bytes
+from unitsentinel.certificate import (
+ MAX_CERTIFICATE_BYTES,
+ MAX_CERTIFICATE_CHECKS,
+ MAX_CERTIFICATE_SOLVER_VERSION_LENGTH,
+ CertificateDecodeError,
+ ProofCertificate,
+ create_certificate,
+ decode_certificate,
+ encode_certificate,
+)
+from unitsentinel.verification import VerificationResult
+from unitsentinel.version import VERSION
+
+CertificateRecord = dict[str, object]
+RecordMutation = Callable[[CertificateRecord], None]
+
+
+def certificate_record() -> CertificateRecord:
+ return create_certificate(build_graph()).canonical_record()
+
+
+def nested(record: CertificateRecord, field: str) -> CertificateRecord:
+ return cast(CertificateRecord, record[field])
+
+
+def items(record: CertificateRecord, field: str) -> list[object]:
+ return cast(list[object], record[field])
+
+
+def encoded_mutation(mutation: RecordMutation) -> bytes:
+ record = copy.deepcopy(certificate_record())
+ mutation(record)
+ return canonical_json_bytes(record)
+
+
+class CertificateBoundaryTests(unittest.TestCase):
+ def test_decoder_round_trips_exact_canonical_bytes(self) -> None:
+ issued = create_certificate(build_graph())
+ payload = encode_certificate(issued)
+
+ decoded = decode_certificate(payload)
+
+ self.assertEqual(decoded, issued)
+ self.assertEqual(decoded.digest, issued.digest)
+ self.assertEqual(decoded.result.digest, issued.result.digest)
+ self.assertEqual(decoded.canonical_bytes(), payload)
+ self.assertEqual(decode_certificate(payload), decoded)
+
+ def test_decoder_round_trips_the_model_boundary(self) -> None:
+ issued = create_certificate(build_graph())
+ source = issued.result
+ solver_version = "1.0." + ("0" * 28)
+ self.assertEqual(
+ len(solver_version),
+ MAX_CERTIFICATE_SOLVER_VERSION_LENGTH,
+ )
+ boundary_result = VerificationResult(
+ status=source.status,
+ graph_digest=source.graph_digest,
+ registry_digest=source.registry_digest,
+ solver_version=solver_version,
+ limits=source.limits,
+ checks_performed=MAX_CERTIFICATE_CHECKS,
+ contracts=source.contracts,
+ )
+ boundary_claim = ProofCertificate(
+ registry_version=issued.registry_version,
+ verifier_version=VERSION,
+ constraints=issued.constraints,
+ result=boundary_result,
+ )
+
+ decoded = decode_certificate(boundary_claim.canonical_bytes())
+
+ self.assertEqual(decoded, boundary_claim)
+
+ def test_decoder_rejects_unsafe_or_noncanonical_json(self) -> None:
+ payload = encode_certificate(create_certificate(build_graph()))
+ cases: tuple[tuple[str, object, str], ...] = (
+ ("wrong-type", "certificate", "exact bytes"),
+ ("empty", b"", "payload is empty"),
+ ("trailing-newline", payload + b"\n", "not canonical JSON"),
+ (
+ "duplicate-key",
+ b'{"schema":"first","schema":"second"}',
+ "duplicate object key",
+ ),
+ (
+ "oversized",
+ b"0" * (MAX_CERTIFICATE_BYTES + 1),
+ "byte limit",
+ ),
+ )
+
+ for name, candidate, message in cases:
+ with (
+ self.subTest(case=name),
+ self.assertRaisesRegex(CertificateDecodeError, message),
+ ):
+ decode_certificate(candidate) # type: ignore[arg-type]
+
+ def test_decoder_requires_exact_root_and_identity_records(self) -> None:
+ def add_extension(record: CertificateRecord) -> None:
+ record["extension"] = None
+
+ def wrong_schema(record: CertificateRecord) -> None:
+ record["schema"] = "unitsentinel.proof-certificate/v2"
+
+ def wrong_graph_shape(record: CertificateRecord) -> None:
+ record["graph"] = []
+
+ def wrong_graph_schema(record: CertificateRecord) -> None:
+ nested(record, "graph")["schema"] = "unitsentinel.graph/v2"
+
+ def malformed_graph_digest(record: CertificateRecord) -> None:
+ nested(record, "graph")["sha256"] = "0"
+
+ def wrong_registry_shape(record: CertificateRecord) -> None:
+ record["registry"] = []
+
+ def wrong_registry_schema(record: CertificateRecord) -> None:
+ nested(record, "registry")["schema"] = "registry/v2"
+
+ def wrong_registry_version_type(record: CertificateRecord) -> None:
+ nested(record, "registry")["version"] = 1
+
+ def wrong_solver_shape(record: CertificateRecord) -> None:
+ record["solver"] = []
+
+ def wrong_solver_implementation(record: CertificateRecord) -> None:
+ nested(record, "solver")["implementation"] = "other"
+
+ def wrong_solver_version_type(record: CertificateRecord) -> None:
+ nested(record, "solver")["version"] = 4
+
+ def wrong_verifier_shape(record: CertificateRecord) -> None:
+ record["verifier"] = []
+
+ def wrong_verifier_implementation(record: CertificateRecord) -> None:
+ nested(record, "verifier")["implementation"] = "other"
+
+ def wrong_verifier_semantics(record: CertificateRecord) -> None:
+ nested(record, "verifier")["semantics"] = "unitsentinel.verifier/v2"
+
+ def wrong_verifier_version_type(record: CertificateRecord) -> None:
+ nested(record, "verifier")["version"] = 1
+
+ cases: tuple[tuple[str, RecordMutation, str], ...] = (
+ ("extension", add_extension, "missing or unknown fields"),
+ ("schema", wrong_schema, "certificate schema is not supported"),
+ ("graph-shape", wrong_graph_shape, "graph binding must be an object"),
+ ("graph-schema", wrong_graph_schema, "graph schema is not supported"),
+ ("graph-digest", malformed_graph_digest, "graph digest is malformed"),
+ (
+ "registry-shape",
+ wrong_registry_shape,
+ "registry binding must be an object",
+ ),
+ (
+ "registry-schema",
+ wrong_registry_schema,
+ "registry schema is not supported",
+ ),
+ (
+ "registry-version",
+ wrong_registry_version_type,
+ "registry version must be text",
+ ),
+ ("solver-shape", wrong_solver_shape, "solver identity must be an object"),
+ (
+ "solver-implementation",
+ wrong_solver_implementation,
+ "solver implementation is not supported",
+ ),
+ (
+ "solver-version",
+ wrong_solver_version_type,
+ "solver version must be text",
+ ),
+ (
+ "verifier-shape",
+ wrong_verifier_shape,
+ "verifier identity must be an object",
+ ),
+ (
+ "verifier-implementation",
+ wrong_verifier_implementation,
+ "verifier implementation is not supported",
+ ),
+ (
+ "verifier-semantics",
+ wrong_verifier_semantics,
+ "verifier semantics is not supported",
+ ),
+ (
+ "verifier-version",
+ wrong_verifier_version_type,
+ "verifier version must be text",
+ ),
+ )
+
+ for name, mutation, message in cases:
+ with (
+ self.subTest(case=name),
+ self.assertRaisesRegex(CertificateDecodeError, message),
+ ):
+ decode_certificate(encoded_mutation(mutation))
+
+ def test_decoder_requires_exact_run_and_proof_records(self) -> None:
+ def wrong_run_shape(record: CertificateRecord) -> None:
+ record["run"] = []
+
+ def wrong_limit_shape(record: CertificateRecord) -> None:
+ nested(record, "run")["limits"] = []
+
+ def bool_check_count(record: CertificateRecord) -> None:
+ nested(record, "run")["checks_performed"] = True
+
+ def bool_timeout(record: CertificateRecord) -> None:
+ limits = nested(nested(record, "run"), "limits")
+ limits["per_check_timeout_ms"] = True
+
+ def invalid_timeout(record: CertificateRecord) -> None:
+ limits = nested(nested(record, "run"), "limits")
+ limits["per_check_timeout_ms"] = 0
+
+ def wrong_proof_shape(record: CertificateRecord) -> None:
+ record["proof"] = []
+
+ def wrong_outcome(record: CertificateRecord) -> None:
+ nested(record, "proof")["outcome"] = "conflict"
+
+ def malformed_result_digest(record: CertificateRecord) -> None:
+ nested(record, "proof")["verification_result_sha256"] = "0"
+
+ def mismatched_result_digest(record: CertificateRecord) -> None:
+ nested(record, "proof")["verification_result_sha256"] = "0" * 64
+
+ def wrong_constraint_array(record: CertificateRecord) -> None:
+ nested(record, "proof")["constraints"] = {}
+
+ def wrong_contract_array(record: CertificateRecord) -> None:
+ nested(record, "proof")["contracts"] = {}
+
+ cases: tuple[tuple[str, RecordMutation, str], ...] = (
+ ("run-shape", wrong_run_shape, "verification run must be an object"),
+ ("limits-shape", wrong_limit_shape, "solver limits must be an object"),
+ ("check-bool", bool_check_count, "solver check count must be an exact"),
+ ("limit-bool", bool_timeout, "per-check timeout must be an exact"),
+ ("limit-range", invalid_timeout, "semantic contract failed"),
+ ("proof-shape", wrong_proof_shape, "proof claim must be an object"),
+ ("outcome", wrong_outcome, "proof outcome is not supported"),
+ (
+ "result-digest-shape",
+ malformed_result_digest,
+ "verification-result digest is malformed",
+ ),
+ (
+ "result-digest-value",
+ mismatched_result_digest,
+ "does not match its contents",
+ ),
+ (
+ "constraint-array",
+ wrong_constraint_array,
+ "proof constraints must be an array",
+ ),
+ (
+ "contract-array",
+ wrong_contract_array,
+ "proof contracts must be an array",
+ ),
+ )
+
+ for name, mutation, message in cases:
+ with (
+ self.subTest(case=name),
+ self.assertRaisesRegex(CertificateDecodeError, message),
+ ):
+ decode_certificate(encoded_mutation(mutation))
+
+ def test_decoder_rejects_malformed_constraint_claims(self) -> None:
+ def first_constraint(record: CertificateRecord) -> CertificateRecord:
+ proof = nested(record, "proof")
+ return cast(CertificateRecord, items(proof, "constraints")[0])
+
+ def wrong_shape(record: CertificateRecord) -> None:
+ items(nested(record, "proof"), "constraints")[0] = []
+
+ def extension(record: CertificateRecord) -> None:
+ first_constraint(record)["extension"] = None
+
+ def wrong_source_type(record: CertificateRecord) -> None:
+ first_constraint(record)["source"] = 1
+
+ def unknown_source(record: CertificateRecord) -> None:
+ first_constraint(record)["source"] = "unknown"
+
+ def wrong_id_type(record: CertificateRecord) -> None:
+ first_constraint(record)["constraint_id"] = 1
+
+ cases: tuple[tuple[str, RecordMutation, str], ...] = (
+ ("shape", wrong_shape, "constraint witness must be an object"),
+ ("extension", extension, "missing or unknown fields"),
+ ("source-type", wrong_source_type, "constraint source must be text"),
+ ("source-value", unknown_source, "constraint source is not supported"),
+ ("identifier-type", wrong_id_type, "constraint identifier must be text"),
+ )
+
+ for name, mutation, message in cases:
+ with (
+ self.subTest(case=name),
+ self.assertRaisesRegex(CertificateDecodeError, message),
+ ):
+ decode_certificate(encoded_mutation(mutation))
+
+ def test_decoder_rejects_malformed_contract_claims(self) -> None:
+ def first_contract(record: CertificateRecord) -> CertificateRecord:
+ proof = nested(record, "proof")
+ return cast(CertificateRecord, items(proof, "contracts")[0])
+
+ def first_term(record: CertificateRecord) -> CertificateRecord:
+ contract = first_contract(record)
+ return cast(CertificateRecord, items(contract, "dimension")[0])
+
+ def wrong_shape(record: CertificateRecord) -> None:
+ items(nested(record, "proof"), "contracts")[0] = []
+
+ def extension(record: CertificateRecord) -> None:
+ first_contract(record)["extension"] = None
+
+ def wrong_kind_type(record: CertificateRecord) -> None:
+ first_contract(record)["kind"] = 1
+
+ def unknown_kind(record: CertificateRecord) -> None:
+ first_contract(record)["kind"] = "unknown"
+
+ def wrong_value_id_type(record: CertificateRecord) -> None:
+ first_contract(record)["value_id"] = 1
+
+ def wrong_dimension_shape(record: CertificateRecord) -> None:
+ first_contract(record)["dimension"] = {}
+
+ def wrong_term_shape(record: CertificateRecord) -> None:
+ items(first_contract(record), "dimension")[0] = []
+
+ def wrong_base_type(record: CertificateRecord) -> None:
+ first_term(record)["base"] = 1
+
+ def unknown_base(record: CertificateRecord) -> None:
+ first_term(record)["base"] = "currency"
+
+ def duplicate_base(record: CertificateRecord) -> None:
+ terms = items(first_contract(record), "dimension")
+ terms.append(copy.deepcopy(terms[0]))
+
+ def wrong_rational_type(record: CertificateRecord) -> None:
+ first_contract(record)["scale"] = 1
+
+ def malformed_rational(record: CertificateRecord) -> None:
+ first_contract(record)["scale"] = "01"
+
+ def unreduced_rational(record: CertificateRecord) -> None:
+ first_contract(record)["scale"] = "10/36"
+
+ def invalid_semantics(record: CertificateRecord) -> None:
+ first_contract(record)["scale"] = "0"
+
+ cases: tuple[tuple[str, RecordMutation, str], ...] = (
+ ("shape", wrong_shape, "inferred contract must be an object"),
+ ("extension", extension, "missing or unknown fields"),
+ ("kind-type", wrong_kind_type, "inferred quantity kind must be text"),
+ ("kind-value", unknown_kind, "quantity kind is not supported"),
+ ("value-id", wrong_value_id_type, "inferred value identifier must be text"),
+ ("dimension-shape", wrong_dimension_shape, "dimension must be an array"),
+ ("term-shape", wrong_term_shape, "dimension term must be an object"),
+ ("base-type", wrong_base_type, "dimension base must be text"),
+ ("base-value", unknown_base, "dimension base is not supported"),
+ ("duplicate-base", duplicate_base, "duplicate base"),
+ ("rational-type", wrong_rational_type, "inferred scale must be text"),
+ ("rational-shape", malformed_rational, "not a canonical rational"),
+ ("rational-reduction", unreduced_rational, "is not reduced"),
+ ("semantic-contract", invalid_semantics, "semantic contract failed"),
+ )
+
+ for name, mutation, message in cases:
+ with (
+ self.subTest(case=name),
+ self.assertRaisesRegex(CertificateDecodeError, message),
+ ):
+ decode_certificate(encoded_mutation(mutation))
+
+ def test_decoder_rejects_semantically_equivalent_noncanonical_model(self) -> None:
+ def reverse_dimension(record: CertificateRecord) -> None:
+ proof = nested(record, "proof")
+ contract = cast(CertificateRecord, items(proof, "contracts")[0])
+ items(contract, "dimension").reverse()
+
+ with self.assertRaisesRegex(
+ CertificateDecodeError,
+ "does not match the canonical certificate model",
+ ):
+ decode_certificate(encoded_mutation(reverse_dimension))
+
+ def test_decoder_accepts_an_internally_coherent_unsigned_partial_claim(
+ self,
+ ) -> None:
+ issued = create_certificate(build_graph())
+ source = issued.result
+ partial_result = VerificationResult(
+ status=source.status,
+ graph_digest=source.graph_digest,
+ registry_digest=source.registry_digest,
+ solver_version=source.solver_version,
+ limits=source.limits,
+ checks_performed=source.checks_performed,
+ contracts=source.contracts[:1],
+ )
+ unsigned_claim = ProofCertificate(
+ registry_version=issued.registry_version,
+ verifier_version=VERSION,
+ constraints=issued.constraints,
+ result=partial_result,
+ )
+
+ decoded = decode_certificate(unsigned_claim.canonical_bytes())
+
+ self.assertEqual(decoded, unsigned_claim)
+ self.assertEqual(len(decoded.result.contracts), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..08cc818
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,864 @@
+from __future__ import annotations
+
+import io
+import json
+import os
+import runpy
+import stat
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from examples.build_speed_contract import build_graph
+from unitsentinel import cli
+from unitsentinel.canonical import canonical_json_bytes, sha256_hex
+from unitsentinel.certificate import (
+ MAX_CERTIFICATE_BYTES,
+ CertificateError,
+ ProofCertificate,
+ create_certificate,
+ encode_certificate,
+)
+from unitsentinel.domain import UnitSentinelError
+from unitsentinel.graph import ComputationGraph, Node, Operation, ScalarType, ValueSpec
+from unitsentinel.graph_codec import MAX_GRAPH_BYTES, encode_graph
+from unitsentinel.registry import BUILTIN_REGISTRY
+from unitsentinel.replay import (
+ CertificateReplay,
+ CertificateReplayError,
+ ReplayReason,
+ ReplayStatus,
+ replay_certificate,
+)
+from unitsentinel.verification import (
+ SolverLimits,
+ UnknownReason,
+ VerificationResult,
+ VerificationStatus,
+)
+from unitsentinel.version import VERSION
+
+
+def ambiguous_graph() -> ComputationGraph:
+ return ComputationGraph(
+ graph_id="ambiguous-identity",
+ values=(
+ ValueSpec("input", ScalarType.FLOAT64, ()),
+ ValueSpec("output", ScalarType.FLOAT64, ()),
+ ),
+ inputs=("input",),
+ nodes=(
+ Node(
+ "copy-input",
+ Operation.IDENTITY,
+ ("input",),
+ "output",
+ ),
+ ),
+ outputs=("output",),
+ )
+
+
+def conflicting_graph() -> ComputationGraph:
+ return ComputationGraph(
+ graph_id="invalid-addition",
+ values=(
+ ValueSpec("distance", ScalarType.FLOAT64, (), "meter"),
+ ValueSpec("duration", ScalarType.FLOAT64, (), "second"),
+ ValueSpec("sum", ScalarType.FLOAT64, (), "meter"),
+ ),
+ inputs=("distance", "duration"),
+ nodes=(
+ Node(
+ "add-incompatible-values",
+ Operation.ADD,
+ ("distance", "duration"),
+ "sum",
+ ),
+ ),
+ outputs=("sum",),
+ )
+
+
+class CLITestCase(unittest.TestCase):
+ graph = build_graph()
+ graph_bytes = encode_graph(graph)
+ certificate = create_certificate(graph)
+ certificate_bytes = encode_certificate(certificate)
+
+ def setUp(self) -> None:
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.directory = Path(temporary.name)
+ self.graph_path = self.directory / "graph.json"
+ self.certificate_path = self.directory / "certificate.json"
+ self.graph_path.write_bytes(self.graph_bytes)
+ self.certificate_path.write_bytes(self.certificate_bytes)
+
+ def invoke(self, *arguments: str) -> tuple[int, str, str]:
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with redirect_stdout(stdout), redirect_stderr(stderr):
+ exit_code = cli.main(arguments)
+ return exit_code, stdout.getvalue(), stderr.getvalue()
+
+
+class ArgumentContractTests(CLITestCase):
+ def test_missing_and_unknown_commands_have_stable_usage_failures(self) -> None:
+ for arguments in ((), ("unknown-secret-command",)):
+ with self.subTest(arguments=arguments):
+ exit_code, stdout, stderr = self.invoke(*arguments)
+ self.assertEqual(exit_code, cli.EXIT_USAGE)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ (
+ "unitsentinel: error: invalid command-line arguments; "
+ "use --help\n"
+ ),
+ )
+ self.assertNotIn("secret", stderr)
+
+ def test_invalid_expected_digest_fails_before_opening_inputs(self) -> None:
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ "missing-certificate",
+ "--graph",
+ "missing-graph",
+ "--expect-sha256",
+ "ABC",
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_USAGE)
+ self.assertEqual(stdout, "")
+ self.assertIn("invalid command-line arguments", stderr)
+
+ def test_help_and_version_use_successful_argparse_exits(self) -> None:
+ for arguments, expected in (
+ (("--help",), "Verify exact dimensional contracts"),
+ (("--version",), f"unitsentinel {VERSION}"),
+ (("verify", "--help"), "--certificate"),
+ (("replay", "--help"), "--strict-toolchain"),
+ ):
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with (
+ self.subTest(arguments=arguments),
+ redirect_stdout(stdout),
+ redirect_stderr(stderr),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ cli.main(arguments)
+ self.assertEqual(raised.exception.code, 0)
+ self.assertIn(expected, stdout.getvalue())
+ self.assertEqual(stderr.getvalue(), "")
+
+ def test_package_module_entrypoint_matches_the_cli_version(self) -> None:
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+ with (
+ patch.object(sys, "argv", ["unitsentinel", "--version"]),
+ redirect_stdout(stdout),
+ redirect_stderr(stderr),
+ self.assertRaises(SystemExit) as raised,
+ ):
+ runpy.run_module("unitsentinel", run_name="__main__")
+
+ self.assertEqual(raised.exception.code, 0)
+ self.assertEqual(stdout.getvalue(), f"unitsentinel {VERSION}\n")
+ self.assertEqual(stderr.getvalue(), "")
+
+ def test_boundary_redacts_interrupt_domain_and_unexpected_failures(self) -> None:
+ cases = (
+ (
+ KeyboardInterrupt(),
+ cli.EXIT_INTERRUPTED,
+ "unitsentinel: interrupted\n",
+ ),
+ (
+ UnitSentinelError("private /home/omar path"),
+ cli.EXIT_INTERNAL,
+ "unitsentinel: error: internal contract failure\n",
+ ),
+ (
+ RuntimeError("private /home/omar path"),
+ cli.EXIT_INTERNAL,
+ "unitsentinel: error: internal failure\n",
+ ),
+ )
+ for failure, expected_exit, expected_error in cases:
+ with (
+ self.subTest(failure=type(failure).__name__),
+ patch.object(cli, "_dispatch", side_effect=failure),
+ ):
+ exit_code, stdout, stderr = self.invoke("verify", str(self.graph_path))
+ self.assertEqual(exit_code, expected_exit)
+ self.assertEqual(stdout, "")
+ self.assertEqual(stderr, expected_error)
+ self.assertNotIn("omar", stderr.lower())
+
+
+class VerifyCommandTests(CLITestCase):
+ def test_positive_text_output_exposes_exact_contracts_and_digests(self) -> None:
+ exit_code, stdout, stderr = self.invoke("verify", str(self.graph_path))
+
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertIn("UnitSentinel verification: VERIFIED\n", stdout)
+ self.assertIn(f"graph sha256: {self.graph.digest}\n", stdout)
+ self.assertIn(f"result sha256: {self.certificate.result.digest}\n", stdout)
+ self.assertIn("solver: z3 4.16.0 (2 checks)\n", stdout)
+ self.assertIn(
+ "raw-speed | length^1 time^-1 | linear | scale=5/18 offset=0",
+ stdout,
+ )
+ self.assertIn(
+ "si-speed | length^1 time^-1 | linear | scale=1 offset=0",
+ stdout,
+ )
+ self.assertIn(f"certificate sha256: {self.certificate.digest}\n", stdout)
+ self.assertIn("certificate authentication: not-provided\n", stdout)
+ self.assertIn("certificate output: not-requested\n", stdout)
+
+ def test_positive_json_output_is_canonical_and_self_describing(self) -> None:
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--json",
+ )
+
+ record = json.loads(stdout)
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertEqual(record["schema"], cli.VERIFY_OUTPUT_SCHEMA)
+ self.assertEqual(record["result"]["record"]["status"], "verified")
+ self.assertEqual(
+ record["result"]["sha256"],
+ self.certificate.result.digest,
+ )
+ self.assertEqual(
+ record["certificate"],
+ {
+ "authentication": "not-provided",
+ "schema": "unitsentinel.proof-certificate/v1",
+ "sha256": self.certificate.digest,
+ },
+ )
+ self.assertEqual(record["certificate_output"], "not-requested")
+ self.assertEqual(record["exit_code"], cli.EXIT_SUCCESS)
+ self.assertEqual(record["graph"]["graph_id"], self.graph.graph_id)
+ self.assertEqual(record["tool"]["version"], VERSION)
+ self.assertEqual(
+ stdout,
+ canonical_json_bytes(record).decode("utf-8") + "\n",
+ )
+
+ def test_certificate_output_is_exact_private_atomic_and_temp_free(self) -> None:
+ output = self.directory / "issued.cert.json"
+ previous_umask = os.umask(0o777)
+ try:
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(output),
+ )
+ finally:
+ os.umask(previous_umask)
+
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertIn("certificate output: written\n", stdout)
+ self.assertEqual(output.read_bytes(), self.certificate_bytes)
+ self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o600)
+ self.assertEqual(list(self.directory.glob(".unitsentinel-*.tmp")), [])
+
+ def test_partial_kernel_writes_are_completed_before_publication(self) -> None:
+ output = self.directory / "short-writes.cert.json"
+ real_write = os.write
+
+ def short_write(descriptor: int, payload: object) -> int:
+ view = memoryview(payload) # type: ignore[arg-type]
+ return real_write(descriptor, view[:17])
+
+ with patch.object(cli.os, "write", side_effect=short_write):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(output),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertIn("certificate output: written", stdout)
+ self.assertEqual(output.read_bytes(), self.certificate_bytes)
+
+ def test_existing_output_is_never_overwritten_or_leaked_to_stdout(self) -> None:
+ output = self.directory / "existing.cert.json"
+ output.write_bytes(b"owner-data")
+
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(output),
+ "--json",
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ "unitsentinel: error: certificate output already exists\n",
+ )
+ self.assertEqual(output.read_bytes(), b"owner-data")
+ self.assertEqual(list(self.directory.glob(".unitsentinel-*.tmp")), [])
+
+ def test_negative_results_have_distinct_exits_and_never_write_certificates(
+ self,
+ ) -> None:
+ cases = (
+ (ambiguous_graph(), cli.EXIT_UNDERCONSTRAINED, "UNDERCONSTRAINED"),
+ (conflicting_graph(), cli.EXIT_CONFLICT, "CONFLICT"),
+ )
+ for index, (graph, expected_exit, expected_status) in enumerate(cases):
+ graph_path = self.directory / f"negative-{index}.json"
+ output = self.directory / f"negative-{index}.cert.json"
+ graph_path.write_bytes(encode_graph(graph))
+ with self.subTest(status=expected_status):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(graph_path),
+ "--certificate",
+ str(output),
+ )
+ self.assertEqual(exit_code, expected_exit)
+ self.assertEqual(stderr, "")
+ self.assertIn(
+ f"UnitSentinel verification: {expected_status}",
+ stdout,
+ )
+ self.assertNotIn("certificate sha256:", stdout)
+ self.assertFalse(output.exists())
+
+ def test_unknown_result_is_an_indeterminate_noncertificate_outcome(self) -> None:
+ unknown = VerificationResult(
+ status=VerificationStatus.UNKNOWN,
+ graph_digest=self.graph.digest,
+ registry_digest=BUILTIN_REGISTRY.digest,
+ solver_version=self.certificate.result.solver_version,
+ limits=SolverLimits(),
+ checks_performed=0,
+ unknown_reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ with patch.object(
+ cli,
+ "_create_certificate_attempt",
+ return_value=(unknown, None),
+ ):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INDETERMINATE)
+ self.assertEqual(stderr, "")
+ self.assertIn("UnitSentinel verification: UNKNOWN", stdout)
+ self.assertIn("unknown reason: resource-limit", stdout)
+ self.assertNotIn("certificate sha256:", stdout)
+
+ def test_internal_issuance_failure_is_redacted(self) -> None:
+ with patch.object(
+ cli,
+ "_create_certificate_attempt",
+ side_effect=CertificateError("solver leaked /private/path"),
+ ):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INTERNAL)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ "unitsentinel: error: verification could not be completed safely\n",
+ )
+ self.assertNotIn("private", stderr)
+
+
+class ReplayCommandTests(CLITestCase):
+ def test_reproduced_text_output_shows_current_semantic_recheck(self) -> None:
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertIn("UnitSentinel replay: REPRODUCED\n", stdout)
+ self.assertIn("reason: none\n", stdout)
+ self.assertIn(f"certificate sha256: {self.certificate.digest}\n", stdout)
+ self.assertIn("certificate authentication: not-provided\n", stdout)
+ self.assertIn("toolchain match: yes (strict=no)\n", stdout)
+ self.assertIn("fresh result: verified ", stdout)
+
+ def test_reproduced_json_output_is_canonical_and_self_describing(self) -> None:
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(self.graph_path),
+ "--strict-toolchain",
+ "--json",
+ )
+
+ record = json.loads(stdout)
+ self.assertEqual(exit_code, cli.EXIT_SUCCESS)
+ self.assertEqual(stderr, "")
+ self.assertEqual(record["schema"], cli.REPLAY_OUTPUT_SCHEMA)
+ self.assertEqual(record["report"]["record"]["status"], "reproduced")
+ self.assertTrue(record["report"]["record"]["strict_toolchain"])
+ self.assertEqual(
+ record["report"]["sha256"],
+ sha256_hex(canonical_json_bytes(record["report"]["record"])),
+ )
+ self.assertEqual(record["certificate"]["authentication"], "not-provided")
+ self.assertEqual(record["exit_code"], cli.EXIT_SUCCESS)
+ self.assertEqual(
+ stdout,
+ canonical_json_bytes(record).decode("utf-8") + "\n",
+ )
+
+ def test_graph_binding_mismatch_is_a_report_not_a_cli_error(self) -> None:
+ other_graph = ComputationGraph(
+ graph_id="different-speed-contract",
+ values=self.graph.values,
+ inputs=self.graph.inputs,
+ nodes=self.graph.nodes,
+ outputs=self.graph.outputs,
+ )
+ other_path = self.directory / "other-graph.json"
+ other_path.write_bytes(encode_graph(other_graph))
+
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(other_path),
+ "--json",
+ )
+
+ record = json.loads(stdout)
+ self.assertEqual(exit_code, cli.EXIT_MISMATCH)
+ self.assertEqual(stderr, "")
+ self.assertEqual(record["report"]["record"]["status"], "mismatch")
+ self.assertEqual(
+ record["report"]["record"]["reason"],
+ "graph-digest-mismatch",
+ )
+ self.assertIsNone(record["report"]["record"]["fresh_result"])
+ self.assertEqual(record["exit_code"], cli.EXIT_MISMATCH)
+
+ def test_expected_digest_matches_or_short_circuits_before_graph_read(self) -> None:
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(self.graph_path),
+ "--expect-sha256",
+ self.certificate.digest,
+ )
+ self.assertEqual((exit_code, stderr), (cli.EXIT_SUCCESS, ""))
+ self.assertIn("REPRODUCED", stdout)
+
+ wrong_digest = "0" * 64
+ if wrong_digest == self.certificate.digest:
+ wrong_digest = "1" * 64
+ with patch.object(
+ cli,
+ "_decode_graph_file",
+ side_effect=AssertionError("graph must not be read"),
+ ) as graph_decoder:
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ "private-graph-name",
+ "--expect-sha256",
+ wrong_digest,
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_MISMATCH)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ (
+ "unitsentinel: error: certificate sha256 does not match "
+ "the expected digest\n"
+ ),
+ )
+ graph_decoder.assert_not_called()
+
+ def test_strict_toolchain_rejects_a_structurally_valid_old_claim(self) -> None:
+ old_claim = ProofCertificate(
+ registry_version=self.certificate.registry_version,
+ verifier_version="0.0.1",
+ constraints=self.certificate.constraints,
+ result=self.certificate.result,
+ )
+ old_path = self.directory / "old-toolchain.cert.json"
+ old_path.write_bytes(encode_certificate(old_claim))
+
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(old_path),
+ "--graph",
+ str(self.graph_path),
+ "--strict-toolchain",
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_MISMATCH)
+ self.assertEqual(stderr, "")
+ self.assertIn("UnitSentinel replay: MISMATCH", stdout)
+ self.assertIn("reason: toolchain-mismatch", stdout)
+ self.assertIn("toolchain match: no (strict=yes)", stdout)
+ self.assertNotIn("fresh result:", stdout)
+
+ def test_indeterminate_replay_has_exit_three_and_fresh_unknown_evidence(
+ self,
+ ) -> None:
+ baseline = replay_certificate(self.certificate, self.graph)
+ fresh_unknown = VerificationResult(
+ status=VerificationStatus.UNKNOWN,
+ graph_digest=self.graph.digest,
+ registry_digest=BUILTIN_REGISTRY.digest,
+ solver_version=baseline.current_solver_version,
+ limits=SolverLimits(),
+ checks_performed=0,
+ unknown_reason=UnknownReason.RESOURCE_LIMIT,
+ )
+ indeterminate = CertificateReplay(
+ status=ReplayStatus.INDETERMINATE,
+ reason=ReplayReason.FRESH_UNKNOWN,
+ certificate_digest=self.certificate.digest,
+ graph_digest=self.graph.digest,
+ registry_digest=BUILTIN_REGISTRY.digest,
+ registry_version=BUILTIN_REGISTRY.version,
+ strict_toolchain=False,
+ certificate_verifier_version=self.certificate.verifier_version,
+ certificate_solver_version=self.certificate.result.solver_version,
+ current_verifier_version=VERSION,
+ current_solver_version=baseline.current_solver_version,
+ toolchain_match=True,
+ fresh_result=fresh_unknown,
+ )
+ with patch.object(
+ cli,
+ "replay_certificate",
+ return_value=indeterminate,
+ ):
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INDETERMINATE)
+ self.assertEqual(stderr, "")
+ self.assertIn("UnitSentinel replay: INDETERMINATE", stdout)
+ self.assertIn("reason: fresh-unknown", stdout)
+ self.assertIn("fresh result: unknown ", stdout)
+
+ def test_internal_replay_failure_is_redacted(self) -> None:
+ with patch.object(
+ cli,
+ "replay_certificate",
+ side_effect=CertificateReplayError("private solver detail"),
+ ):
+ exit_code, stdout, stderr = self.invoke(
+ "replay",
+ str(self.certificate_path),
+ "--graph",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INTERNAL)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ ("unitsentinel: error: certificate replay could not be completed safely\n"),
+ )
+ self.assertNotIn("private", stderr)
+
+
+class InputBoundaryTests(CLITestCase):
+ def test_missing_paths_are_redacted(self) -> None:
+ private_name = self.directory / "omar-secret-graph.json"
+ exit_code, stdout, stderr = self.invoke("verify", str(private_name))
+
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ "unitsentinel: error: graph input could not be opened\n",
+ )
+ self.assertNotIn("omar", stderr)
+
+ def test_malformed_graph_and_certificate_are_distinguished(self) -> None:
+ malformed_graph = self.directory / "malformed-graph.json"
+ malformed_certificate = self.directory / "malformed-certificate.json"
+ malformed_graph.write_bytes(b"{}")
+ malformed_certificate.write_bytes(b"{}")
+
+ graph_exit, graph_stdout, graph_stderr = self.invoke(
+ "verify",
+ str(malformed_graph),
+ )
+ certificate_exit, certificate_stdout, certificate_stderr = self.invoke(
+ "replay",
+ str(malformed_certificate),
+ "--graph",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(graph_exit, cli.EXIT_INPUT)
+ self.assertEqual(certificate_exit, cli.EXIT_INPUT)
+ self.assertEqual(graph_stdout, "")
+ self.assertEqual(certificate_stdout, "")
+ self.assertIn("graph input is invalid:", graph_stderr)
+ self.assertIn("certificate input is invalid:", certificate_stderr)
+
+ def test_oversized_graph_and_certificate_stop_before_decoding(self) -> None:
+ oversized_graph = self.directory / "oversized-graph.json"
+ oversized_certificate = self.directory / "oversized-certificate.json"
+ oversized_graph.write_bytes(b" " * (MAX_GRAPH_BYTES + 1))
+ oversized_certificate.write_bytes(b" " * (MAX_CERTIFICATE_BYTES + 1))
+
+ for command, expected_label in (
+ (("verify", str(oversized_graph)), "graph input"),
+ (
+ (
+ "replay",
+ str(oversized_certificate),
+ "--graph",
+ str(self.graph_path),
+ ),
+ "certificate input",
+ ),
+ ):
+ with self.subTest(label=expected_label):
+ exit_code, stdout, stderr = self.invoke(*command)
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ f"unitsentinel: error: {expected_label} exceeds the byte limit\n",
+ )
+
+ def test_final_symlinks_directories_and_fifos_are_not_read(self) -> None:
+ symlink = self.directory / "graph-link.json"
+ directory = self.directory / "graph-directory"
+ fifo = self.directory / "graph-fifo"
+ symlink.symlink_to(self.graph_path)
+ directory.mkdir()
+ os.mkfifo(fifo)
+
+ cases = (
+ (symlink, "could not be opened"),
+ (directory, "must be a regular file"),
+ (fifo, "must be a regular file"),
+ )
+ for path, expected in cases:
+ with self.subTest(kind=path.name):
+ exit_code, stdout, stderr = self.invoke("verify", str(path))
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertIn(expected, stderr)
+
+ def test_read_failures_are_stable_and_close_the_descriptor(self) -> None:
+ with patch.object(cli.os, "read", side_effect=OSError("private failure")):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertEqual(
+ stderr,
+ "unitsentinel: error: graph input could not be read\n",
+ )
+ self.assertNotIn("private", stderr)
+
+ def test_stream_growth_is_capped_even_when_initial_size_is_stale(self) -> None:
+ payload = self.directory / "growing-input"
+ payload.write_bytes(b"1234")
+ real_fstat = os.fstat
+
+ def stale_size(descriptor: int) -> SimpleNamespace:
+ metadata = real_fstat(descriptor)
+ return SimpleNamespace(st_mode=metadata.st_mode, st_size=0)
+
+ with (
+ patch.object(cli.os, "fstat", side_effect=stale_size),
+ self.assertRaises(cli._CLIError) as raised,
+ ):
+ cli._read_bounded_file(
+ str(payload),
+ label="test input",
+ max_bytes=3,
+ )
+
+ self.assertEqual(raised.exception.exit_code, cli.EXIT_INPUT)
+ self.assertEqual(
+ raised.exception.message,
+ "test input exceeds the byte limit",
+ )
+
+
+class AtomicOutputBoundaryTests(CLITestCase):
+ def test_invalid_leaf_missing_parent_and_symlink_parent_fail_closed(self) -> None:
+ real_directory = self.directory / "real"
+ linked_directory = self.directory / "linked"
+ real_directory.mkdir()
+ linked_directory.symlink_to(real_directory, target_is_directory=True)
+ cases = (
+ (str(self.directory) + "/", "output path is invalid"),
+ (
+ str(self.directory / "missing" / "certificate.json"),
+ "output directory could not be opened",
+ ),
+ (
+ str(linked_directory / "certificate.json"),
+ "output directory could not be opened",
+ ),
+ )
+ for output, expected in cases:
+ with self.subTest(expected=expected):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ output,
+ )
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertIn(expected, stderr)
+
+ def test_existing_symlink_target_is_not_followed_or_replaced(self) -> None:
+ owner_file = self.directory / "owner-file"
+ output = self.directory / "certificate-link"
+ owner_file.write_bytes(b"owner-data")
+ output.symlink_to(owner_file)
+
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(output),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertIn("output already exists", stderr)
+ self.assertTrue(output.is_symlink())
+ self.assertEqual(owner_file.read_bytes(), b"owner-data")
+
+ def test_write_link_and_directory_fsync_failures_never_publish_partial_bytes(
+ self,
+ ) -> None:
+ write_output = self.directory / "write-failure.cert.json"
+ with patch.object(cli, "_write_all", side_effect=OSError("write failure")):
+ write_exit, write_stdout, write_stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(write_output),
+ )
+ self.assertEqual(write_exit, cli.EXIT_INPUT)
+ self.assertEqual(write_stdout, "")
+ self.assertIn("could not be written", write_stderr)
+ self.assertFalse(write_output.exists())
+
+ link_output = self.directory / "link-failure.cert.json"
+ with patch.object(cli.os, "link", side_effect=OSError("link failure")):
+ link_exit, link_stdout, link_stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(link_output),
+ )
+ self.assertEqual(link_exit, cli.EXIT_INPUT)
+ self.assertEqual(link_stdout, "")
+ self.assertIn("could not be published", link_stderr)
+ self.assertFalse(link_output.exists())
+
+ fsync_output = self.directory / "fsync-failure.cert.json"
+ real_fsync = os.fsync
+
+ def fail_directory_fsync(descriptor: int) -> None:
+ if stat.S_ISDIR(os.fstat(descriptor).st_mode):
+ raise OSError("directory fsync failure")
+ real_fsync(descriptor)
+
+ with patch.object(cli.os, "fsync", side_effect=fail_directory_fsync):
+ fsync_exit, fsync_stdout, fsync_stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(fsync_output),
+ )
+ self.assertEqual(fsync_exit, cli.EXIT_INPUT)
+ self.assertEqual(fsync_stdout, "")
+ self.assertIn("durability could not be confirmed", fsync_stderr)
+ self.assertEqual(fsync_output.read_bytes(), self.certificate_bytes)
+ self.assertEqual(list(self.directory.glob(".unitsentinel-*.tmp")), [])
+
+ def test_postpublication_cleanup_failure_keeps_complete_target_and_errors(
+ self,
+ ) -> None:
+ output = self.directory / "cleanup-failure.cert.json"
+ with patch.object(cli.os, "unlink", side_effect=OSError("cleanup failure")):
+ exit_code, stdout, stderr = self.invoke(
+ "verify",
+ str(self.graph_path),
+ "--certificate",
+ str(output),
+ )
+
+ self.assertEqual(exit_code, cli.EXIT_INPUT)
+ self.assertEqual(stdout, "")
+ self.assertIn("output cleanup could not be confirmed", stderr)
+ self.assertEqual(output.read_bytes(), self.certificate_bytes)
+
+ def test_temp_name_exhaustion_and_zero_length_write_fail_stably(self) -> None:
+ collision = self.directory / ".unitsentinel-fixed.tmp"
+ collision.write_bytes(b"owner-data")
+ directory = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY)
+ self.addCleanup(os.close, directory)
+ with (
+ patch.object(cli.secrets, "token_hex", return_value="fixed"),
+ self.assertRaises(cli._CLIError) as raised,
+ ):
+ cli._create_output_temp(directory)
+ self.assertEqual(raised.exception.exit_code, cli.EXIT_INPUT)
+ self.assertEqual(collision.read_bytes(), b"owner-data")
+
+ with (
+ patch.object(cli.os, "write", return_value=0),
+ self.assertRaises(OSError),
+ ):
+ cli._write_all(directory, b"payload")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_domain.py b/tests/test_domain.py
new file mode 100644
index 0000000..2ee7c0c
--- /dev/null
+++ b/tests/test_domain.py
@@ -0,0 +1,386 @@
+from __future__ import annotations
+
+import unittest
+from dataclasses import FrozenInstanceError
+from fractions import Fraction
+
+from unitsentinel.domain import (
+ AMOUNT_OF_SUBSTANCE,
+ BASE_DIMENSIONS,
+ DIMENSIONLESS,
+ ELECTRIC_CURRENT,
+ LENGTH,
+ LUMINOUS_INTENSITY,
+ MASS,
+ MAX_EXPONENT_DENOMINATOR,
+ MAX_EXPONENT_NUMERATOR,
+ THERMODYNAMIC_TEMPERATURE,
+ TIME,
+ BaseDimension,
+ ConversionError,
+ Dimension,
+ DimensionError,
+ Quantity,
+ QuantityKind,
+ Unit,
+ UnitDefinitionError,
+)
+
+
+def meter() -> Unit:
+ return Unit("meter", "m", LENGTH, Fraction(1))
+
+
+def kilometer() -> Unit:
+ return Unit("kilometer", "km", LENGTH, Fraction(1_000))
+
+
+def kelvin() -> Unit:
+ return Unit(
+ "kelvin",
+ "K",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(1),
+ kind=QuantityKind.ABSOLUTE_TEMPERATURE,
+ )
+
+
+def celsius() -> Unit:
+ return Unit(
+ "degree-celsius",
+ "°C",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(1),
+ Fraction(27_315, 100),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ )
+
+
+def fahrenheit() -> Unit:
+ return Unit(
+ "degree-fahrenheit",
+ "°F",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(5, 9),
+ Fraction(45_967, 180),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ )
+
+
+def celsius_delta() -> Unit:
+ return Unit(
+ "delta-celsius",
+ "Δ°C",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(1),
+ kind=QuantityKind.TEMPERATURE_DELTA,
+ )
+
+
+def fahrenheit_delta() -> Unit:
+ return Unit(
+ "delta-fahrenheit",
+ "Δ°F",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(5, 9),
+ kind=QuantityKind.TEMPERATURE_DELTA,
+ )
+
+
+class DimensionTests(unittest.TestCase):
+ def test_base_dimension_order_is_frozen(self) -> None:
+ self.assertEqual(
+ tuple(base.value for base in BASE_DIMENSIONS),
+ (
+ "length",
+ "mass",
+ "time",
+ "electric-current",
+ "thermodynamic-temperature",
+ "amount-of-substance",
+ "luminous-intensity",
+ ),
+ )
+ self.assertEqual(
+ (
+ LENGTH,
+ MASS,
+ TIME,
+ ELECTRIC_CURRENT,
+ THERMODYNAMIC_TEMPERATURE,
+ AMOUNT_OF_SUBSTANCE,
+ LUMINOUS_INTENSITY,
+ ),
+ tuple(Dimension.base(base) for base in BASE_DIMENSIONS),
+ )
+
+ def test_exact_algebra_builds_velocity_acceleration_and_energy(self) -> None:
+ velocity = LENGTH.divide(TIME)
+ acceleration = velocity.divide(TIME)
+ energy = MASS.multiply(acceleration).multiply(LENGTH)
+
+ self.assertEqual(
+ velocity.canonical_pairs(),
+ (("length", "1"), ("time", "-1")),
+ )
+ self.assertEqual(
+ acceleration.canonical_pairs(),
+ (("length", "1"), ("time", "-2")),
+ )
+ self.assertEqual(
+ energy.canonical_pairs(),
+ (("length", "2"), ("mass", "1"), ("time", "-2")),
+ )
+ self.assertTrue(velocity.divide(velocity).is_dimensionless)
+ self.assertEqual(DIMENSIONLESS.canonical_pairs(), ())
+
+ def test_rational_power_is_exact_and_bounded(self) -> None:
+ area = LENGTH.power(Fraction(2))
+ self.assertEqual(area.power(Fraction(1, 2)), LENGTH)
+ self.assertEqual(
+ LENGTH.power(Fraction(1, 3)).canonical_pairs(),
+ (("length", "1/3"),),
+ )
+
+ with self.assertRaisesRegex(DimensionError, "declared bounds"):
+ LENGTH.power(Fraction(1, MAX_EXPONENT_DENOMINATOR + 1))
+ with self.assertRaisesRegex(DimensionError, "declared bounds"):
+ LENGTH.power(Fraction(MAX_EXPONENT_NUMERATOR + 1))
+ with self.assertRaisesRegex(DimensionError, "declared bounds"):
+ LENGTH.power(Fraction(MAX_EXPONENT_NUMERATOR)).multiply(LENGTH)
+ with self.assertRaisesRegex(DimensionError, "declared bounds"):
+ LENGTH.power(Fraction(1, 5)).multiply(LENGTH.power(Fraction(1, 7)))
+
+ def test_mapping_is_closed_over_known_exact_bases(self) -> None:
+ dimension = Dimension.from_mapping(
+ {
+ BaseDimension.LENGTH: Fraction(2),
+ BaseDimension.TIME: Fraction(-1),
+ }
+ )
+ self.assertEqual(
+ dimension.canonical_pairs(),
+ (("length", "2"), ("time", "-1")),
+ )
+
+ with self.assertRaisesRegex(DimensionError, "unknown base"):
+ Dimension.from_mapping({"length": Fraction(1)}) # type: ignore[dict-item]
+ with self.assertRaisesRegex(DimensionError, "exact Fraction"):
+ Dimension.from_mapping({BaseDimension.LENGTH: 1}) # type: ignore[dict-item]
+ with self.assertRaisesRegex(DimensionError, "Mapping"):
+ Dimension.from_mapping(object()) # type: ignore[arg-type]
+ with self.assertRaisesRegex(DimensionError, "too many entries"):
+ Dimension.from_mapping({str(index): Fraction(1) for index in range(8)}) # type: ignore[arg-type]
+
+ def test_malformed_dimensions_and_subclasses_fail_closed(self) -> None:
+ class DerivedDimension(Dimension):
+ pass
+
+ with self.assertRaisesRegex(DimensionError, "exact Dimension"):
+ DerivedDimension(DIMENSIONLESS.exponents)
+ with self.assertRaisesRegex(DimensionError, "known SI dimension"):
+ Dimension.base("length") # type: ignore[arg-type]
+ with self.assertRaisesRegex(DimensionError, "tuple"):
+ Dimension([Fraction(0)] * 7) # type: ignore[arg-type]
+ with self.assertRaisesRegex(DimensionError, "seven"):
+ Dimension((Fraction(0),) * 6)
+ with self.assertRaisesRegex(DimensionError, "exact Fraction"):
+ Dimension((0,) * 7) # type: ignore[arg-type]
+ with self.assertRaisesRegex(DimensionError, "exact Dimension"):
+ LENGTH.multiply(object()) # type: ignore[arg-type]
+
+ def test_dimensions_are_immutable(self) -> None:
+ with self.assertRaises(FrozenInstanceError):
+ LENGTH.exponents = DIMENSIONLESS.exponents # type: ignore[misc]
+
+ def test_operations_revalidate_a_low_level_mutated_dimension(self) -> None:
+ dimension = Dimension.base(BaseDimension.LENGTH)
+ object.__setattr__(dimension, "exponents", (Fraction(0),) * 6)
+
+ with self.assertRaisesRegex(DimensionError, "seven"):
+ dimension.multiply(TIME)
+
+
+class UnitTests(unittest.TestCase):
+ def test_linear_scale_conversion_is_exact(self) -> None:
+ distance = Quantity(Fraction(5, 4), kilometer())
+ converted = distance.to(meter())
+
+ self.assertEqual(converted.magnitude, Fraction(1_250))
+ self.assertEqual(converted.unit.unit_id, "meter")
+ self.assertEqual(
+ converted.canonical_record(),
+ {"magnitude": "1250", "unit_id": "meter"},
+ )
+
+ def test_absolute_temperature_conversions_are_exact(self) -> None:
+ freezing = Quantity(Fraction(32), fahrenheit())
+
+ self.assertEqual(freezing.to(celsius()).magnitude, Fraction(0))
+ self.assertEqual(freezing.to(kelvin()).magnitude, Fraction(27_315, 100))
+ self.assertEqual(
+ Quantity(Fraction(100), celsius()).to(fahrenheit()).magnitude,
+ Fraction(212),
+ )
+
+ def test_temperature_deltas_do_not_apply_absolute_offsets(self) -> None:
+ delta = Quantity(Fraction(18), fahrenheit_delta())
+ self.assertEqual(delta.to(celsius_delta()).magnitude, Fraction(10))
+ self.assertEqual(
+ Quantity(Fraction(10), celsius_delta()).to(fahrenheit_delta()).magnitude,
+ Fraction(18),
+ )
+
+ def test_absolute_and_delta_temperatures_never_mix_implicitly(self) -> None:
+ with self.assertRaisesRegex(ConversionError, "quantity kinds"):
+ celsius().convert_value_to(Fraction(20), celsius_delta())
+ with self.assertRaisesRegex(ConversionError, "quantity kinds"):
+ celsius_delta().convert_value_to(Fraction(20), kelvin())
+
+ def test_dimension_mismatch_is_rejected_before_arithmetic(self) -> None:
+ second = Unit("second", "s", TIME, Fraction(1))
+ with self.assertRaisesRegex(ConversionError, "dimensions"):
+ meter().convert_value_to(Fraction(1), second)
+
+ def test_unit_definition_rejects_unsafe_identifiers_and_symbols(self) -> None:
+ cases = (
+ ("Meter", "m", "identifier"),
+ ("meter_2", "m", "identifier"),
+ ("meter", " m", "symbol"),
+ ("meter", "m\n", "symbol"),
+ ("meter", "m\u200b", "control"),
+ ("meter", "m\u0301", "canonical Unicode"),
+ ("meter", "m\u2028s", "whitespace"),
+ ("meter", 1, "must be text"),
+ )
+ for unit_id, symbol, message in cases:
+ with (
+ self.subTest(unit_id=unit_id, symbol=repr(symbol)),
+ self.assertRaisesRegex(UnitDefinitionError, message),
+ ):
+ Unit(unit_id, symbol, LENGTH, Fraction(1))
+
+ def test_unit_definition_enforces_scale_offset_and_kind_semantics(self) -> None:
+ cases = (
+ (
+ ("meter", "m", LENGTH, Fraction(0)),
+ "scale must be positive",
+ ),
+ (
+ ("meter", "m", LENGTH, Fraction(1), Fraction(1)),
+ "linear units cannot",
+ ),
+ (
+ (
+ "ambiguous-kelvin",
+ "K?",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(1),
+ ),
+ "explicit absolute or delta",
+ ),
+ (
+ (
+ "absolute-meter",
+ "mA",
+ LENGTH,
+ Fraction(1),
+ Fraction(0),
+ QuantityKind.ABSOLUTE_TEMPERATURE,
+ ),
+ "require temperature",
+ ),
+ (
+ (
+ "delta-celsius",
+ "Δ°C",
+ THERMODYNAMIC_TEMPERATURE,
+ Fraction(1),
+ Fraction(1),
+ QuantityKind.TEMPERATURE_DELTA,
+ ),
+ "cannot have an offset",
+ ),
+ )
+ for arguments, message in cases:
+ with (
+ self.subTest(arguments=arguments),
+ self.assertRaisesRegex(
+ UnitDefinitionError,
+ message,
+ ),
+ ):
+ Unit(*arguments)
+
+ def test_float_and_integer_convenience_values_are_not_accepted(self) -> None:
+ with self.assertRaisesRegex(UnitDefinitionError, "exact Fraction"):
+ Unit("meter", "m", LENGTH, 1) # type: ignore[arg-type]
+ with self.assertRaisesRegex(ConversionError, "exact Fraction"):
+ meter().convert_value_to(1.0, kilometer()) # type: ignore[arg-type]
+ with self.assertRaisesRegex(UnitDefinitionError, "exact Fraction"):
+ Quantity(1, meter()) # type: ignore[arg-type]
+ with self.assertRaisesRegex(UnitDefinitionError, "exact Unit"):
+ Quantity(Fraction(1), object()) # type: ignore[arg-type]
+
+ def test_rational_size_and_exact_target_types_are_bounded(self) -> None:
+ oversized = Fraction(1 << 256)
+
+ with self.assertRaisesRegex(UnitDefinitionError, "size limit"):
+ Unit("oversized", "x", LENGTH, oversized)
+ with self.assertRaisesRegex(ConversionError, "exact Unit"):
+ meter().convert_value_to(Fraction(1), object()) # type: ignore[arg-type]
+
+ def test_canonical_unit_record_uses_rational_strings(self) -> None:
+ self.assertEqual(
+ fahrenheit().canonical_record(),
+ {
+ "dimension": [
+ {
+ "base": "thermodynamic-temperature",
+ "exponent": "1",
+ }
+ ],
+ "kind": "absolute-temperature",
+ "offset": "45967/180",
+ "scale": "5/9",
+ "symbol": "°F",
+ "unit_id": "degree-fahrenheit",
+ },
+ )
+
+ def test_quantity_and_unit_are_immutable(self) -> None:
+ quantity = Quantity(Fraction(1), meter())
+ with self.assertRaises(FrozenInstanceError):
+ quantity.magnitude = Fraction(2) # type: ignore[misc]
+ with self.assertRaises(FrozenInstanceError):
+ quantity.unit.scale = Fraction(2) # type: ignore[misc]
+
+ def test_public_operations_revalidate_low_level_mutation(self) -> None:
+ corrupted_unit = meter()
+ object.__setattr__(corrupted_unit, "kind", "linear")
+ with self.assertRaisesRegex(UnitDefinitionError, "kind is unknown"):
+ corrupted_unit.canonical_record()
+
+ corrupted_quantity = Quantity(Fraction(1), meter())
+ object.__setattr__(corrupted_quantity, "magnitude", 1)
+ with self.assertRaisesRegex(UnitDefinitionError, "exact Fraction"):
+ corrupted_quantity.to(kilometer())
+
+ def test_domain_value_subclasses_fail_closed_as_receivers(self) -> None:
+ class DerivedUnit(Unit):
+ pass
+
+ class DerivedQuantity(Quantity):
+ pass
+
+ with self.assertRaisesRegex(UnitDefinitionError, "unit must be an exact Unit"):
+ DerivedUnit("meter", "m", LENGTH, Fraction(1))
+ with self.assertRaisesRegex(
+ UnitDefinitionError,
+ "quantity must be an exact Quantity",
+ ):
+ DerivedQuantity(Fraction(1), meter())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_evidence.py b/tests/test_evidence.py
new file mode 100644
index 0000000..6d0d527
--- /dev/null
+++ b/tests/test_evidence.py
@@ -0,0 +1,1018 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import stat
+import statistics
+import struct
+import subprocess
+import sys
+import unittest
+import xml.etree.ElementTree as ElementTree
+import zlib
+from datetime import datetime, timedelta
+from pathlib import Path, PurePosixPath
+from urllib.parse import unquote, urlsplit
+
+from examples.build_wheel_anomaly_contract import build_graph
+from unitsentinel import (
+ BUILTIN_REGISTRY,
+ VerificationStatus,
+ decode_certificate,
+ decode_graph,
+ encode_graph,
+)
+from unitsentinel.canonical import canonical_json_bytes
+
+ROOT = Path(__file__).resolve().parents[1]
+ASSETS = ROOT / "docs" / "assets"
+EVIDENCE = ROOT / "docs" / "evidence"
+MANIFEST = EVIDENCE / "manifest.json"
+EVIDENCE_README = EVIDENCE / "README.md"
+
+VERIFIED_GRAPH_DIGEST = (
+ "139e3e3d99d64c3d9cde89e9e1f116f09452c3532eaaee2e0513c71a0f2ada3c"
+)
+CONFLICT_GRAPH_DIGEST = (
+ "6ae6457c38e5dbe707187031a521e4c76124ee55ac58869a36ba746978a4f708"
+)
+VERIFIED_RESULT_DIGEST = (
+ "f2dce1e2b1e602719d117d05dfe356521bb204039de084beccc68dd8920406bd"
+)
+CONFLICT_RESULT_DIGEST = (
+ "521b85cbf597e5ca45716c7add5346cc65191063060c24c87ca70797bac67aea"
+)
+CERTIFICATE_DIGEST = "e93cc87cd72c6ede9cf8d324bfb41b2eb2bdcea6cb0aa6fea7aed4696009ab1a"
+REPLAY_DIGEST = "aca0b2794371a552a1f1b3af75bdd86b8cf8fb21e5cb664b835b2adf19acf3aa"
+REGISTRY_DIGEST = "fc80cbb596f3341b1d2ff13795e50d2d1e05c792b34f24804afc97c3470913e5"
+
+EXPECTED_CONFLICT_CORE = (
+ "declaration/acceleration-si/unit",
+ "operation/derive-acceleration/dimension",
+ "operation/normalize-sample-period/dimension",
+ "operation/normalize-speed-delta/dimension",
+)
+EXPECTED_VISUAL_DIMENSIONS = {
+ "certificate-lineage": (1_440, 900),
+ "conflict-core": (1_440, 930),
+ "conflict-terminal": (1_440, 900),
+ "replay-terminal": (1_440, 900),
+ "scaling": (1_440, 890),
+ "verification-pipeline": (1_440, 890),
+ "verify-terminal": (1_440, 900),
+ "wheel-anomaly-contract": (1_440, 900),
+}
+REQUIRED_README_EMBEDS = {
+ "docs/assets/certificate-lineage.png",
+ "docs/assets/conflict-core.png",
+ "docs/assets/conflict-terminal.png",
+ "docs/assets/replay-terminal.png",
+ "docs/assets/scaling.png",
+ "docs/assets/unitsentinel-demo.gif",
+ "docs/assets/verification-pipeline.png",
+ "docs/assets/verify-terminal.png",
+ "docs/assets/wheel-anomaly-contract.png",
+}
+
+SVG_NAMESPACE = "http://www.w3.org/2000/svg"
+SAFE_SVG_TAGS = {
+ "circle",
+ "defs",
+ "desc",
+ "feDropShadow",
+ "filter",
+ "line",
+ "linearGradient",
+ "marker",
+ "path",
+ "polyline",
+ "rect",
+ "stop",
+ "svg",
+ "text",
+ "title",
+ "tspan",
+}
+MARKDOWN_LINK = re.compile(
+ r"(?P!)?\[(?P