diff --git a/CHANGELOG.md b/CHANGELOG.md index d53f9aa..419e567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Thi ## [Unreleased] ### Added +- Assay fleet-tier contracts (cloud-mesh): `AssayRollup` (fleet/cohort aggregate of `ReasoningAssay` verdicts over a window — ok/sad/bad distribution, calibration-drift view of which `AssayStandard` versions are live across the fleet, unassayed-reason breakdown) and `AssayStandardRollout` (canary→widen/halt promotion of a new `AssayStandard` version across cohorts, gated on an observed `AssayRollup`; rides the release-bundle + lifecycle machinery). New `validate-assay-fleet-examples` target enforces aggregation/rollout soundness (distribution sums, drift-flag agreement, rolloutPct vs node share, decision/phase consistency, and no-promotion-by-hope), all verified biting. This is the boundary between prophet-mesh single-user-local (node self-view, no fleet dashboards, structural) and cloud-mesh (rollup + rollout + dashboards). See `schemas/assay-fleet.README.md`. - Trained DataClass classifiers — adversarial hardening (follow-up to #264): stronger fixture where BOTH monotone features (token_magnitude, decimal_places) genuinely drive the label so the monotone constraint actually BINDS (was vacuous — decimal_places held constant); standardiser fit on the TRAIN split only (no test leakage); ModelManifest named honestly `monotone-logistic` (a realisation of the declared tf-lattice-wide-and-deep contract, not a full lattice). Validator now checks BOTH per-class heads monotone AND asserts the constraint binds (non-vacuous), and compares reproduced accuracy at the stored 4-decimal precision (fixes a latent tolerance bug the trivial 1.0-accuracy fixture had masked). 8 teeth. - Trained DataClass classifiers v0.1 — specified→trained→registered→reproduced (closes the program's last owed thread): `tools/train_dataclass_classifiers.py` trains real per-class LOGISTIC (one-vs-rest) + per-table SOFTMAX heads, MONOTONE-constrained in `monotonicFeatures` (weight >=0 projection = TF-Lattice essence), deterministically; emits trained weights + a conformant `ModelManifest` (id=DataClass modelRef) + train/eval `RunRecord`s (ids=runRef/evalRunRef) so the previously-dangling DataClass classifier references now RESOLVE. `validate-trained-classifiers` (recompute-don't-trust): digest matches weight bytes; eval accuracy REPRODUCED from weights+fixture (inflated claim fails); model verified genuinely monotone (perturb up → score never drops); refs resolve; artifacts schema-conform. Teeth-verified (tampered weights refused). Uses numpy. - Learned spell-correction + user dictionary v0.1 — from context, not dictionary matching (task #13): `tools/learned_dictionary.py` decides each UNKNOWN token from a count-based skip-gram word-sense predictor (PPMI + truncated SVD; SGNS≈PPMI-SVD) instead of a static wordlist. LEARN a token that recurs with a coherent word-sense (a real domain term the dictionary hadn't seen); CORRECT a rare token to the known word its SENSE matches (skip-gram cosine picks the target, not edit distance alone — a near-spelled but different-sense token is not auto-corrected); leave the rest UNKNOWN (fail-closed, human-admitted). Every decision is a proposal, never a silent rewrite. `validate-learned-dictionary` teeth: epistemiclevel learned, reciept->receipt by sense, qwzptl unknown, learned term never auto-corrected. Uses numpy. diff --git a/Makefile b/Makefile index f495401..07f1598 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,12 @@ -.PHONY: validate validate-resource-contract validate-measurement validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-ingestion-pipeline-examples validate-data-acquisition-examples validate-glossary-alignment-examples validate-data-class-examples validate-table-keys validate-dag-loop validate-vocab-currency-loop vocab-currency-dogfood validate-glossary-promotion validate-agreement validate-stopword-analysis stopword-analysis-live validate-kgram-differential validate-learned-dictionary train-dataclass-classifiers validate-schema-references +.PHONY: validate validate-resource-contract validate-measurement validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-ingestion-pipeline-examples validate-data-acquisition-examples validate-glossary-alignment-examples validate-data-class-examples validate-table-keys validate-dag-loop validate-vocab-currency-loop vocab-currency-dogfood validate-glossary-promotion validate-agreement validate-stopword-analysis stopword-analysis-live validate-kgram-differential validate-learned-dictionary train-dataclass-classifiers validate-assay-fleet-examples validate-schema-references -validate: validate-data-class-examples validate-glossary-alignment-examples validate-data-acquisition-examples validate-ingestion-pipeline-examples validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-resource-contract validate-table-keys validate-dag-loop validate-vocab-currency-loop validate-glossary-promotion validate-agreement validate-stopword-analysis validate-kgram-differential validate-learned-dictionary validate-trained-classifiers validate-multiverseal-twin-examples validate-schema-references +validate: validate-data-class-examples validate-glossary-alignment-examples validate-data-acquisition-examples validate-ingestion-pipeline-examples validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-resource-contract validate-table-keys validate-dag-loop validate-vocab-currency-loop validate-glossary-promotion validate-agreement validate-stopword-analysis validate-kgram-differential validate-learned-dictionary validate-trained-classifiers validate-multiverseal-twin-examples validate-assay-fleet-examples validate-schema-references @echo "OK: validate" +validate-assay-fleet-examples: + python3 -m pip install --user jsonschema >/dev/null + python3 tools/validate_assay_fleet_examples.py + # General referential-integrity gate: every schema is a valid JSON Schema and every # $ref resolves. The per-example validators only exercise schemas that ship examples; # this catches a dangling/renamed/typo'd $ref in ANY of the 300+ schemas — a diff --git a/examples/assay_rollup.json b/examples/assay_rollup.json new file mode 100644 index 0000000..0b3aaab --- /dev/null +++ b/examples/assay_rollup.json @@ -0,0 +1,43 @@ +{ + "id": "urn:srcos:assay-rollup:fleet-2026-07-05", + "type": "AssayRollup", + "specVersion": "2.0.0", + "scope": { + "mode": "fleet", + "nodeCount": 12 + }, + "window": { + "from": "2026-07-05T00:00:00Z", + "to": "2026-07-05T01:00:00Z" + }, + "totalAssays": 100, + "distribution": { + "ok": 40, + "sad": 55, + "bad": 5 + }, + "byMethod": { + "computed": 30, + "retrieved": 25, + "generated": 45 + }, + "unassayedReasons": { + "post-hoc-binding": 30, + "uncalibrated-verifier": 15, + "correlated-arms": 10 + }, + "standardAdoption": [ + { + "calibrationRef": "urn:srcos:assay-standard:narration-fidelity-verifier:0.2.0", + "nodeCount": 9, + "calibrated": true + }, + { + "calibrationRef": "urn:srcos:assay-standard:deployed-nli:0.1.0", + "nodeCount": 3, + "calibrated": false + } + ], + "driftDetected": true, + "capturedAt": "2026-07-05T01:00:01Z" +} diff --git a/examples/assay_standard_rollout.json b/examples/assay_standard_rollout.json new file mode 100644 index 0000000..a0f7abe --- /dev/null +++ b/examples/assay_standard_rollout.json @@ -0,0 +1,30 @@ +{ + "id": "urn:srcos:assay-standard-rollout:narration-fidelity-0.3.0", + "type": "AssayStandardRollout", + "specVersion": "2.0.0", + "standardRef": "urn:srcos:assay-standard:narration-fidelity-verifier:0.3.0", + "supersedes": "urn:srcos:assay-standard:narration-fidelity-verifier:0.2.0", + "releaseBundleRef": "urn:srcos:runtime-release-bundle:assay-standards-2026-07-05", + "strategy": "canary", + "phase": "widening", + "cohorts": [ + { + "cohortId": "canary-a", + "nodeCount": 2, + "state": "promoted" + }, + { + "cohortId": "fleet-remainder", + "nodeCount": 10, + "state": "pending" + } + ], + "guard": { + "observedRollupRef": "urn:srcos:assay-rollup:fleet-2026-07-05", + "metric": "bad_rate_delta", + "decision": "continue" + }, + "rolloutPct": 16.7, + "startedAt": "2026-07-05T00:30:00Z", + "updatedAt": "2026-07-05T01:05:00Z" +} diff --git a/schemas/AssayRollup.json b/schemas/AssayRollup.json new file mode 100644 index 0000000..8bd0ff5 --- /dev/null +++ b/schemas/AssayRollup.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/AssayRollup.json", + "title": "AssayRollup", + "description": "A fleet/cohort aggregate of ReasoningAssay verdicts over a window. This is the CLOUD-MESH tier: it exists only where verdicts are aggregated across nodes. A single-user local deployment (scope.mode 'node') MAY emit a self-rollup of its own runs, but the fleet/cohort rollup and its dashboards are structurally absent on-device — the aggregation path is not built there, not merely permission-gated. Records the ok/sad/bad distribution, plus the two things a fleet operator cannot see from a single node: which AssayStandard versions are actually live across the fleet (calibration drift), and the breakdown of why claims are unassayed.", + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "specVersion", "scope", "window", "totalAssays", "distribution", "capturedAt"], + "properties": { + "id": { "type": "string", "pattern": "^urn:srcos:assay-rollup:", "description": "Stable URN for this rollup." }, + "type": { "const": "AssayRollup" }, + "specVersion": { "type": "string" }, + "scope": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "nodeCount"], + "description": "What this rollup aggregates over. 'node' is the on-device self-view (single-user local); 'cohort' and 'fleet' are cloud-mesh only.", + "properties": { + "mode": { "enum": ["node", "cohort", "fleet"] }, + "cohortId": { "type": "string", "description": "Present when mode is 'cohort'." }, + "nodeCount": { "type": "integer", "minimum": 1, "description": "Number of nodes contributing. 1 for a local self-view." } + } + }, + "window": { + "type": "object", + "additionalProperties": false, + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "format": "date-time" }, + "to": { "type": "string", "format": "date-time" } + } + }, + "totalAssays": { "type": "integer", "minimum": 0, "description": "Total assays in the window. distribution counts MUST sum to this." }, + "distribution": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "sad", "bad"], + "description": "Count of assays by projectedState. Sums to totalAssays (validator-enforced).", + "properties": { + "ok": { "type": "integer", "minimum": 0 }, + "sad": { "type": "integer", "minimum": 0 }, + "bad": { "type": "integer", "minimum": 0 } + } + }, + "byMethod": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 }, + "description": "Optional count by method (computed/retrieved/generated). Each value <= totalAssays." + }, + "unassayedReasons": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 }, + "description": "Optional breakdown of the 'sad' band by unassayedReason. Sum <= distribution.sad (validator-enforced): tells the operator WHY the fleet is amber." + }, + "standardAdoption": { + "type": "array", + "description": "Which AssayStandard versions are live across the fleet — the calibration-drift view. A fleet where some nodes verify against an uncalibrated or stale standard is silently weaker than its ok-count suggests.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["calibrationRef", "nodeCount", "calibrated"], + "properties": { + "calibrationRef": { "type": "string", "pattern": "^urn:srcos:assay-standard:" }, + "nodeCount": { "type": "integer", "minimum": 1, "description": "Nodes verifying against this standard version." }, + "calibrated": { "type": "boolean", "description": "Whether that standard is calibrated (mirrors AssayStandard.calibrated)." } + } + } + }, + "driftDetected": { + "type": "boolean", + "description": "True when nodes are split across divergent standard versions or any live standard is uncalibrated. Validator cross-checks this against standardAdoption." + }, + "capturedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/schemas/AssayStandardRollout.json b/schemas/AssayStandardRollout.json new file mode 100644 index 0000000..729b681 --- /dev/null +++ b/schemas/AssayStandardRollout.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/AssayStandardRollout.json", + "title": "AssayStandardRollout", + "description": "Governs promoting a new AssayStandard version across a fleet — the CLOUD-MESH rollout tier. When a verifier is re-measured and a better AssayStandard is published, nodes must not all switch at once: a bad new standard would silently re-project the whole fleet's verdicts. The rollout advances a canary cohort first, watches its AssayRollup, and widens or halts on that observed evidence. Rides the existing release-bundle + lifecycle machinery rather than a bespoke controller. Not emitted on single-user local deployments (nothing to roll out across).", + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "specVersion", "standardRef", "strategy", "phase", "cohorts", "startedAt"], + "properties": { + "id": { "type": "string", "pattern": "^urn:srcos:assay-standard-rollout:", "description": "Stable URN for this rollout." }, + "type": { "const": "AssayStandardRollout" }, + "specVersion": { "type": "string" }, + "standardRef": { "type": "string", "pattern": "^urn:srcos:assay-standard:", "description": "The new AssayStandard version being rolled out." }, + "supersedes": { "type": "string", "pattern": "^urn:srcos:assay-standard:", "description": "The prior standard version this replaces, if any." }, + "releaseBundleRef": { "type": "string", "description": "Optional runtime-release-bundle this rollout rides (content-hash-pinned promotion lane)." }, + "strategy": { "enum": ["canary", "staged", "immediate"], "description": "canary = one small cohort first; staged = successive cohorts; immediate = whole fleet at once (discouraged; allowed for hotfix of a broken standard)." }, + "phase": { + "enum": ["canary", "widening", "complete", "halted", "rolled-back"], + "description": "Lifecycle of the rollout. 'halted' pauses (guard.decision 'hold'); 'rolled-back' reverts to supersedes (guard.decision 'rollback'). A guard decision must be consistent with the phase (validator-enforced)." + }, + "cohorts": { + "type": "array", + "minItems": 1, + "description": "Fleet cohorts and their per-cohort state. rolloutPct is derived from these (validator-enforced).", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["cohortId", "nodeCount", "state"], + "properties": { + "cohortId": { "type": "string" }, + "nodeCount": { "type": "integer", "minimum": 1 }, + "state": { "enum": ["pending", "observing", "promoted", "held", "reverted"], "description": "pending = not yet on the new standard; observing = on it, being watched; promoted = accepted; held = paused; reverted = rolled back to supersedes." } + } + } + }, + "guard": { + "type": "object", + "additionalProperties": false, + "description": "The evidence that gates widening: an observed AssayRollup and the decision it drove. Widening without an observedRollupRef is promotion-by-hope, which this contract forbids.", + "properties": { + "observedRollupRef": { "type": "string", "pattern": "^urn:srcos:assay-rollup:", "description": "The canary cohort's AssayRollup that this decision was read from." }, + "metric": { "type": "string", "description": "What was watched, e.g. 'bad_rate_delta' or 'ok_rate'." }, + "decision": { "enum": ["continue", "hold", "rollback"], "description": "continue → widening/complete; hold → halted; rollback → rolled-back." } + } + }, + "rolloutPct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Fraction of fleet nodes on the new standard. Validator checks it matches the promoted/observing cohort node share." }, + "startedAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/schemas/assay-fleet.README.md b/schemas/assay-fleet.README.md new file mode 100644 index 0000000..4656289 --- /dev/null +++ b/schemas/assay-fleet.README.md @@ -0,0 +1,51 @@ +# Assay Fleet Tier (cloud-mesh) + +The node-tier Assay contracts (`ReasoningAssay`, `AssayStandard`) describe a verdict on a +single claim on a single node. The **fleet tier** adds the two contracts a cloud-mesh operator +needs and a single node cannot produce, and it is the boundary between the two prophet-mesh +deployment modes. + +## Deployment modes + +| | Single-user local | Cloud mesh (fleet) | +|---|---|---| +| locus | `local` / `trusted_private` | `attested_fog` / `burst_cloud` | +| node verdicts | `ReasoningAssay` stays on device | `ReasoningAssay` emitted per node | +| aggregation | self-view only (`AssayRollup` scope `node`) — optional | `AssayRollup` scope `cohort` / `fleet` | +| rollout | nothing to roll out | `AssayStandardRollout` across cohorts | +| dashboards | **none** | fleet + per-node views (external surface) | + +"No fleet dashboards for a single user" is a **structural** property, not a permission: on-device the +fleet/cohort aggregation path is not built, so there is nothing to display and nothing to leak. Only +the cloud-mesh deployment instantiates the rollup/rollout tier. + +## Schemas + +| Schema | URN prefix | Purpose | +|---|---|---| +| `AssayRollup.json` | `urn:srcos:assay-rollup:` | Fleet/cohort aggregate of verdicts over a window: ok/sad/bad distribution, calibration-drift view (which `AssayStandard` versions are live across the fleet), and the unassayed-reason breakdown. | +| `AssayStandardRollout.json` | `urn:srcos:assay-standard-rollout:` | Governs promoting a new `AssayStandard` version across cohorts — canary first, widen or halt on the observed `AssayRollup`. Rides the release-bundle + lifecycle machinery. | + +## Examples + +| Example | Schema | +|---|---| +| `examples/assay_rollup.json` | `AssayRollup` (fleet scope, drift detected) | +| `examples/assay_standard_rollout.json` | `AssayStandardRollout` (canary widening on an observed rollup) | + +## Enforced invariants + +`tools/validate_assay_fleet_examples.py` checks aggregation and rollout soundness, not just schema shape: + +- a rollup's `ok`/`sad`/`bad` counts sum to `totalAssays`; `unassayedReasons` cannot exceed the `sad` band; + `standardAdoption` node counts match `scope.nodeCount`; `driftDetected` must agree with the adoption table; +- a rollout's `rolloutPct` matches its promoted/observing node share; `guard.decision` is consistent with `phase`; + and a rollout may not have widened past canary without an observed `AssayRollup` — **no promotion-by-hope**. + +## The rollup → rollout loop + +A new `AssayStandard` version is not switched on fleet-wide at once — that would silently re-project every +node's verdicts against an unproven judge. The rollout advances a canary cohort, the canary emits an +`AssayRollup`, and `guard.decision` reads that rollup to `continue` / `hold` / `rollback`. Promotion is +gated on measured fleet evidence — the same "measured, not asserted" discipline the node tier applies to a +single claim, applied to a fleet-wide change. diff --git a/tools/validate_assay_fleet_examples.py b/tools/validate_assay_fleet_examples.py new file mode 100644 index 0000000..4217226 --- /dev/null +++ b/tools/validate_assay_fleet_examples.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Validate the Assay fleet-tier contracts (cloud-mesh): AssayRollup + AssayStandardRollout. + +Schema conformance plus aggregation/rollout soundness — the same discipline as the +node-tier validator, one level up: + * a rollup's ok/sad/bad counts must sum to totalAssays, its unassayedReasons must + not exceed the sad band, its standardAdoption node counts must match scope, and + driftDetected must agree with what the adoption table actually shows; + * a rollout's rolloutPct must match its promoted/observing node share, its + guard.decision must be consistent with its phase, and it may not have widened + past canary without an observed rollup (no promotion-by-hope). +""" +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema + +ROOT = Path(__file__).resolve().parents[1] + +PCT_TOLERANCE = 0.5 + +PAIRS = [ + (ROOT / "schemas" / "AssayRollup.json", ROOT / "examples" / "assay_rollup.json"), + (ROOT / "schemas" / "AssayStandardRollout.json", ROOT / "examples" / "assay_standard_rollout.json"), +] + + +def check_rollup(r: dict, name: str) -> None: + dist = r["distribution"] + total = r["totalAssays"] + dist_sum = dist["ok"] + dist["sad"] + dist["bad"] + if dist_sum != total: + raise SystemExit(f"rollup {name}: distribution sums to {dist_sum} but totalAssays={total}") + + for field in ("byMethod", "unassayedReasons"): + block = r.get(field) + if not block: + continue + s = sum(block.values()) + cap = dist["sad"] if field == "unassayedReasons" else total + if s > cap: + raise SystemExit(f"rollup {name}: {field} sums to {s} but cap is {cap}") + + adoption = r.get("standardAdoption") + if adoption: + node_sum = sum(a["nodeCount"] for a in adoption) + if node_sum != r["scope"]["nodeCount"]: + raise SystemExit( + f"rollup {name}: standardAdoption node counts sum to {node_sum} " + f"but scope.nodeCount={r['scope']['nodeCount']}" + ) + versions = {a["calibrationRef"] for a in adoption} + any_uncalibrated = any(not a["calibrated"] for a in adoption) + expected_drift = len(versions) > 1 or any_uncalibrated + if "driftDetected" in r and r["driftDetected"] != expected_drift: + raise SystemExit( + f"rollup {name}: driftDetected={r['driftDetected']} but adoption table " + f"(versions={len(versions)}, any_uncalibrated={any_uncalibrated}) implies {expected_drift}" + ) + + +def check_rollout(r: dict, name: str) -> None: + cohorts = r["cohorts"] + total_nodes = sum(c["nodeCount"] for c in cohorts) + on_new = sum(c["nodeCount"] for c in cohorts if c["state"] in ("promoted", "observing")) + if "rolloutPct" in r and total_nodes: + expected = 100.0 * on_new / total_nodes + if abs(r["rolloutPct"] - expected) > PCT_TOLERANCE: + raise SystemExit( + f"rollout {name}: rolloutPct={r['rolloutPct']} but promoted/observing share " + f"is {expected:.1f}% ({on_new}/{total_nodes})" + ) + + phase = r["phase"] + guard = r.get("guard") or {} + decision = guard.get("decision") + if decision: + expected_phase = { + "continue": {"canary", "widening", "complete"}, + "hold": {"halted"}, + "rollback": {"rolled-back"}, + }[decision] + if phase not in expected_phase: + raise SystemExit( + f"rollout {name}: guard.decision={decision!r} is inconsistent with phase={phase!r}" + ) + + # No promotion-by-hope: past canary requires an observed rollup. + if phase in ("widening", "complete") and not guard.get("observedRollupRef"): + raise SystemExit( + f"rollout {name}: phase={phase!r} but no guard.observedRollupRef — widening " + "must be gated on an observed AssayRollup, not hope" + ) + + +def validate_pair(schema_path: Path, example_path: Path) -> None: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + jsonschema.validators.validator_for(schema).check_schema(schema) + example = json.loads(example_path.read_text(encoding="utf-8")) + jsonschema.validate(example, schema) + if example.get("type") == "AssayRollup": + check_rollup(example, example_path.name) + if example.get("type") == "AssayStandardRollout": + check_rollout(example, example_path.name) + + +def main() -> int: + checks: dict[str, bool] = {} + for schema_path, example_path in PAIRS: + validate_pair(schema_path, example_path) + checks[example_path.name] = True + print(json.dumps({"ok": all(checks.values()), "checks": checks}, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())