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. + +![Implemented UnitSentinel verification pipeline and fail-closed outcomes](docs/assets/verification-pipeline.png) + +*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 + +![Recorded UnitSentinel conflict verification and strict replay demo](docs/assets/unitsentinel-demo.gif) + +*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. + +![Verified physics-informed wheel anomaly dimensional contract](docs/assets/wheel-anomaly-contract.png) -## 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. + +![Genuine successful UnitSentinel CLI certificate issuance](docs/assets/verify-terminal.png) + +*[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 +``` + +![Genuine UnitSentinel CLI dimensional conflict output](docs/assets/conflict-terminal.png) + +*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: + +![Tracked UnitSentinel deletion-minimal conflict core explanation](docs/assets/conflict-core.png) + +*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. + +![Content-addressed UnitSentinel certificate and replay lineage](docs/assets/certificate-lineage.png) + +*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: +![Genuine UnitSentinel strict certificate replay output](docs/assets/replay-terminal.png) -- 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. + +![Measured UnitSentinel verification and strict replay scaling](docs/assets/scaling.png) + +*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 @@ + + + UnitSentinel certificate and replay lineage + Exact graph, registry, toolchain, verification result, certificate, and strict semantic replay digests. + + + + + + + + + + + + + +Content-addressed proof lineage +Integrity and semantic reproduction are explicit; issuer authentication is not provided. + + +Canonical graph + + sha256 139e3e3d99d64c3d9cde… + 2,072 exact bytes + + + +Registry snapshot + + sha256 fc80cbb596f3341b1d2f… + version 1.0.0 · 33 units + + + +Pinned toolchain + + unitsentinel 0.1.0 + z3 4.16.0 + caller-owned solver limits + + + +Verified result + + 10 unique contracts · 24 witnesses + 2 bounded solver checks + sha256 f2dce1e2b1e602719d11… + + + +Detached certificate + + positive claim · unsigned · 5,559 bytes + authentication: not-provided + sha256 e93cc87cd72c6ede9cf8… + + + +Strict replay + + recompute certificate digest + compare current graph + registry + match current toolchain (strict) + catalog + pure witness replay + fresh uniqueness check + status REPRODUCED + sha256 aca0b2794371a552a1f1… + +graph + registry + toolchain establish the claim + + + + +issue + +claim + current inputs + 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 @@ + + + UnitSentinel conflict-core explanation + The actual four-witness deletion-minimal conflict produced by the wheel anomaly serving-contract error. + + + + + + + + + + + + + +A shape-valid serving contract that cannot be physical +The only changed annotation is acceleration-si: m/s² → m/s. + + +speed-delta-si + + length^1 time^-1 + scale 1 + derived through CONVERT + + + +sample-period-si + + time^1 + scale 1 + derived through CONVERT + + + +DIVIDE Δv / Δt + + derived acceleration + length^1 time^-2 + meter-per-second-squared + + + +declared acceleration-si + + length^1 time^-1 + meter-per-second + CONTRADICTION + + + + + +Deletion-minimal tracked core +graph 6ae6457c38e5… · result 521b85cbf597… +1. declaration/acceleration-si/unit +2. operation/derive-acceleration/dimension +3. operation/normalize-sample-period/dimension +4. operation/normalize-speed-delta/dimension + 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 @@ + + + serving contract bug · fail closed + Real UnitSentinel CLI output for the shape-valid but dimensionally conflicting wheel anomaly graph. + + + + + + + + + + + + + + + + + + + +serving contract bug · fail closed + +$ .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/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 @@ + + + detached certificate · strict semantic replay + Real UnitSentinel CLI output for strict replay of the recorded unsigned certificate claim. + + + + + + + + + + + + + + + + + + + +detached certificate · strict semantic replay + +$ .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/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 @@ + + + UnitSentinel scaling benchmark + Measured median verification-plus-issuance and strict replay time across bounded identity-chain graph sizes. + + + + + + + + + + + + + +Bounded verification and replay scaling +Measured snapshot · median of 3 runs · log-scale milliseconds · lower is better + + + +15.0 + +45.8 + +139.4 + +424.6 + +1293.6 + + + +verify + issue + +strict replay + + +1 +20.0 +28.8 + + +8 +42.0 +54.0 + + +32 +113.0 +140.9 + + +128 +413.4 +500.4 + + +256 +867.0 +1034.9 +identity-chain nodes +Snapshot context +Python 3.12.3 · Z3 4.16.0 · UnitSentinel 0.1.0 · 2026-07-26T07:25:03+00:00 + 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 @@ + + + UnitSentinel verification pipeline + The implemented canonical decoding, constraint solving, fail-closed outcomes, positive certificate issuance, and independent replay flow. + + + + + + + + + + + + + +UnitSentinel verification pipeline +Implemented boundaries; positive, conflict, and replay paths are recorded. + + +Canonical graph + + closed JSON schema + 1 MiB / 512-node caps + sha256 139e3e3d99d6… + + + +Strict decoder + + duplicate / float reject + topology + shape metadata + byte-for-byte closure + + + +Constraint compiler + + dimension + kind + exact scale + offset + source-labelled witnesses + + + +Tracked Z3 + + bounded checks + alternate-model test + independent Python replay + + + + + + +CONFLICT + + tracked witness core + bounded deterministic shrink + never issues a certificate + + + +UNDERCONSTRAINED + + alternate model exists + observable values listed + no positive claim + + + +UNKNOWN + + timeout / memory / solver + redacted stable reason + fails closed + + + +VERIFIED + + unique exact contracts + result f2dce1e2b1e6… + positive issuance allowed + + +unsat + +non-unique + +resource error + +unique model + + +Detached certificate + + graph + registry + limits + toolchain + content-addressed, unsigned + only a positive verification claim + + + +Independent replay + + pure contract check + fresh bounded solver run + REPRODUCED ≠ authenticated + + +issue + +replay + 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 @@ + + + verified graph · positive certificate + Real UnitSentinel CLI output for the verified wheel anomaly feature graph and certificate issuance. + + + + + + + + + + + + + + + + + + + +verified graph · positive certificate + +$ .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/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 @@ + + + Verified wheel anomaly contract flow + A real six-operation ML feature pipeline with exact dimensions, unit scales, acceleration derivation, normalization, and sigmoid. + + + + + + + + + + + + + +Physics-informed wheel anomaly feature +Dimensions, scales, and kinds come from the live verified record. + + +wheel-speed-kph + + length^1 time^-1 + scale 5/18 · linear + + + +previous speed + + length^1 time^-1 + scale 5/18 · linear + + + +SUBTRACT + + speed-delta-kph + length^1 time^-1 + scale 5/18 · linear + + + +CONVERT → m/s + + speed-delta-si + length^1 time^-1 + scale 1 · linear + + + +sample-period-ms + + time^1 + scale 1/1000 · linear + + + +CONVERT → s + + sample-period-si + time^1 + scale 1 · linear + + + +DIVIDE Δv / Δt + + acceleration-si + length^1 time^-2 + scale 1 · linear + + + +reference accel. + + length^1 time^-2 + scale 1 · linear + + + +DIVIDE + SIGMOID + + normalized-acceleration + dimensionless + scale 1 · linear + anomaly-score + dimensionless + scale 1 · linear + + + + +5/18 → 1 + + + +time^1 + + + 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 @@ + + + serving contract bug · fail closed + Real UnitSentinel CLI output for the shape-valid but dimensionally conflicting wheel anomaly graph. + + + + + + + + + + + + + + + + + + + +serving contract bug · fail closed + +$ .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/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 @@ + + + verified graph · positive certificate + Real UnitSentinel CLI output for the verified wheel anomaly feature graph and certificate issuance. + + + + + + + + + + + + + + + + + + + +verified graph · positive certificate + +$ .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/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 @@ + + + detached certificate · strict semantic replay + Real UnitSentinel CLI output for strict replay of the recorded unsigned certificate claim. + + + + + + + + + + + + + + + + + + + +detached certificate · strict semantic replay + +$ .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/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