From e97774c17da16f2088d64832296f06f42f5cb60f Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 16:04:24 +0200 Subject: [PATCH 1/7] feat: add schema inference receipt handoff Problem Schema commits accepted no durable handoff from the pristine schema-inference gate, so inferred-corpus campaign loading could proceed from catalog-only data without binding package contents or explicit unsupported decisions. What changed Add an immutable content-addressed handoff receipt for the accepted gate digest, origin/provider coverage, persisted package/version/element hashes, and unsupported or nonrepresentable elements. Require the receipt at schema commit time, aggregate it beside the registered packages, expose it through the operator result and CLI, and validate it against the real registry when campaign manifests are compiled or loaded. Ref polylogue-tnqqt and polylogue-r9xsj. Compatibility/migration The schema commit CLI now requires --schema-inference-gate-receipt. Existing catalog-only manifest reads remain available outside campaign mode. No reindex/rebuild modules or wire formats changed. --- devtools/schema_commit.py | 11 + .../maintenance/schema_inference_gate.py | 8 + polylogue/schemas/operator/commit.py | 51 ++ polylogue/schemas/operator/models.py | 6 + polylogue/schemas/operator/receipt.py | 556 ++++++++++++++++++ tests/infra/inferred_corpus.py | 100 +++- .../devtools/test_schema_commit_command.py | 42 +- .../maintenance/test_schema_inference_gate.py | 11 + .../schemas/test_inferred_corpus_manifest.py | 71 +++ tests/unit/schemas/test_operator_commit.py | 89 ++- 10 files changed, 913 insertions(+), 32 deletions(-) create mode 100644 polylogue/schemas/operator/receipt.py diff --git a/devtools/schema_commit.py b/devtools/schema_commit.py index e31f5393a1..e1a7d96c54 100644 --- a/devtools/schema_commit.py +++ b/devtools/schema_commit.py @@ -56,6 +56,12 @@ def _build_parser() -> argparse.ArgumentParser: help="Privacy preset level. Defaults to standard.", ) parser.add_argument("--privacy-config", type=Path, default=None, help="Path to TOML privacy config overrides.") + parser.add_argument( + "--schema-inference-gate-receipt", + type=Path, + required=True, + help="Accepted PASS receipt from devtools verify schema-inference-gate.", + ) parser.add_argument( "--dry-run", "--check", @@ -90,6 +96,7 @@ def main(argv: list[str] | None = None) -> int: privacy_config=privacy_config, full_corpus=bool(args.full_corpus), dry_run=bool(args.dry_run), + schema_inference_gate_receipt_path=args.schema_inference_gate_receipt, ) ) @@ -107,6 +114,10 @@ def main(argv: list[str] | None = None) -> int: mode = "DRY RUN (no files written)" if result.dry_run else f"committed to {output_dir}" print(f"schema-commit: {result.provider} -- {mode}") print(f" sample_count={result.generation.sample_count}") + if result.handoff is not None: + print(f" handoff_digest={result.handoff.receipt_digest}") + if result.handoff_path is not None: + print(f" handoff_path={result.handoff_path}") for version_report in result.versions: flags = [] if version_report.narrowed_paths: diff --git a/polylogue/maintenance/schema_inference_gate.py b/polylogue/maintenance/schema_inference_gate.py index 54354160cc..c1714bec17 100644 --- a/polylogue/maintenance/schema_inference_gate.py +++ b/polylogue/maintenance/schema_inference_gate.py @@ -1014,6 +1014,13 @@ def _as_dict(value: object) -> dict[str, object]: return cast(dict[str, object], value) if isinstance(value, dict) else {} +def schema_inference_gate_receipt_digest(payload: Mapping[str, object]) -> str: + """Return the content digest used when a PASS gate is handed off.""" + + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + def _int_or_zero(value: object) -> int: return value if isinstance(value, int) and not isinstance(value, bool) else 0 @@ -1146,5 +1153,6 @@ def run_schema_inference_gate( "RECEIPT_SCHEMA", "SchemaInferenceGateError", "SchemaInferenceGateResult", + "schema_inference_gate_receipt_digest", "run_schema_inference_gate", ] diff --git a/polylogue/schemas/operator/commit.py b/polylogue/schemas/operator/commit.py index 7570b15744..04c67f7a7f 100644 --- a/polylogue/schemas/operator/commit.py +++ b/polylogue/schemas/operator/commit.py @@ -30,15 +30,26 @@ from __future__ import annotations +import json import shutil import tempfile +from collections.abc import Mapping from pathlib import Path +from typing import cast from polylogue.core.json import JSONDocument +from polylogue.maintenance.schema_inference_gate import RECEIPT_SCHEMA, schema_inference_gate_receipt_digest from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.generation.workflow import generate_all_schemas from polylogue.schemas.operator.inference import privacy_config_from_payload from polylogue.schemas.operator.models import SchemaCommitRequest, SchemaCommitResult, SchemaVersionCommitReport +from polylogue.schemas.operator.receipt import ( + SCHEMA_INFERENCE_HANDOFF_FILENAME, + SchemaInferenceReceipt, + build_schema_inference_receipt, + load_schema_inference_receipt, + write_schema_inference_receipt, +) from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.runtime_registry import canonical_schema_provider from polylogue.schemas.type_narrowing import added_paths, narrowed_paths @@ -52,8 +63,25 @@ def _element_schemas_by_kind( } +def _accepted_gate_receipt_digest(path: Path | None) -> str: + if path is None: + raise ValueError("schema commit requires an accepted schema-inference gate receipt path") + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError) as exc: + raise ValueError(f"unable to read schema-inference gate receipt {path}: {exc}") from exc + if not isinstance(payload, Mapping): + raise ValueError("schema-inference gate receipt must be a JSON object") + if payload.get("schema") != RECEIPT_SCHEMA: + raise ValueError(f"schema-inference gate receipt must use schema {RECEIPT_SCHEMA!r}") + if payload.get("verdict") != "PASS": + raise ValueError("schema-inference gate receipt must have verdict PASS") + return schema_inference_gate_receipt_digest(cast(Mapping[str, object], payload)) + + def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommitResult: provider_token = str(canonical_schema_provider(request.provider)) + gate_receipt_digest = _accepted_gate_receipt_digest(request.schema_inference_gate_receipt_path) registry_before = SchemaRegistry(storage_root=output_dir) catalog_before = registry_before.load_package_catalog(provider_token) @@ -124,11 +152,30 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit ) ) + handoff: SchemaInferenceReceipt | None = None + handoff_path: Path | None = None + if generation.success: + registry_after = SchemaRegistry(storage_root=output_dir) + provider_handoff = build_schema_inference_receipt( + registry_after, + provider=provider_token, + gate_receipt_digest=gate_receipt_digest, + ) + handoff = provider_handoff + existing_path = output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME + if existing_path.exists(): + handoff = load_schema_inference_receipt(existing_path).merged_with(provider_handoff) + write_schema_inference_receipt(handoff, existing_path) + if not request.dry_run: + handoff_path = existing_path + return SchemaCommitResult( provider=request.provider, generation=generation, versions=tuple(version_reports), dry_run=request.dry_run, + handoff=handoff, + handoff_path=handoff_path, ) @@ -151,12 +198,16 @@ def commit_provider_schema(request: SchemaCommitRequest) -> SchemaCommitResult: committed_provider_dir = request.output_dir / provider_token if committed_provider_dir.exists(): shutil.copytree(committed_provider_dir, staging_root / provider_token) + handoff_path = request.output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME + if handoff_path.exists(): + shutil.copy2(handoff_path, staging_root / SCHEMA_INFERENCE_HANDOFF_FILENAME) result = _commit_into(request, staging_root) return SchemaCommitResult( provider=result.provider, generation=result.generation, versions=result.versions, dry_run=True, + handoff=result.handoff, ) diff --git a/polylogue/schemas/operator/models.py b/polylogue/schemas/operator/models.py index 36a31c3ea5..f7cfd5c948 100644 --- a/polylogue/schemas/operator/models.py +++ b/polylogue/schemas/operator/models.py @@ -9,6 +9,7 @@ from polylogue.scenarios import CorpusScenario, CorpusSpec from polylogue.schemas.generation.models import GenerationProgressCallback, GenerationResult +from polylogue.schemas.operator.receipt import SchemaInferenceReceipt from polylogue.schemas.packages import SchemaPackageCatalog, SchemaResolution, SchemaVersionPackage from polylogue.schemas.tooling_registry import ClusterManifest, SchemaDiff from polylogue.schemas.validation.models import ArtifactCoverageReport @@ -364,6 +365,7 @@ class SchemaCommitRequest: privacy_config: JSONDocument | None = None full_corpus: bool = True dry_run: bool = False + schema_inference_gate_receipt_path: Path | None = None @dataclass(frozen=True) @@ -394,6 +396,8 @@ class SchemaCommitResult: generation: GenerationResult versions: tuple[SchemaVersionCommitReport, ...] dry_run: bool + handoff: SchemaInferenceReceipt | None = None + handoff_path: Path | None = None @property def success(self) -> bool: @@ -412,4 +416,6 @@ def to_dict(self) -> JSONDocument: "dry_run": self.dry_run, "sample_count": self.generation.sample_count, "versions": [report.to_dict() for report in self.versions], + "handoff": self.handoff.to_payload() if self.handoff is not None else None, + "handoff_path": str(self.handoff_path) if self.handoff_path is not None else None, } diff --git a/polylogue/schemas/operator/receipt.py b/polylogue/schemas/operator/receipt.py new file mode 100644 index 0000000000..41c2d42948 --- /dev/null +++ b/polylogue/schemas/operator/receipt.py @@ -0,0 +1,556 @@ +"""Content-addressed handoff receipts for committed inferred schemas.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, Protocol, TypeAlias, cast + +from polylogue.core.hashing import hash_file, hash_payload +from polylogue.core.json import JSONDocument +from polylogue.core.sources import origin_from_provider +from polylogue.schemas.operator.registry import RuntimeSchemaRegistryLike +from polylogue.schemas.packages import SchemaPackageCatalog, SchemaVersionPackage +from polylogue.schemas.runtime_registry import canonical_schema_provider +from polylogue.schemas.synthetic.wire_formats import PROVIDER_WIRE_FORMATS + +SCHEMA_INFERENCE_HANDOFF_SCHEMA = "polylogue.schema-inference-handoff.v1" +SCHEMA_INFERENCE_HANDOFF_FILENAME = "schema-inference-handoff.json" + +CoverageDecision: TypeAlias = Literal["committed", "unsupported", "nonrepresentable"] +_COVERAGE_DECISIONS = frozenset({"committed", "unsupported", "nonrepresentable"}) +_SUPPORTED_SCHEMA_KEYS = frozenset( + { + "$anchor", + "$comment", + "$id", + "$schema", + "additionalProperties", + "anyOf", + "default", + "deprecated", + "description", + "examples", + "items", + "oneOf", + "properties", + "readOnly", + "title", + "type", + "writeOnly", + "x-polylogue-array-lengths", + "x-polylogue-foreign-keys", + "x-polylogue-format", + "x-polylogue-frequency", + "x-polylogue-multiline", + "x-polylogue-mutually-exclusive", + "x-polylogue-observed-distribution", + "x-polylogue-range", + "x-polylogue-semantic-role", + "x-polylogue-string-lengths", + "x-polylogue-time-deltas", + "x-polylogue-values", + } +) +_PERSISTED_SCHEMA_METADATA_ANNOTATIONS = frozenset( + { + "x-polylogue-anchor-profile-family-id", + "x-polylogue-artifact-kind", + "x-polylogue-element-bundle-scope-count", + "x-polylogue-element-first-seen", + "x-polylogue-element-kind", + "x-polylogue-element-last-seen", + "x-polylogue-evidence", + "x-polylogue-evidence-confidence", + "x-polylogue-exact-structure-ids", + "x-polylogue-generated-at", + "x-polylogue-generator", + "x-polylogue-high-cardinality-keys", + "x-polylogue-observed-artifact-count", + "x-polylogue-package-profile-family-ids", + "x-polylogue-package-version", + "x-polylogue-profile-family-ids", + "x-polylogue-profile-tokens", + "x-polylogue-promoted-at", + "x-polylogue-registered-at", + "x-polylogue-sample-count", + "x-polylogue-sample-granularity", + "x-polylogue-score", + "x-polylogue-version", + } +) +_SCHEMA_MAPPING_KEYS = frozenset( + { + "$defs", + "additionalProperties", + "contentSchema", + "contains", + "dependentSchemas", + "dependencies", + "else", + "if", + "items", + "not", + "patternProperties", + "properties", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + } +) +_SCHEMA_ARRAY_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) + + +def _require_digest(value: object, *, field: str) -> str: + if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdef" for char in value): + raise ValueError(f"{field} must be a lowercase SHA-256 digest") + return value + + +def _canonical_payload(payload: Mapping[str, object]) -> str: + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) + + +def _walk_schema_keys(value: object, found: set[str]) -> None: + if isinstance(value, Mapping): + for key, child in value.items(): + if isinstance(key, str) and key.startswith("x-"): + if key not in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS: + found.add(key) + continue + if isinstance(key, str): + found.add(key) + if key == "properties" and isinstance(child, Mapping): + for property_schema in child.values(): + _walk_schema_keys(property_schema, found) + elif key in _SCHEMA_MAPPING_KEYS: + _walk_schema_keys(child, found) + elif key in _SCHEMA_ARRAY_KEYS and isinstance(child, Sequence): + for branch in child: + _walk_schema_keys(branch, found) + elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + for child in value: + _walk_schema_keys(child, found) + + +@dataclass(frozen=True, order=True, slots=True) +class SchemaInferenceCoverageDecision: + """The explicit decision for one origin/provider pair in a handoff.""" + + origin: str + provider: str + decision: CoverageDecision + reason: str | None = None + + def __post_init__(self) -> None: + if not self.origin or not self.provider or self.decision not in _COVERAGE_DECISIONS: + raise ValueError("schema inference coverage decisions require identity and a valid decision") + if self.decision != "committed" and not self.reason: + raise ValueError("unsupported or nonrepresentable coverage decisions require a reason") + + def to_payload(self) -> JSONDocument: + return { + "origin": self.origin, + "provider": self.provider, + "decision": self.decision, + "reason": self.reason, + } + + +@dataclass(frozen=True, order=True, slots=True) +class SchemaInferenceUnsupportedDecision: + """A typed refusal for an element that cannot enter the inferred corpus.""" + + provider: str + package_version: str + element_kind: str + decision: Literal["unsupported", "nonrepresentable"] + reason: str + details: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not self.provider or not self.package_version or not self.element_kind: + raise ValueError("unsupported schema decisions require package identity") + if self.decision not in {"unsupported", "nonrepresentable"} or not self.reason: + raise ValueError("unsupported schema decisions require a supported decision and reason") + if tuple(sorted(set(self.details))) != self.details: + raise ValueError("unsupported schema decision details must be sorted and unique") + + def to_payload(self) -> JSONDocument: + return { + "provider": self.provider, + "package_version": self.package_version, + "element_kind": self.element_kind, + "decision": self.decision, + "reason": self.reason, + "details": list(self.details), + } + + +@dataclass(frozen=True, order=True, slots=True) +class SchemaElementContentHash: + element_kind: str + content_hash: str + + def __post_init__(self) -> None: + if not self.element_kind: + raise ValueError("schema element hash requires an element kind") + _require_digest(self.content_hash, field="element content_hash") + + def to_payload(self) -> JSONDocument: + return {"element_kind": self.element_kind, "content_hash": self.content_hash} + + +@dataclass(frozen=True, order=True, slots=True) +class SchemaPackageContentHash: + """Exact persisted hashes for one package version and its elements.""" + + provider: str + package_version: str + package_hash: str + version_hash: str + element_hashes: tuple[SchemaElementContentHash, ...] + + def __post_init__(self) -> None: + if not self.provider or not self.package_version: + raise ValueError("schema package hashes require provider and version") + _require_digest(self.package_hash, field="package_hash") + _require_digest(self.version_hash, field="version_hash") + if tuple(sorted(self.element_hashes)) != self.element_hashes: + raise ValueError("schema element hashes must be sorted") + if len({item.element_kind for item in self.element_hashes}) != len(self.element_hashes): + raise ValueError("schema package element hashes must be unique") + + def to_payload(self) -> JSONDocument: + return { + "provider": self.provider, + "package_version": self.package_version, + "package_hash": self.package_hash, + "version_hash": self.version_hash, + "element_hashes": [item.to_payload() for item in self.element_hashes], + } + + +@dataclass(frozen=True, slots=True) +class SchemaInferenceReceipt: + """Immutable aggregate handoff from pristine gate to inferred corpus.""" + + gate_receipt_digest: str + coverage_decisions: tuple[SchemaInferenceCoverageDecision, ...] + packages: tuple[SchemaPackageContentHash, ...] + unsupported_decisions: tuple[SchemaInferenceUnsupportedDecision, ...] = () + + def __post_init__(self) -> None: + _require_digest(self.gate_receipt_digest, field="gate_receipt_digest") + if tuple(sorted(self.coverage_decisions)) != self.coverage_decisions: + raise ValueError("coverage decisions must be sorted") + if tuple(sorted(self.packages)) != self.packages: + raise ValueError("package hashes must be sorted") + if tuple(sorted(self.unsupported_decisions)) != self.unsupported_decisions: + raise ValueError("unsupported decisions must be sorted") + coverage_keys = [(item.origin, item.provider) for item in self.coverage_decisions] + if len(coverage_keys) != len(set(coverage_keys)): + raise ValueError("coverage decisions must have unique origin/provider pairs") + package_keys = [(item.provider, item.package_version) for item in self.packages] + if len(package_keys) != len(set(package_keys)): + raise ValueError("package hashes must have unique provider/version pairs") + unsupported_keys = [ + (item.provider, item.package_version, item.element_kind) for item in self.unsupported_decisions + ] + if len(unsupported_keys) != len(set(unsupported_keys)): + raise ValueError("unsupported decisions must have unique package elements") + + @property + def receipt_digest(self) -> str: + return hash_payload(self._payload_without_digest()) + + def _payload_without_digest(self) -> JSONDocument: + return { + "schema": SCHEMA_INFERENCE_HANDOFF_SCHEMA, + "gate_receipt_digest": self.gate_receipt_digest, + "coverage_decisions": [item.to_payload() for item in self.coverage_decisions], + "packages": [item.to_payload() for item in self.packages], + "unsupported_decisions": [item.to_payload() for item in self.unsupported_decisions], + } + + def to_payload(self) -> JSONDocument: + return {**self._payload_without_digest(), "receipt_digest": self.receipt_digest} + + def merged_with(self, other: SchemaInferenceReceipt) -> SchemaInferenceReceipt: + if self.gate_receipt_digest != other.gate_receipt_digest: + raise ValueError("schema inference handoffs use different gate receipt digests") + providers = {item.provider for item in other.coverage_decisions} + coverage = tuple( + sorted( + [item for item in self.coverage_decisions if item.provider not in providers] + + list(other.coverage_decisions) + ) + ) + package_providers = {item.provider for item in other.packages} + packages = tuple( + sorted([item for item in self.packages if item.provider not in package_providers] + list(other.packages)) + ) + unsupported = tuple( + sorted( + [item for item in self.unsupported_decisions if item.provider not in package_providers] + + list(other.unsupported_decisions) + ) + ) + return SchemaInferenceReceipt(self.gate_receipt_digest, coverage, packages, unsupported) + + @classmethod + def from_payload(cls, payload: Mapping[str, object]) -> SchemaInferenceReceipt: + expected = { + "schema", + "gate_receipt_digest", + "coverage_decisions", + "packages", + "unsupported_decisions", + "receipt_digest", + } + if set(payload) != expected or payload.get("schema") != SCHEMA_INFERENCE_HANDOFF_SCHEMA: + raise ValueError("schema inference handoff fields or schema changed") + receipt = cls( + gate_receipt_digest=_require_digest(payload.get("gate_receipt_digest"), field="gate_receipt_digest"), + coverage_decisions=tuple( + _coverage_from_payload(item) for item in _list_of_mappings(payload, "coverage_decisions") + ), + packages=tuple(_package_from_payload(item) for item in _list_of_mappings(payload, "packages")), + unsupported_decisions=tuple( + _unsupported_from_payload(item) for item in _list_of_mappings(payload, "unsupported_decisions") + ), + ) + if payload.get("receipt_digest") != receipt.receipt_digest: + raise ValueError("schema inference handoff receipt digest mismatch") + return receipt + + +def _list_of_mappings(payload: Mapping[str, object], field: str) -> list[Mapping[str, object]]: + value = payload.get(field) + if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value): + raise ValueError(f"schema inference handoff {field} must be a list of objects") + return [cast(Mapping[str, object], item) for item in value] + + +def _coverage_from_payload(payload: Mapping[str, object]) -> SchemaInferenceCoverageDecision: + if set(payload) != {"origin", "provider", "decision", "reason"}: + raise ValueError("coverage decision fields changed") + reason = payload.get("reason") + if not isinstance(reason, str): + reason = None + return SchemaInferenceCoverageDecision( + origin=str(payload.get("origin")), + provider=str(payload.get("provider")), + decision=cast(CoverageDecision, payload.get("decision")), + reason=reason, + ) + + +def _unsupported_from_payload(payload: Mapping[str, object]) -> SchemaInferenceUnsupportedDecision: + if set(payload) != {"provider", "package_version", "element_kind", "decision", "reason", "details"}: + raise ValueError("unsupported decision fields changed") + details = payload.get("details") + if not isinstance(details, list) or not all(isinstance(item, str) for item in details): + raise ValueError("unsupported decision details must be a list of strings") + return SchemaInferenceUnsupportedDecision( + provider=str(payload.get("provider")), + package_version=str(payload.get("package_version")), + element_kind=str(payload.get("element_kind")), + decision=cast(Literal["unsupported", "nonrepresentable"], payload.get("decision")), + reason=str(payload.get("reason")), + details=tuple(details), + ) + + +def _element_from_payload(payload: Mapping[str, object]) -> SchemaElementContentHash: + if set(payload) != {"element_kind", "content_hash"}: + raise ValueError("element hash fields changed") + return SchemaElementContentHash( + str(payload.get("element_kind")), _require_digest(payload.get("content_hash"), field="content_hash") + ) + + +def _package_from_payload(payload: Mapping[str, object]) -> SchemaPackageContentHash: + if set(payload) != {"provider", "package_version", "package_hash", "version_hash", "element_hashes"}: + raise ValueError("package hash fields changed") + return SchemaPackageContentHash( + provider=str(payload.get("provider")), + package_version=str(payload.get("package_version")), + package_hash=_require_digest(payload.get("package_hash"), field="package_hash"), + version_hash=_require_digest(payload.get("version_hash"), field="version_hash"), + element_hashes=tuple(_element_from_payload(item) for item in _list_of_mappings(payload, "element_hashes")), + ) + + +class SchemaReceiptRegistry(RuntimeSchemaRegistryLike, Protocol): + """Filesystem-backed registry operations required to hash persisted files.""" + + @property + def storage_root(self) -> Path: ... + + +def _package_hashes_for_package( + registry: SchemaReceiptRegistry, provider: str, package: SchemaVersionPackage +) -> SchemaPackageContentHash: + storage_root = registry.storage_root + provider_token = str(canonical_schema_provider(provider)) + package_dir = storage_root / provider_token / "versions" / package.version + package_path = package_dir / "package.json" + if not package_path.exists(): + raise ValueError(f"persisted schema package is missing: {package_path}") + element_hashes: list[SchemaElementContentHash] = [] + version_files: list[dict[str, str]] = [{"path": "package.json", "hash": hash_file(package_path)}] + for element in sorted(package.elements, key=lambda item: item.element_kind): + if element.schema_file is None: + continue + path = package_dir / "elements" / element.schema_file + if not path.exists(): + raise ValueError(f"persisted schema element is missing: {path}") + content_hash = hash_file(path) + element_hashes.append(SchemaElementContentHash(element.element_kind, content_hash)) + version_files.append({"path": f"elements/{element.schema_file}", "hash": content_hash}) + if package.workload_profile_file is not None: + path = package_dir / package.workload_profile_file + if not path.exists(): + raise ValueError(f"persisted schema workload profile is missing: {path}") + content_hash = hash_file(path) + version_files.append({"path": package.workload_profile_file, "hash": content_hash}) + return SchemaPackageContentHash( + provider=provider_token, + package_version=package.version, + package_hash=hash_file(package_path), + version_hash=hash_payload(version_files), + element_hashes=tuple(element_hashes), + ) + + +def package_hashes_for_registry( + registry: SchemaReceiptRegistry, providers: Sequence[str] | None = None +) -> tuple[SchemaPackageContentHash, ...]: + provider_names = tuple(providers) if providers is not None else tuple(registry.list_providers()) + records: list[SchemaPackageContentHash] = [] + for provider in sorted(set(provider_names)): + catalog = registry.load_package_catalog(provider) + if not isinstance(catalog, SchemaPackageCatalog): + raise ValueError(f"registry provider {provider!r} has no persisted package catalog") + records.extend(_package_hashes_for_package(registry, provider, package) for package in catalog.packages) + return tuple(sorted(records)) + + +def _unsupported_for_package( + registry: SchemaReceiptRegistry, provider: str, package: SchemaVersionPackage +) -> tuple[SchemaInferenceUnsupportedDecision, ...]: + decisions: list[SchemaInferenceUnsupportedDecision] = [] + if provider not in PROVIDER_WIRE_FORMATS: + for element in package.elements: + decisions.append( + SchemaInferenceUnsupportedDecision( + provider, + package.version, + element.element_kind, + "unsupported", + "provider_without_wire_format", + ) + ) + return tuple(sorted(decisions)) + for element in package.elements: + if not element.supported or element.schema_file is None: + decisions.append( + SchemaInferenceUnsupportedDecision( + provider, + package.version, + element.element_kind, + "unsupported", + "unsupported_element" if not element.supported else "missing_schema", + ) + ) + continue + schema = registry.get_element_schema(provider, version=package.version, element_kind=element.element_kind) + if not isinstance(schema, Mapping): + decisions.append( + SchemaInferenceUnsupportedDecision( + provider, package.version, element.element_kind, "unsupported", "missing_schema" + ) + ) + continue + keys: set[str] = set() + _walk_schema_keys(schema, keys) + unsupported = tuple(sorted(keys - _SUPPORTED_SCHEMA_KEYS)) + schema_type = schema.get("type") + if "x-polylogue-observed-distribution" in schema and ( + not isinstance(schema_type, str) or schema_type not in {"array", "number", "integer"} + ): + unsupported = tuple(sorted(set(unsupported) | {"x-polylogue-observed-distribution"})) + if unsupported: + decisions.append( + SchemaInferenceUnsupportedDecision( + provider, + package.version, + element.element_kind, + "nonrepresentable", + "unsupported_json_schema_construct", + unsupported, + ) + ) + return tuple(sorted(decisions)) + + +def build_schema_inference_receipt( + registry: SchemaReceiptRegistry, *, provider: str, gate_receipt_digest: str +) -> SchemaInferenceReceipt: + provider_token = str(canonical_schema_provider(provider)) + catalog = registry.load_package_catalog(provider_token) + if not isinstance(catalog, SchemaPackageCatalog) or not catalog.packages: + raise ValueError(f"schema commit produced no persisted packages for {provider_token}") + packages = tuple( + sorted(_package_hashes_for_package(registry, provider_token, package) for package in catalog.packages) + ) + unsupported = tuple( + sorted( + item for package in catalog.packages for item in _unsupported_for_package(registry, provider_token, package) + ) + ) + origin = origin_from_provider(provider_token).value + coverage = SchemaInferenceCoverageDecision( + origin=origin, + provider=provider_token, + decision="committed", + reason="persisted package/version/element hashes recorded", + ) + return SchemaInferenceReceipt(gate_receipt_digest, (coverage,), packages, unsupported) + + +def load_schema_inference_receipt(path: Path) -> SchemaInferenceReceipt: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError) as exc: + raise ValueError(f"unable to read schema inference handoff {path}: {exc}") from exc + if not isinstance(payload, Mapping): + raise ValueError("schema inference handoff root must be an object") + return SchemaInferenceReceipt.from_payload(cast(Mapping[str, object], payload)) + + +def write_schema_inference_receipt(receipt: SchemaInferenceReceipt, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text(json.dumps(receipt.to_payload(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + + +__all__ = [ + "SCHEMA_INFERENCE_HANDOFF_FILENAME", + "SCHEMA_INFERENCE_HANDOFF_SCHEMA", + "SchemaElementContentHash", + "SchemaInferenceCoverageDecision", + "SchemaInferenceReceipt", + "SchemaInferenceUnsupportedDecision", + "SchemaPackageContentHash", + "SchemaReceiptRegistry", + "build_schema_inference_receipt", + "load_schema_inference_receipt", + "package_hashes_for_registry", + "write_schema_inference_receipt", +] diff --git a/tests/infra/inferred_corpus.py b/tests/infra/inferred_corpus.py index a1fdc2236f..46d1433e49 100644 --- a/tests/infra/inferred_corpus.py +++ b/tests/infra/inferred_corpus.py @@ -19,7 +19,13 @@ from typing import Literal, TypeAlias, cast from polylogue.core.json import JSONDocument +from polylogue.core.sources import origin_from_provider from polylogue.scenarios import CorpusSpec +from polylogue.schemas.operator.receipt import ( + SchemaInferenceReceipt, + SchemaReceiptRegistry, + package_hashes_for_registry, +) from polylogue.schemas.operator.registry import RuntimeSchemaRegistryLike from polylogue.schemas.packages import SchemaElementManifest, SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.synthetic import SyntheticCorpus @@ -398,6 +404,12 @@ def from_payload(cls, payload: Mapping[str, object]) -> InferredCorpusManifest: return manifest +def _require_inference_handoff(manifest: InferredCorpusManifest) -> SchemaInferenceReceipt: + if manifest.receipt_state == "catalog_only" or manifest.package_receipt is None: + raise ValueError("campaign mode requires a persisted schema-inference handoff, not catalog-only data") + return SchemaInferenceReceipt.from_payload(manifest.package_receipt) + + @dataclass(frozen=True) class InferredCorpusConvergenceHandoff: """Exact executable manifest subset admitted to the convergence loop.""" @@ -552,7 +564,7 @@ def write_inferred_corpus_manifest(manifest: InferredCorpusManifest, path: Path) ) -def read_inferred_corpus_manifest(path: Path) -> InferredCorpusManifest: +def read_inferred_corpus_manifest(path: Path, *, campaign_mode: bool = False) -> InferredCorpusManifest: """Read and validate a persisted manifest before exposing executable rows.""" try: @@ -565,7 +577,10 @@ def read_inferred_corpus_manifest(path: Path) -> InferredCorpusManifest: raise ValueError(f"unable to read inferred corpus manifest {path}: {exc}") from exc if not isinstance(payload, dict): raise ValueError("inferred corpus manifest root must be a JSON object") - return InferredCorpusManifest.from_payload(payload) + manifest = InferredCorpusManifest.from_payload(payload) + if campaign_mode: + _require_inference_handoff(manifest) + return manifest def _is_number(value: object) -> bool: @@ -992,10 +1007,16 @@ def visit(node: object, path: str = "$") -> None: def build_inferred_corpus_convergence_handoff( manifest: InferredCorpusManifest | Path, + *, + campaign_mode: bool = False, ) -> InferredCorpusConvergenceHandoff: """Bind every supported row from memory or persisted disk to convergence.""" - persisted_manifest = read_inferred_corpus_manifest(manifest) if isinstance(manifest, Path) else manifest + persisted_manifest = ( + read_inferred_corpus_manifest(manifest, campaign_mode=campaign_mode) if isinstance(manifest, Path) else manifest + ) + if campaign_mode: + _require_inference_handoff(persisted_manifest) selections = tuple(_selection_for_entry(entry) for entry in persisted_manifest.entries if entry.spec is not None) handoff = InferredCorpusConvergenceHandoff( manifest_id=persisted_manifest.manifest_id, @@ -1053,9 +1074,11 @@ def _stable_seed(key: CorpusManifestKey) -> int: def _catalog_entries( registry: RuntimeSchemaRegistryLike, + providers: Sequence[str] | None = None, ) -> tuple[tuple[str, SchemaPackageCatalog, SchemaVersionPackage, SchemaElementManifest], ...]: result: list[tuple[str, SchemaPackageCatalog, SchemaVersionPackage, SchemaElementManifest]] = [] - for provider in sorted(set(registry.list_providers())): + provider_names = providers if providers is not None else registry.list_providers() + for provider in sorted(set(provider_names)): catalog = registry.load_package_catalog(provider) if catalog is None: raise RuntimeError(f"registry provider {provider!r} has no persisted package catalog") @@ -1149,12 +1172,14 @@ def _compile_entry( def assert_inferred_corpus_manifest_complete( manifest: InferredCorpusManifest, registry: RuntimeSchemaRegistryLike, + *, + providers: Sequence[str] | None = None, ) -> None: """Fail loudly when a manifest omits any currently persisted catalog entry.""" expected = { CorpusManifestKey(provider, package.version, element.element_kind) - for provider, _catalog, package, element in _catalog_entries(registry) + for provider, _catalog, package, element in _catalog_entries(registry, providers) } actual = { CorpusManifestKey(entry.key.provider, entry.key.package_version, entry.key.element_kind) @@ -1173,10 +1198,14 @@ def compile_inferred_corpus_manifest( registry: RuntimeSchemaRegistryLike, package_receipt: PackageReceipt | None = None, wire_formats: Mapping[str, WireFormat] | None = None, + providers: Sequence[str] | None = None, + campaign_mode: bool = False, ) -> InferredCorpusManifest: """Compile every persisted package/version/element into a typed manifest.""" formats = PROVIDER_WIRE_FORMATS if wire_formats is None else wire_formats + if campaign_mode and package_receipt is None: + raise ValueError("campaign mode requires a persisted schema-inference handoff") entries = tuple( _compile_entry( provider=provider, @@ -1185,15 +1214,72 @@ def compile_inferred_corpus_manifest( registry=registry, wire_formats=formats, ) - for provider, catalog, package, element in _catalog_entries(registry) + for provider, catalog, package, element in _catalog_entries(registry, providers) ) manifest = InferredCorpusManifest( entries=tuple(sorted(entries, key=lambda entry: entry.key)), package_receipt=package_receipt ) - assert_inferred_corpus_manifest_complete(manifest, registry) + assert_inferred_corpus_manifest_complete(manifest, registry, providers=providers) + if campaign_mode: + _validate_inference_handoff(manifest, registry, providers=providers) return manifest +def _validate_inference_handoff( + manifest: InferredCorpusManifest, + registry: RuntimeSchemaRegistryLike, + *, + providers: Sequence[str] | None, +) -> None: + receipt = _require_inference_handoff(manifest) + expected_packages = package_hashes_for_registry(cast(SchemaReceiptRegistry, registry), providers) + if receipt.packages != expected_packages: + raise ValueError("schema-inference handoff package/version/element hashes do not match the registry") + + expected_coverage = { + (provider, origin_from_provider(provider).value) + for provider, _catalog, _package, _element in _catalog_entries(registry, providers) + } + actual_coverage = {(item.provider, item.origin) for item in receipt.coverage_decisions} + if actual_coverage != expected_coverage: + raise ValueError( + "schema-inference handoff does not contain complete origin/provider coverage: " + f"missing={sorted(expected_coverage - actual_coverage)!r}, " + f"unexpected={sorted(actual_coverage - expected_coverage)!r}" + ) + if any(item.decision != "committed" for item in receipt.coverage_decisions): + raise ValueError("schema-inference handoff contains a non-committed coverage decision") + + expected_unsupported = { + ( + entry.key.provider, + entry.key.package_version, + entry.key.element_kind, + "nonrepresentable" if entry.unsupported.reason == "unsupported_json_schema_construct" else "unsupported", + entry.unsupported.reason, + entry.unsupported.details, + ) + for entry in manifest.entries + if entry.unsupported is not None + } + actual_unsupported = { + ( + item.provider, + item.package_version, + item.element_kind, + item.decision, + item.reason, + item.details, + ) + for item in receipt.unsupported_decisions + } + if actual_unsupported != expected_unsupported: + raise ValueError( + "schema-inference handoff unsupported/nonrepresentable decisions changed: " + f"expected={sorted(expected_unsupported)!r}, actual={sorted(actual_unsupported)!r}" + ) + + __all__ = [ "ConstructSupport", "CorpusManifestKey", diff --git a/tests/unit/devtools/test_schema_commit_command.py b/tests/unit/devtools/test_schema_commit_command.py index 892fa3473e..8466c63f6c 100644 --- a/tests/unit/devtools/test_schema_commit_command.py +++ b/tests/unit/devtools/test_schema_commit_command.py @@ -43,7 +43,10 @@ def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: monkeypatch.setattr(schema_commit, "get_config", fake_get_config) monkeypatch.setattr(schema_commit, "commit_provider_schema", fake_commit) - assert schema_commit.main(["--provider", "chatgpt"]) == 0 + assert ( + schema_commit.main(["--provider", "chatgpt", "--schema-inference-gate-receipt", str(tmp_path / "gate.json")]) + == 0 + ) assert len(captured) == 1 request = captured[0] @@ -52,6 +55,7 @@ def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: assert request.db_path == tmp_path / "archive.db" assert request.full_corpus is True assert request.dry_run is False + assert request.schema_inference_gate_receipt_path == tmp_path / "gate.json" def test_schema_commit_honors_output_dir_and_dry_run_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -73,7 +77,16 @@ def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: custom_output = tmp_path / "custom-providers" assert ( schema_commit.main( - ["--provider", "chatgpt", "--output-dir", str(custom_output), "--dry-run", "--no-full-corpus"] + [ + "--provider", + "chatgpt", + "--output-dir", + str(custom_output), + "--dry-run", + "--no-full-corpus", + "--schema-inference-gate-receipt", + str(tmp_path / "gate.json"), + ] ) == 0 ) @@ -102,7 +115,12 @@ def test_schema_commit_json_output_reports_success( ), ) - assert schema_commit.main(["--provider", "chatgpt", "--json"]) == 0 + assert ( + schema_commit.main( + ["--provider", "chatgpt", "--json", "--schema-inference-gate-receipt", str(tmp_path / "gate.json")] + ) + == 0 + ) payload = json.loads(capsys.readouterr().out) assert payload["provider"] == "chatgpt" @@ -128,7 +146,18 @@ def test_schema_commit_exits_nonzero_on_generation_failure( ), ) - assert schema_commit.main(["--provider", "broken-provider", "--json"]) == 1 + assert ( + schema_commit.main( + [ + "--provider", + "broken-provider", + "--json", + "--schema-inference-gate-receipt", + str(tmp_path / "gate.json"), + ] + ) + == 1 + ) payload = json.loads(capsys.readouterr().out) assert payload["success"] is False assert payload["error"] == "No samples" @@ -154,4 +183,7 @@ def test_schema_commit_exits_nonzero_when_narrowed(monkeypatch: pytest.MonkeyPat ), ) - assert schema_commit.main(["--provider", "chatgpt"]) == 1 + assert ( + schema_commit.main(["--provider", "chatgpt", "--schema-inference-gate-receipt", str(tmp_path / "gate.json")]) + == 1 + ) diff --git a/tests/unit/maintenance/test_schema_inference_gate.py b/tests/unit/maintenance/test_schema_inference_gate.py index 14cf969667..bff4543b0f 100644 --- a/tests/unit/maintenance/test_schema_inference_gate.py +++ b/tests/unit/maintenance/test_schema_inference_gate.py @@ -14,6 +14,7 @@ RECEIPT_FILENAME, SchemaInferenceGateError, run_schema_inference_gate, + schema_inference_gate_receipt_digest, ) from polylogue.storage.blob_store import BlobStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -462,3 +463,13 @@ def to_json(self) -> dict[str, object]: payload = _run(root, tmp_path, ground_truth=ground_truth) assert payload["verdict"] == "FAIL" assert any("untyped corpus residual" in reason for reason in payload["pass_fail_reasons"]) + + +def test_gate_receipt_digest_is_canonical_and_content_bound() -> None: + first = {"verdict": "PASS", "schema": "polylogue.schema-inference-gate.v1", "nested": {"b": 2, "a": 1}} + reordered = {"nested": {"a": 1, "b": 2}, "schema": first["schema"], "verdict": first["verdict"]} + + assert schema_inference_gate_receipt_digest(first) == schema_inference_gate_receipt_digest(reordered) + altered = dict(first) + altered["verdict"] = "FAIL" + assert schema_inference_gate_receipt_digest(first) != schema_inference_gate_receipt_digest(altered) diff --git a/tests/unit/schemas/test_inferred_corpus_manifest.py b/tests/unit/schemas/test_inferred_corpus_manifest.py index af59b08e6c..09274b8b19 100644 --- a/tests/unit/schemas/test_inferred_corpus_manifest.py +++ b/tests/unit/schemas/test_inferred_corpus_manifest.py @@ -8,6 +8,10 @@ import pytest from polylogue.core.json import JSONValue +from polylogue.schemas.operator.receipt import ( + SchemaInferenceUnsupportedDecision, + build_schema_inference_receipt, +) from polylogue.schemas.registry import SCHEMA_DIR, SchemaRegistry from polylogue.schemas.synthetic.models import SchemaRecord from polylogue.schemas.synthetic.wire_formats import PROVIDER_WIRE_FORMATS @@ -88,6 +92,73 @@ def test_persisted_manifest_round_trip_validates_identity_and_integrity(tmp_path assert read_inferred_corpus_manifest(path) == manifest +def test_campaign_mode_rejects_catalog_only_manifest(tmp_path: Path) -> None: + manifest = compile_inferred_corpus_manifest(registry=_registry()) + path = tmp_path / "catalog-only.json" + write_inferred_corpus_manifest(manifest, path) + + with pytest.raises(ValueError, match="catalog-only"): + read_inferred_corpus_manifest(path, campaign_mode=True) + with pytest.raises(ValueError, match="handoff"): + compile_inferred_corpus_manifest(registry=_registry(), campaign_mode=True) + + +def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decisions() -> None: + registry = _registry() + provider = registry.list_providers()[0] + receipt = build_schema_inference_receipt( + registry, + provider=provider, + gate_receipt_digest="a" * 64, + ) + compile_inferred_corpus_manifest( + registry=registry, + providers=(provider,), + package_receipt=receipt.to_payload(), + campaign_mode=True, + ) + + tampered_gate = replace(receipt, gate_receipt_digest="b" * 64) + with pytest.raises(ValueError, match="different gate receipt digests"): + tampered_gate.merged_with(receipt) + + tampered_package = replace( + receipt, + packages=(replace(receipt.packages[0], package_hash="b" * 64), *receipt.packages[1:]), + ) + with pytest.raises(ValueError, match="package/version/element hashes"): + compile_inferred_corpus_manifest( + registry=registry, + providers=(provider,), + package_receipt=tampered_package.to_payload(), + campaign_mode=True, + ) + + package = receipt.packages[0] + if receipt.unsupported_decisions: + first = receipt.unsupported_decisions[0] + changed = replace(first, decision="unsupported" if first.decision == "nonrepresentable" else "nonrepresentable") + unsupported = (changed, *receipt.unsupported_decisions[1:]) + else: + changed = SchemaInferenceUnsupportedDecision( + provider=provider, + package_version=package.package_version, + element_kind="tampered-element", + decision="nonrepresentable", + reason="tampered decision", + details=("tampered_construct",), + ) + unsupported = (changed,) + tampered_unsupported = replace(receipt, unsupported_decisions=tuple(sorted(unsupported))) + with pytest.raises(ValueError, match="unsupported/nonrepresentable decisions"): + compile_inferred_corpus_manifest( + registry=registry, + providers=(provider,), + package_receipt=tampered_unsupported.to_payload(), + campaign_mode=True, + ) + + @pytest.mark.parametrize("field", ["manifest_id", "payload_sha256"]) def test_persisted_manifest_rejects_tampered_hash_fields(tmp_path: Path, field: str) -> None: manifest = compile_inferred_corpus_manifest(registry=_registry()) diff --git a/tests/unit/schemas/test_operator_commit.py b/tests/unit/schemas/test_operator_commit.py index cff34a4a80..c3bcd6d897 100644 --- a/tests/unit/schemas/test_operator_commit.py +++ b/tests/unit/schemas/test_operator_commit.py @@ -26,15 +26,39 @@ from typing import Any, cast from unittest.mock import patch +import pytest + +from polylogue.maintenance.schema_inference_gate import schema_inference_gate_receipt_digest from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.operator.commit import commit_provider_schema from polylogue.schemas.operator.models import SchemaCommitRequest +from polylogue.schemas.operator.receipt import SCHEMA_INFERENCE_HANDOFF_FILENAME, load_schema_inference_receipt from polylogue.schemas.packages import SchemaElementManifest, SchemaPackageCatalog, SchemaVersionPackage +from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.tooling_models import ClusterManifest +from tests.infra.inferred_corpus import compile_inferred_corpus_manifest _PROVIDER = "commit-fixture-k45pq" +def _gate_receipt(output_dir: Path) -> Path: + path = output_dir.parent / "schema-inference-gate-receipt.json" + payload = {"schema": "polylogue.schema-inference-gate.v1", "gate_version": "test", "verdict": "PASS"} + path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + assert schema_inference_gate_receipt_digest(payload) + return path + + +def _request(output_dir: Path, *, dry_run: bool = False) -> SchemaCommitRequest: + return SchemaCommitRequest( + provider=_PROVIDER, + output_dir=output_dir, + full_corpus=True, + schema_inference_gate_receipt_path=_gate_receipt(output_dir), + dry_run=dry_run, + ) + + def _bundle( *, version: str, @@ -97,9 +121,7 @@ def test_new_provider_writes_catalog_and_element_files(self, tmp_path: Path) -> bundle = _bundle(version="v1", schema=schema, sample_count=5) with patch("polylogue.schemas.generation.workflow._build_provider_bundle", return_value=bundle): - commit_result = commit_provider_schema( - SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True) - ) + commit_result = commit_provider_schema(_request(output_dir)) assert commit_result.success assert not commit_result.dry_run @@ -115,6 +137,36 @@ def test_new_provider_writes_catalog_and_element_files(self, tmp_path: Path) -> assert version_report.sample_count == 5 assert not version_report.narrowed_paths assert "session_document.id" in version_report.added_paths + assert commit_result.handoff is not None + assert commit_result.handoff_path == output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME + assert load_schema_inference_receipt(commit_result.handoff_path) == commit_result.handoff + assert commit_result.handoff.packages[0].element_hashes[0].element_kind == "session_document" + + def test_commit_to_registry_to_campaign_manifest_is_a_real_route(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + bundle = _bundle( + version="v1", + schema={"type": "object", "properties": {"id": {"type": "string"}}}, + sample_count=5, + ) + with patch("polylogue.schemas.generation.workflow._build_provider_bundle", return_value=bundle): + result = commit_provider_schema(_request(output_dir)) + + assert result.handoff is not None + registry = SchemaRegistry(storage_root=output_dir) + manifest = compile_inferred_corpus_manifest( + registry=registry, + providers=(_PROVIDER,), + package_receipt=result.handoff.to_payload(), + campaign_mode=True, + ) + assert manifest.receipt_state == "package_receipt_attached" + assert len(manifest.entries) == 1 + + def test_commit_requires_an_accepted_gate_receipt(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + with pytest.raises(ValueError, match="accepted schema-inference gate receipt"): + commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) def test_regeneration_with_new_field_reports_changed_and_added(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" @@ -123,7 +175,7 @@ def test_regeneration_with_new_field_reports_changed_and_added(self, tmp_path: P "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=first_schema, sample_count=5), ): - commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) + commit_provider_schema(_request(output_dir)) second_schema = { "type": "object", @@ -133,9 +185,7 @@ def test_regeneration_with_new_field_reports_changed_and_added(self, tmp_path: P "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=second_schema, sample_count=9), ): - commit_result = commit_provider_schema( - SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True) - ) + commit_result = commit_provider_schema(_request(output_dir)) assert commit_result.success version_report = commit_result.versions[0] @@ -156,10 +206,8 @@ def test_identical_regeneration_reports_unchanged(self, tmp_path: Path) -> None: "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=schema, sample_count=5), ): - commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) - commit_result = commit_provider_schema( - SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True) - ) + commit_provider_schema(_request(output_dir)) + commit_result = commit_provider_schema(_request(output_dir)) assert commit_result.versions[0].status == "unchanged" @@ -177,16 +225,14 @@ def test_thin_regeneration_window_cannot_narrow_committed_union(self, tmp_path: "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=wide_schema, sample_count=100), ): - commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) + commit_provider_schema(_request(output_dir)) thin_schema = {"type": "object", "properties": {"timestamp": {"type": "string"}}} with patch( "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=thin_schema, sample_count=3), ): - commit_result = commit_provider_schema( - SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True) - ) + commit_result = commit_provider_schema(_request(output_dir)) assert not commit_result.narrowed assert not commit_result.versions[0].narrowed_paths @@ -200,7 +246,7 @@ def test_dry_run_does_not_touch_output_dir(self, tmp_path: Path) -> None: "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=schema, sample_count=5), ): - commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) + commit_provider_schema(_request(output_dir)) catalog_before_bytes = (output_dir / _PROVIDER / "catalog.json").read_bytes() @@ -212,9 +258,7 @@ def test_dry_run_does_not_touch_output_dir(self, tmp_path: Path) -> None: "polylogue.schemas.generation.workflow._build_provider_bundle", return_value=_bundle(version="v1", schema=second_schema, sample_count=9), ): - commit_result = commit_provider_schema( - SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True, dry_run=True) - ) + commit_result = commit_provider_schema(_request(output_dir, dry_run=True)) assert commit_result.dry_run assert commit_result.versions[0].status == "changed" @@ -230,7 +274,12 @@ def test_failed_generation_reports_no_success_and_no_versions(self, tmp_path: Pa # mocking needed to exercise the failure path for real. output_dir = tmp_path / "providers" commit_result = commit_provider_schema( - SchemaCommitRequest(provider="not-a-real-provider-k45pq", output_dir=output_dir, full_corpus=True) + SchemaCommitRequest( + provider="not-a-real-provider-k45pq", + output_dir=output_dir, + full_corpus=True, + schema_inference_gate_receipt_path=_gate_receipt(output_dir), + ) ) assert not commit_result.success From 15e8c6b8568e55e14b79672e53db68ff8bbfb7b7 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 16:50:55 +0200 Subject: [PATCH 2/7] fix: bind schema commits to pristine gate receipts Problem Schema commit acceptance trusted a content digest over a minimal PASS payload, while receipt and inferred-corpus compilation maintained separate construct classifiers. The commit-to-manifest regression also used a provider without a persisted production wire format. What changed Validate the complete gate receipt contract, including archive authority identity, nonce and freshness, pristine query evidence, and explicit full BlobStore verification. Move construct classification into one canonical synthetic-runtime module used by both receipt and campaign compilation. Exercise the bundled registry relation annotations and the real chatgpt registry-to-manifest route, with mutation-sensitive rejection tests. Compatibility/migration Existing handoffs must be regenerated from the authoritative schema-inference gate. Unsupported and nonrepresentable entries remain explicit in package receipts and campaign manifests. --- .../maintenance/schema_inference_gate.py | 125 ++++ polylogue/schemas/operator/commit.py | 30 +- polylogue/schemas/operator/receipt.py | 113 +--- polylogue/schemas/synthetic/classification.py | 519 +++++++++++++++ tests/infra/inferred_corpus.py | 610 +----------------- .../schemas/test_inferred_corpus_manifest.py | 59 +- tests/unit/schemas/test_operator_commit.py | 126 +++- 7 files changed, 846 insertions(+), 736 deletions(-) create mode 100644 polylogue/schemas/synthetic/classification.py diff --git a/polylogue/maintenance/schema_inference_gate.py b/polylogue/maintenance/schema_inference_gate.py index c1714bec17..67b0cb90e5 100644 --- a/polylogue/maintenance/schema_inference_gate.py +++ b/polylogue/maintenance/schema_inference_gate.py @@ -14,6 +14,7 @@ import platform import sqlite3 import sys +import uuid from collections import Counter from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass @@ -34,6 +35,7 @@ GATE_VERSION = "2" DEFAULT_SAMPLE_LIMIT = 10 RECEIPT_FILENAME = "schema-inference-gate-receipt.json" +RECEIPT_MAX_AGE_SECONDS = 24 * 60 * 60 _ALLOWED_RESIDUAL_EXPLANATIONS = frozenset( {"materialized", "superseded-duplicate", "legitimately-excluded-non-conversation"} @@ -1021,6 +1023,123 @@ def schema_inference_gate_receipt_digest(payload: Mapping[str, object]) -> str: return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +def validate_schema_inference_gate_receipt( + payload: Mapping[str, object], + *, + archive_root: Path, + now: datetime | None = None, +) -> str: + """Validate the authoritative PASS receipt consumed by schema commit. + + A verdict alone is not evidence. The handoff contract requires the gate's + complete receipt shape, a fresh nonce, the current archive authority + identity, and an explicit successful full blob verification. + """ + + required_fields = { + "schema", + "gate_version", + "generated_at", + "receipt_nonce", + "verdict", + "archive_root", + "archive_identity_digest", + "schema_identity", + "source_schema_identity", + "query_results", + "source_denominators", + "blob_denominators", + "ground_truth_denominators", + "ground_truth_inputs", + "exemptions", + "corpus_fidelity", + "full_blob_hash_verification", + "input_paths", + "tool_versions", + "pass_fail_reasons", + } + missing = sorted(required_fields - set(payload)) + if missing: + raise ValueError(f"schema-inference gate receipt is missing authoritative fields: {missing}") + if payload.get("schema") != RECEIPT_SCHEMA or payload.get("gate_version") != GATE_VERSION: + raise ValueError("schema-inference gate receipt schema or gate version is not authoritative") + if payload.get("verdict") != "PASS": + raise ValueError("schema-inference gate receipt must have verdict PASS") + + nonce = payload.get("receipt_nonce") + try: + valid_nonce = isinstance(nonce, str) and uuid.UUID(nonce).hex == nonce.replace("-", "") + except ValueError: + valid_nonce = False + if not valid_nonce: + raise ValueError("schema-inference gate receipt must contain a valid receipt nonce") + + generated_at = payload.get("generated_at") + if not isinstance(generated_at, str): + raise ValueError("schema-inference gate receipt generated_at is required") + try: + generated = datetime.fromisoformat(generated_at.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("schema-inference gate receipt generated_at is invalid") from exc + if generated.tzinfo is None: + raise ValueError("schema-inference gate receipt generated_at must include a timezone") + observed_at = now or datetime.now(UTC) + age_seconds = (observed_at - generated.astimezone(UTC)).total_seconds() + if age_seconds < -300 or age_seconds > RECEIPT_MAX_AGE_SECONDS: + raise ValueError("schema-inference gate receipt is stale or from the future") + + expected_root = archive_root.absolute() + recorded_root = payload.get("archive_root") + if not isinstance(recorded_root, str) or Path(recorded_root).absolute() != expected_root: + raise ValueError("schema-inference gate receipt targets a different archive") + try: + location = ArchiveLocation.resolve(expected_root) + expected_identity_digest = ArchiveIdentity.resolve_location(location).authority_identity_digest + except (OSError, ValueError, RuntimeError) as exc: + raise ValueError(f"unable to resolve target archive identity: {exc}") from exc + if payload.get("archive_identity_digest") != expected_identity_digest: + raise ValueError("schema-inference gate receipt archive identity is stale or mismatched") + + full_blob = payload.get("full_blob_hash_verification") + if not isinstance(full_blob, Mapping) or full_blob.get("passed") is not True: + raise ValueError("schema-inference gate receipt lacks an explicit full_blob_hash_verification PASS") + verifier = full_blob.get("verifier") + if ( + not isinstance(verifier, Mapping) + or verifier.get("identity") != "polylogue.storage.blob_store.BlobStore.verify_all" + ): + raise ValueError("schema-inference gate receipt lacks the authoritative full blob verifier") + before = full_blob.get("before_snapshot") + after = full_blob.get("after_snapshot") + if ( + not isinstance(before, Mapping) + or not isinstance(after, Mapping) + or not isinstance(before.get("digest"), str) + or before.get("digest") != after.get("digest") + or full_blob.get("failures") != [] + or full_blob.get("missing_references") != [] + or full_blob.get("errors") != [] + ): + raise ValueError("schema-inference gate receipt full blob verification evidence is incomplete") + + query_results = payload.get("query_results") + if ( + not isinstance(query_results, Mapping) + or not query_results + or any(not isinstance(result, Mapping) or result.get("passed") is not True for result in query_results.values()) + ): + raise ValueError("schema-inference gate receipt contains a non-passing pristine query gate") + corpus_fidelity = payload.get("corpus_fidelity") + ground_truth = payload.get("ground_truth_inputs") + if not isinstance(corpus_fidelity, Mapping) or corpus_fidelity.get("passed") is not True: + raise ValueError("schema-inference gate receipt corpus fidelity is not PASS") + if not isinstance(ground_truth, Mapping) or ground_truth.get("passed") is not True: + raise ValueError("schema-inference gate receipt ground truth is not PASS") + if payload.get("pass_fail_reasons") != []: + raise ValueError("schema-inference gate receipt contains pass/fail reasons") + return schema_inference_gate_receipt_digest(payload) + + def _int_or_zero(value: object) -> int: return value if isinstance(value, int) and not isinstance(value, bool) else 0 @@ -1115,8 +1234,12 @@ def run_schema_inference_gate( "schema": RECEIPT_SCHEMA, "gate_version": GATE_VERSION, "generated_at": datetime.now(UTC).isoformat(), + "receipt_nonce": str(uuid.uuid4()), "verdict": "PASS" if not reasons and passed_hard_gates else "FAIL", "archive_root": str(root), + "archive_identity_digest": ( + ArchiveIdentity.resolve_location(ArchiveLocation.resolve(root)).authority_identity_digest + ), "schema_identity": schema_identity, "source_schema_identity": source_entry, "query_results": gate_results, @@ -1150,9 +1273,11 @@ def run_schema_inference_gate( "DEFAULT_SAMPLE_LIMIT", "GROUND_TRUTH_INPUTS", "RECEIPT_FILENAME", + "RECEIPT_MAX_AGE_SECONDS", "RECEIPT_SCHEMA", "SchemaInferenceGateError", "SchemaInferenceGateResult", "schema_inference_gate_receipt_digest", + "validate_schema_inference_gate_receipt", "run_schema_inference_gate", ] diff --git a/polylogue/schemas/operator/commit.py b/polylogue/schemas/operator/commit.py index 04c67f7a7f..fe431a75cc 100644 --- a/polylogue/schemas/operator/commit.py +++ b/polylogue/schemas/operator/commit.py @@ -38,7 +38,10 @@ from typing import cast from polylogue.core.json import JSONDocument -from polylogue.maintenance.schema_inference_gate import RECEIPT_SCHEMA, schema_inference_gate_receipt_digest +from polylogue.maintenance.schema_inference_gate import ( + validate_schema_inference_gate_receipt, +) +from polylogue.paths import db_path as default_index_db_path from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.generation.workflow import generate_all_schemas from polylogue.schemas.operator.inference import privacy_config_from_payload @@ -63,7 +66,7 @@ def _element_schemas_by_kind( } -def _accepted_gate_receipt_digest(path: Path | None) -> str: +def _accepted_gate_receipt_digest(path: Path | None, *, archive_root: Path) -> str: if path is None: raise ValueError("schema commit requires an accepted schema-inference gate receipt path") try: @@ -72,19 +75,28 @@ def _accepted_gate_receipt_digest(path: Path | None) -> str: raise ValueError(f"unable to read schema-inference gate receipt {path}: {exc}") from exc if not isinstance(payload, Mapping): raise ValueError("schema-inference gate receipt must be a JSON object") - if payload.get("schema") != RECEIPT_SCHEMA: - raise ValueError(f"schema-inference gate receipt must use schema {RECEIPT_SCHEMA!r}") - if payload.get("verdict") != "PASS": - raise ValueError("schema-inference gate receipt must have verdict PASS") - return schema_inference_gate_receipt_digest(cast(Mapping[str, object], payload)) + return validate_schema_inference_gate_receipt( + cast(Mapping[str, object], payload), + archive_root=archive_root, + ) + + +def _target_archive_root(request: SchemaCommitRequest) -> Path: + target_db = request.db_path or default_index_db_path() + return target_db.absolute().parent def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommitResult: provider_token = str(canonical_schema_provider(request.provider)) - gate_receipt_digest = _accepted_gate_receipt_digest(request.schema_inference_gate_receipt_path) + gate_receipt_digest = _accepted_gate_receipt_digest( + request.schema_inference_gate_receipt_path, + archive_root=_target_archive_root(request), + ) registry_before = SchemaRegistry(storage_root=output_dir) - catalog_before = registry_before.load_package_catalog(provider_token) + # The bundled registry is a read fallback, not the prior state of this + # commit's output directory. Compare against local persisted packages only. + catalog_before = registry_before._load_local_catalog(provider_token) before_versions = {package.version for package in catalog_before.packages} if catalog_before is not None else set() before_schemas: dict[str, dict[str, JSONDocument | None]] = {} if catalog_before is not None: diff --git a/polylogue/schemas/operator/receipt.py b/polylogue/schemas/operator/receipt.py index 41c2d42948..a32fe2fccd 100644 --- a/polylogue/schemas/operator/receipt.py +++ b/polylogue/schemas/operator/receipt.py @@ -14,6 +14,7 @@ from polylogue.schemas.operator.registry import RuntimeSchemaRegistryLike from polylogue.schemas.packages import SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.runtime_registry import canonical_schema_provider +from polylogue.schemas.synthetic.classification import unsupported_schema_constructs from polylogue.schemas.synthetic.wire_formats import PROVIDER_WIRE_FORMATS SCHEMA_INFERENCE_HANDOFF_SCHEMA = "polylogue.schema-inference-handoff.v1" @@ -21,87 +22,6 @@ CoverageDecision: TypeAlias = Literal["committed", "unsupported", "nonrepresentable"] _COVERAGE_DECISIONS = frozenset({"committed", "unsupported", "nonrepresentable"}) -_SUPPORTED_SCHEMA_KEYS = frozenset( - { - "$anchor", - "$comment", - "$id", - "$schema", - "additionalProperties", - "anyOf", - "default", - "deprecated", - "description", - "examples", - "items", - "oneOf", - "properties", - "readOnly", - "title", - "type", - "writeOnly", - "x-polylogue-array-lengths", - "x-polylogue-foreign-keys", - "x-polylogue-format", - "x-polylogue-frequency", - "x-polylogue-multiline", - "x-polylogue-mutually-exclusive", - "x-polylogue-observed-distribution", - "x-polylogue-range", - "x-polylogue-semantic-role", - "x-polylogue-string-lengths", - "x-polylogue-time-deltas", - "x-polylogue-values", - } -) -_PERSISTED_SCHEMA_METADATA_ANNOTATIONS = frozenset( - { - "x-polylogue-anchor-profile-family-id", - "x-polylogue-artifact-kind", - "x-polylogue-element-bundle-scope-count", - "x-polylogue-element-first-seen", - "x-polylogue-element-kind", - "x-polylogue-element-last-seen", - "x-polylogue-evidence", - "x-polylogue-evidence-confidence", - "x-polylogue-exact-structure-ids", - "x-polylogue-generated-at", - "x-polylogue-generator", - "x-polylogue-high-cardinality-keys", - "x-polylogue-observed-artifact-count", - "x-polylogue-package-profile-family-ids", - "x-polylogue-package-version", - "x-polylogue-profile-family-ids", - "x-polylogue-profile-tokens", - "x-polylogue-promoted-at", - "x-polylogue-registered-at", - "x-polylogue-sample-count", - "x-polylogue-sample-granularity", - "x-polylogue-score", - "x-polylogue-version", - } -) -_SCHEMA_MAPPING_KEYS = frozenset( - { - "$defs", - "additionalProperties", - "contentSchema", - "contains", - "dependentSchemas", - "dependencies", - "else", - "if", - "items", - "not", - "patternProperties", - "properties", - "propertyNames", - "then", - "unevaluatedItems", - "unevaluatedProperties", - } -) -_SCHEMA_ARRAY_KEYS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) def _require_digest(value: object, *, field: str) -> str: @@ -114,28 +34,6 @@ def _canonical_payload(payload: Mapping[str, object]) -> str: return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) -def _walk_schema_keys(value: object, found: set[str]) -> None: - if isinstance(value, Mapping): - for key, child in value.items(): - if isinstance(key, str) and key.startswith("x-"): - if key not in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS: - found.add(key) - continue - if isinstance(key, str): - found.add(key) - if key == "properties" and isinstance(child, Mapping): - for property_schema in child.values(): - _walk_schema_keys(property_schema, found) - elif key in _SCHEMA_MAPPING_KEYS: - _walk_schema_keys(child, found) - elif key in _SCHEMA_ARRAY_KEYS and isinstance(child, Sequence): - for branch in child: - _walk_schema_keys(branch, found) - elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - for child in value: - _walk_schema_keys(child, found) - - @dataclass(frozen=True, order=True, slots=True) class SchemaInferenceCoverageDecision: """The explicit decision for one origin/provider pair in a handoff.""" @@ -476,14 +374,7 @@ def _unsupported_for_package( ) ) continue - keys: set[str] = set() - _walk_schema_keys(schema, keys) - unsupported = tuple(sorted(keys - _SUPPORTED_SCHEMA_KEYS)) - schema_type = schema.get("type") - if "x-polylogue-observed-distribution" in schema and ( - not isinstance(schema_type, str) or schema_type not in {"array", "number", "integer"} - ): - unsupported = tuple(sorted(set(unsupported) | {"x-polylogue-observed-distribution"})) + unsupported = unsupported_schema_constructs(schema) if unsupported: decisions.append( SchemaInferenceUnsupportedDecision( diff --git a/polylogue/schemas/synthetic/classification.py b/polylogue/schemas/synthetic/classification.py new file mode 100644 index 0000000000..e3c1b80ba0 --- /dev/null +++ b/polylogue/schemas/synthetic/classification.py @@ -0,0 +1,519 @@ +"""Canonical executable-support classification for persisted schemas.""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Literal + +from polylogue.schemas.synthetic.models import SchemaRecord + +ConstructSupportState = Literal["supported", "unsupported"] + + +@dataclass(frozen=True, order=True) +class ConstructSupport: + """Support verdict for one schema keyword or runtime annotation.""" + + construct: str + state: ConstructSupportState + + def to_payload(self) -> dict[str, str]: + return {"construct": self.construct, "state": self.state} + + +_SUPPORTED_SCHEMA_CONSTRUCTS = frozenset( + { + "$anchor", + "$comment", + "$id", + "$schema", + "additionalProperties", + "anyOf", + "default", + "deprecated", + "description", + "examples", + "items", + "oneOf", + "properties", + "readOnly", + "title", + "type", + "writeOnly", + } +) +_STANDARD_SCHEMA_KEYWORDS = frozenset( + { + "$anchor", + "$comment", + "$defs", + "$dynamicAnchor", + "$dynamicRef", + "$id", + "$recursiveAnchor", + "$recursiveRef", + "$ref", + "$schema", + "$vocabulary", + "additionalProperties", + "allOf", + "anyOf", + "const", + "contains", + "contentEncoding", + "contentMediaType", + "contentSchema", + "default", + "definitions", + "dependentRequired", + "dependentSchemas", + "dependencies", + "deprecated", + "description", + "else", + "enum", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "formatAssertion", + "if", + "items", + "maxContains", + "maxItems", + "maxLength", + "maxProperties", + "maximum", + "minContains", + "minItems", + "minLength", + "minProperties", + "minimum", + "multipleOf", + "not", + "oneOf", + "pattern", + "patternProperties", + "prefixItems", + "properties", + "propertyNames", + "readOnly", + "required", + "then", + "title", + "unevaluatedItems", + "unevaluatedProperties", + "uniqueItems", + "writeOnly", + } +) +_SCHEMA_MAPPING_KEYWORDS = frozenset( + { + "$defs", + "additionalProperties", + "contentSchema", + "contains", + "dependentSchemas", + "dependencies", + "else", + "if", + "items", + "not", + "patternProperties", + "properties", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + } +) +_SCHEMA_ARRAY_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) +_SUPPORTED_SYNTHETIC_ANNOTATIONS = frozenset( + { + "x-polylogue-array-lengths", + "x-polylogue-foreign-keys", + "x-polylogue-format", + "x-polylogue-frequency", + "x-polylogue-multiline", + "x-polylogue-mutually-exclusive", + "x-polylogue-observed-distribution", + "x-polylogue-range", + "x-polylogue-semantic-role", + "x-polylogue-string-lengths", + "x-polylogue-time-deltas", + "x-polylogue-values", + } +) +_SUPPORTED_FORMAT_VALUES = frozenset( + {"uuid4", "uuid", "hex-id", "iso8601", "unix-epoch", "unix-epoch-str", "url", "email", "mime-type", "base64"} +) +_SUPPORTED_SEMANTIC_ROLE_VALUES = frozenset({"message_role", "message_body", "message_timestamp", "session_title"}) +_PERSISTED_SCHEMA_METADATA_ANNOTATIONS = frozenset( + { + "x-polylogue-anchor-profile-family-id", + "x-polylogue-artifact-kind", + "x-polylogue-element-bundle-scope-count", + "x-polylogue-element-first-seen", + "x-polylogue-element-kind", + "x-polylogue-element-last-seen", + "x-polylogue-evidence", + "x-polylogue-evidence-confidence", + "x-polylogue-exact-structure-ids", + "x-polylogue-generated-at", + "x-polylogue-generator", + "x-polylogue-high-cardinality-keys", + "x-polylogue-observed-artifact-count", + "x-polylogue-package-profile-family-ids", + "x-polylogue-package-version", + "x-polylogue-profile-family-ids", + "x-polylogue-profile-tokens", + "x-polylogue-promoted-at", + "x-polylogue-registered-at", + "x-polylogue-sample-count", + "x-polylogue-sample-granularity", + "x-polylogue-score", + "x-polylogue-version", + } +) +_SCHEMA_PATH_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + + +def _number(value: object) -> int | float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + return value if math.isfinite(float(value)) else None + except (OverflowError, ValueError): + return None + + +def _valid_pair(value: object, *, integral: bool = False, nonnegative: bool = False) -> bool: + if not isinstance(value, list) or len(value) != 2: + return False + first, second = (_number(item) for item in value) + if first is None or second is None: + return False + return not ( + (integral and (not isinstance(first, int) or not isinstance(second, int))) + or (nonnegative and (first < 0 or second < 0)) + or first > second + ) + + +def _relation_records(value: object, required: tuple[str, ...]) -> list[dict[str, object]] | None: + if not isinstance(value, list) or not value: + return None + records: list[dict[str, object]] = [] + for record in value: + if not isinstance(record, dict) or not all( + isinstance(record.get(field), str) and record[field].strip() for field in required + ): + return None + records.append(record) + return records + + +def _histogram_bucket_is_safe(index: int, log_base: int | float) -> bool: + try: + sampled_value = math.expm1((abs(float(index)) - 0.5) * math.log(float(log_base))) + except (OverflowError, ValueError): + return False + return math.isfinite(sampled_value) + + +def _valid_observed_distribution(value: object) -> bool: + if not isinstance(value, Mapping) or not value: + return False + for distribution in value.values(): + if not isinstance(distribution, Mapping): + return False + histogram = distribution.get("histogram") + log_base = _number(distribution.get("log_base")) + if not isinstance(histogram, list) or not histogram or log_base is None or log_base <= 1: + return False + if not all( + isinstance(bucket, list) + and len(bucket) == 2 + and isinstance(bucket[0], int) + and not isinstance(bucket[0], bool) + and isinstance(bucket[1], int) + and not isinstance(bucket[1], bool) + and bucket[1] > 0 + and _histogram_bucket_is_safe(bucket[0], float(log_base)) + for bucket in histogram + ): + return False + values = tuple(_number(distribution.get(key)) for key in ("p0", "p50", "p90", "p95", "p99", "p100")) + present = tuple(item for item in values if item is not None) + minimum = _number(distribution.get("min")) + maximum = _number(distribution.get("max")) + if minimum is not None and maximum is not None and minimum > maximum: + return False + if any(left > right for left, right in zip(present, present[1:], strict=False)): + return False + if any( + (minimum is not None and item < minimum) or (maximum is not None and item > maximum) for item in present + ): + return False + for key in ("min", "max", "mean", "p0", "p50", "p90", "p95", "p99", "p100", "stddev"): + if key in distribution and _number(distribution[key]) is None: + return False + stddev = _number(distribution.get("stddev")) + if stddev is not None and stddev < 0: + return False + return True + + +def _schema_nodes_at_path(schema: SchemaRecord, path: object) -> tuple[SchemaRecord, ...]: + if not isinstance(path, str) or not path.startswith("$"): + return () + if path == "$": + return (schema,) + if not path.startswith("$."): + return () + nodes: tuple[SchemaRecord, ...] = (schema,) + for raw_segment in path[2:].split("."): + wants_items = raw_segment.endswith("[*]") + segment = raw_segment[:-3] if wants_items else raw_segment + if not _SCHEMA_PATH_SEGMENT.fullmatch(segment): + return () + next_nodes: list[SchemaRecord] = [] + for node in nodes: + variants = [node] + for branch_key in ("anyOf", "oneOf"): + branches = node.get(branch_key) + if isinstance(branches, list): + variants.extend(branch for branch in branches if isinstance(branch, dict)) + for variant in variants: + properties = variant.get("properties") + child = properties.get(segment) if isinstance(properties, dict) else None + if not isinstance(child, dict): + continue + if not wants_items: + next_nodes.append(child) + continue + child_variants = [child] + for branch_key in ("anyOf", "oneOf"): + branches = child.get(branch_key) + if isinstance(branches, list): + child_variants.extend(branch for branch in branches if isinstance(branch, dict)) + next_nodes.extend( + item_schema + for child_variant in child_variants + if isinstance(item_schema := child_variant.get("items"), dict) + ) + nodes = tuple(next_nodes) + if not nodes: + return () + return nodes + + +def _schema_types(node: Mapping[str, object]) -> set[str]: + schema_type = node.get("type") + if isinstance(schema_type, str): + return {schema_type} + if isinstance(schema_type, list): + return {item for item in schema_type if isinstance(item, str)} + types: set[str] = set() + for keyword in ("anyOf", "oneOf"): + variants = node.get(keyword) + if isinstance(variants, list): + for variant in variants: + if isinstance(variant, Mapping): + types.update(_schema_types(variant)) + return types + + +def _schema_type(node: Mapping[str, object]) -> str | None: + schema_type = node.get("type") + if isinstance(schema_type, str): + return schema_type + if isinstance(schema_type, list): + types = [item for item in schema_type if isinstance(item, str) and item != "null"] + return types[0] if len(types) == 1 else None + return None + + +def _schema_nodes_have_type(nodes: tuple[SchemaRecord, ...], allowed: set[str]) -> bool: + return bool(nodes) and all(bool(types := _schema_types(node)) and types <= allowed for node in nodes) + + +def _annotation_supported(key: str, value: object, root_schema: SchemaRecord) -> bool: + if key not in _SUPPORTED_SYNTHETIC_ANNOTATIONS: + return False + if key == "x-polylogue-format": + return isinstance(value, str) and value in _SUPPORTED_FORMAT_VALUES + if key == "x-polylogue-semantic-role": + return isinstance(value, str) and value in _SUPPORTED_SEMANTIC_ROLE_VALUES + if key == "x-polylogue-frequency": + frequency = _number(value) + return frequency is not None and 0 <= frequency <= 1 + if key == "x-polylogue-multiline": + return isinstance(value, bool) + if key == "x-polylogue-values": + return isinstance(value, list) and bool(value) and all(isinstance(item, str) and item for item in value) + if key == "x-polylogue-range": + return _valid_pair(value) + if key == "x-polylogue-array-lengths": + return _valid_pair(value, integral=True, nonnegative=True) + if key == "x-polylogue-observed-distribution": + return _valid_observed_distribution(value) + required = { + "x-polylogue-foreign-keys": ("source", "target"), + "x-polylogue-time-deltas": ("field_a", "field_b"), + "x-polylogue-mutually-exclusive": ("parent",), + "x-polylogue-string-lengths": ("path",), + }.get(key) + if required is None: + return False + records = _relation_records(value, required) + if records is None: + return False + if key == "x-polylogue-foreign-keys": + return all( + _schema_nodes_at_path(root_schema, record["source"]) + and _schema_nodes_at_path(root_schema, record["target"]) + for record in records + ) + if key == "x-polylogue-time-deltas": + return all( + _schema_nodes_have_type( + _schema_nodes_at_path(root_schema, record["field_a"]), {"string", "number", "integer"} + ) + and _schema_nodes_have_type( + _schema_nodes_at_path(root_schema, record["field_b"]), {"string", "number", "integer"} + ) + and _valid_pair([record.get("min_delta"), record.get("max_delta")]) + and _number(record.get("avg_delta")) is not None + for record in records + ) + if key == "x-polylogue-mutually-exclusive": + return all( + isinstance(fields := record.get("fields"), list) + and len(fields) >= 2 + and all( + isinstance(field, str) + and _schema_nodes_have_type( + _schema_nodes_at_path(root_schema, f"{record['parent']}.{field}"), + {"string", "number", "integer", "boolean", "array", "object", "null"}, + ) + for field in fields + ) + for record in records + ) + return all( + _schema_nodes_at_path(root_schema, record["path"]) + and any("string" in _schema_types(node) for node in _schema_nodes_at_path(root_schema, record["path"])) + and _valid_pair([record.get("min"), record.get("max")], integral=True, nonnegative=True) + and _number(record.get("avg")) is not None + and (stddev := _number(record.get("stddev"))) is not None + and stddev >= 0 + for record in records + ) + + +def _annotation_is_supported_at_node( + key: str, value: object, node: Mapping[str, object], path: str, root_schema: SchemaRecord +) -> bool: + schema_type = _schema_type(node) + schema_types = _schema_types(node) + union_path = ".anyOf[" in path or ".oneOf[" in path + if key in { + "x-polylogue-foreign-keys", + "x-polylogue-time-deltas", + "x-polylogue-mutually-exclusive", + "x-polylogue-string-lengths", + }: + if key in {"x-polylogue-foreign-keys", "x-polylogue-time-deltas"}: + return False + return path == "$" and _annotation_supported(key, value, root_schema) + if key == "x-polylogue-frequency": + return path != "$" and _annotation_supported(key, value, root_schema) + if key == "x-polylogue-array-lengths": + return _annotation_supported(key, value, root_schema) and ("array" in schema_types or union_path) + if key == "x-polylogue-observed-distribution": + if schema_type not in {"array", "number", "integer"} or not isinstance(value, Mapping): + return False + expected_distribution = "array_length" if schema_type == "array" else "numeric" + return expected_distribution in value and _annotation_supported(key, value, root_schema) + if key == "x-polylogue-range": + return schema_type in {"number", "integer"} and _annotation_supported(key, value, root_schema) + if key in {"x-polylogue-format", "x-polylogue-values", "x-polylogue-multiline"}: + if key == "x-polylogue-format" and schema_type in {"number", "integer"}: + return value == "unix-epoch" + return _annotation_supported(key, value, root_schema) and ( + schema_type == "string" or "string" in schema_types or union_path + ) + if key == "x-polylogue-semantic-role": + role = value if isinstance(value, str) else None + if role == "message_timestamp": + return bool(schema_types & {"string", "number", "integer"}) and _annotation_supported( + key, value, root_schema + ) + if role == "message_container": + return "object" in schema_types + return schema_type == "string" and _annotation_supported(key, value, root_schema) + return key in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS + + +def classify_schema_constructs(schema: object) -> tuple[ConstructSupport, ...]: + """Classify exactly what the production synthetic runtime can consume.""" + + found: dict[str, ConstructSupportState] = {} + + def record(key: str, state: ConstructSupportState) -> None: + if found.get(key) == "unsupported": + return + found[key] = state + + def visit(node: object, path: str = "$") -> None: + if not isinstance(node, Mapping): + return + for key, value in node.items(): + if not isinstance(key, str): + continue + if key.startswith("x-"): + if key not in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS: + record( + key, + "supported" + if _annotation_is_supported_at_node( + key, value, node, path, schema if isinstance(schema, dict) else {} + ) + else "unsupported", + ) + continue + record(key, "supported" if key in _SUPPORTED_SCHEMA_CONSTRUCTS else "unsupported") + if key in _SCHEMA_MAPPING_KEYWORDS and isinstance(value, Mapping): + if key in {"$defs", "dependentSchemas", "dependencies", "patternProperties", "properties"}: + for child_name, child in value.items(): + child_path = f"{path}.{child_name}" if key == "properties" else f"{path}.{key}.{child_name}" + visit(child, child_path) + else: + visit(value, f"{path}.{key}") + elif key in _SCHEMA_ARRAY_KEYWORDS and isinstance(value, Sequence) and not isinstance(value, str): + for index, child in enumerate(value): + visit(child, f"{path}.{key}[{index}]") + elif key == "items": + if isinstance(value, Sequence) and not isinstance(value, str): + for index, child in enumerate(value): + visit(child, f"{path}.items[{index}]") + else: + visit(value, f"{path}.items") + elif key == "additionalProperties" and isinstance(value, Mapping): + visit(value, f"{path}.additionalProperties") + + visit(schema) + return tuple(ConstructSupport(construct, found[construct]) for construct in sorted(found)) + + +def unsupported_schema_constructs(schema: object) -> tuple[str, ...]: + """Return the canonical unsupported construct details for one schema.""" + + return tuple(item.construct for item in classify_schema_constructs(schema) if item.state == "unsupported") + + +__all__ = ["ConstructSupport", "ConstructSupportState", "classify_schema_constructs", "unsupported_schema_constructs"] diff --git a/tests/infra/inferred_corpus.py b/tests/infra/inferred_corpus.py index 46d1433e49..ebc73bb7ab 100644 --- a/tests/infra/inferred_corpus.py +++ b/tests/infra/inferred_corpus.py @@ -11,8 +11,6 @@ import hashlib import json -import math -import re from collections.abc import Mapping, Sequence from dataclasses import dataclass, replace from pathlib import Path @@ -29,10 +27,10 @@ from polylogue.schemas.operator.registry import RuntimeSchemaRegistryLike from polylogue.schemas.packages import SchemaElementManifest, SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.synthetic import SyntheticCorpus +from polylogue.schemas.synthetic.classification import ConstructSupport, classify_schema_constructs from polylogue.schemas.synthetic.models import SchemaRecord, SyntheticSchemaSelection from polylogue.schemas.synthetic.wire_formats import PROVIDER_WIRE_FORMATS, WireFormat -ConstructSupportState: TypeAlias = Literal["supported", "unsupported"] INFERRED_CORPUS_MANIFEST_SCHEMA_VERSION = 1 UnsupportedCorpusReason: TypeAlias = Literal[ "provider_without_wire_format", @@ -42,190 +40,6 @@ ] PackageReceipt: TypeAlias = JSONDocument -# These are the schema constructs the current recursive synthetic runtime can -# consume as structure. ``additionalProperties`` is intentionally included: -# the generator emits declared properties and does not need to materialize -# arbitrary unknown keys for the current corpus contract. -_SUPPORTED_SCHEMA_CONSTRUCTS = frozenset( - { - "$anchor", - "$comment", - "$id", - "$schema", - "additionalProperties", - "anyOf", - "default", - "deprecated", - "description", - "examples", - "items", - "oneOf", - "properties", - "readOnly", - "title", - "type", - "writeOnly", - } -) - -# The synthetic runtime can select structural branches and emit declared -# properties, but it does not validate generated values against JSON Schema. -# Every assertion keyword outside the explicit structural subset therefore -# fails closed. This keeps a manifest spec from implying conformance to a -# pattern, format, range, collection bound, or other constraint it cannot -# prove. -_STANDARD_SCHEMA_KEYWORDS = frozenset( - { - "$anchor", - "$comment", - "$defs", - "$dynamicAnchor", - "$dynamicRef", - "$id", - "$recursiveAnchor", - "$recursiveRef", - "$ref", - "$schema", - "$vocabulary", - "additionalProperties", - "allOf", - "anyOf", - "const", - "contains", - "contentEncoding", - "contentMediaType", - "contentSchema", - "default", - "definitions", - "dependentRequired", - "dependentSchemas", - "dependencies", - "deprecated", - "description", - "else", - "enum", - "examples", - "exclusiveMaximum", - "exclusiveMinimum", - "format", - "formatAssertion", - "if", - "items", - "maxContains", - "maxItems", - "maxLength", - "maxProperties", - "maximum", - "minContains", - "minItems", - "minLength", - "minProperties", - "minimum", - "multipleOf", - "not", - "oneOf", - "pattern", - "patternProperties", - "prefixItems", - "properties", - "propertyNames", - "readOnly", - "required", - "then", - "title", - "type", - "unevaluatedItems", - "unevaluatedProperties", - "uniqueItems", - "writeOnly", - } -) -_SCHEMA_MAPPING_KEYWORDS = frozenset( - { - "$defs", - "additionalProperties", - "contentSchema", - "contains", - "dependentSchemas", - "dependencies", - "else", - "if", - "items", - "not", - "patternProperties", - "properties", - "propertyNames", - "then", - "unevaluatedItems", - "unevaluatedProperties", - } -) -_SCHEMA_ARRAY_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) - -# These annotations are read by the production synthetic runtime, semantic -# value generator, or relation solver. Their support verdict means the -# corresponding generator path consumes the annotation, rather than merely -# tolerating its namespace. -_SUPPORTED_SYNTHETIC_ANNOTATIONS = frozenset( - { - "x-polylogue-array-lengths", - "x-polylogue-foreign-keys", - "x-polylogue-format", - "x-polylogue-frequency", - "x-polylogue-multiline", - "x-polylogue-mutually-exclusive", - "x-polylogue-observed-distribution", - "x-polylogue-range", - "x-polylogue-semantic-role", - "x-polylogue-string-lengths", - "x-polylogue-time-deltas", - "x-polylogue-values", - } -) -_SUPPORTED_FORMAT_VALUES = frozenset( - {"uuid4", "uuid", "hex-id", "iso8601", "unix-epoch", "unix-epoch-str", "url", "email", "mime-type", "base64"} -) -_SUPPORTED_SEMANTIC_ROLE_VALUES = frozenset({"message_role", "message_body", "message_timestamp", "session_title"}) - -_PERSISTED_SCHEMA_METADATA_ANNOTATIONS = frozenset( - { - "x-polylogue-anchor-profile-family-id", - "x-polylogue-artifact-kind", - "x-polylogue-element-bundle-scope-count", - "x-polylogue-element-first-seen", - "x-polylogue-element-kind", - "x-polylogue-element-last-seen", - "x-polylogue-evidence", - "x-polylogue-evidence-confidence", - "x-polylogue-exact-structure-ids", - "x-polylogue-generated-at", - "x-polylogue-generator", - "x-polylogue-high-cardinality-keys", - "x-polylogue-observed-artifact-count", - "x-polylogue-package-profile-family-ids", - "x-polylogue-package-version", - "x-polylogue-profile-family-ids", - "x-polylogue-profile-tokens", - "x-polylogue-promoted-at", - "x-polylogue-registered-at", - "x-polylogue-sample-count", - "x-polylogue-sample-granularity", - "x-polylogue-score", - "x-polylogue-version", - } -) - - -@dataclass(frozen=True, order=True) -class ConstructSupport: - """Support verdict for one JSON Schema construct found in an element.""" - - construct: str - state: ConstructSupportState - - def to_payload(self) -> dict[str, str]: - return {"construct": self.construct, "state": self.state} - @dataclass(frozen=True, order=True) class CorpusManifestKey: @@ -583,426 +397,10 @@ def read_inferred_corpus_manifest(path: Path, *, campaign_mode: bool = False) -> return manifest -def _is_number(value: object) -> bool: - if not isinstance(value, (int, float)) or isinstance(value, bool): - return False - try: - return math.isfinite(float(value)) - except (OverflowError, ValueError): - return False - - -def _number(value: object) -> int | float | None: - if isinstance(value, int) and not isinstance(value, bool): - return value if _is_number(value) else None - if isinstance(value, float) and not isinstance(value, bool) and math.isfinite(value): - return value - return None - - -def _valid_pair(value: object, *, integral: bool = False, nonnegative: bool = False) -> bool: - if not isinstance(value, list) or len(value) != 2: - return False - first = _number(value[0]) - second = _number(value[1]) - if first is None or second is None: - return False - if integral and (not isinstance(first, int) or not isinstance(second, int)): - return False - if nonnegative and (first < 0 or second < 0): - return False - return first <= second - - -def _histogram_bucket_is_safe(index: int, log_base: int | float) -> bool: - try: - sampled_value = math.expm1((abs(float(index)) - 0.5) * math.log(float(log_base))) - except (OverflowError, ValueError): - return False - return math.isfinite(sampled_value) - - -def _valid_observed_distribution(value: object) -> bool: - if not isinstance(value, Mapping) or not value: - return False - for distribution in value.values(): - if not isinstance(distribution, Mapping): - return False - histogram = distribution.get("histogram") - log_base = _number(distribution.get("log_base")) - if not isinstance(histogram, list) or not histogram or log_base is None or log_base <= 1: - return False - log_base_float = float(log_base) - if not all( - isinstance(bucket, list) - and len(bucket) == 2 - and isinstance(bucket[0], int) - and not isinstance(bucket[0], bool) - and isinstance(bucket[1], int) - and not isinstance(bucket[1], bool) - and _number(bucket[0]) is not None - and _number(bucket[1]) is not None - and bucket[1] > 0 - and _histogram_bucket_is_safe(bucket[0], log_base_float) - for bucket in histogram - ): - return False - for key in ("min", "max", "mean", "p0", "p50", "p90", "p95", "p99", "p100", "stddev"): - if key in distribution and not _is_number(distribution[key]): - return False - minimum = _number(distribution.get("min")) - maximum = _number(distribution.get("max")) - if minimum is not None and maximum is not None and minimum > maximum: - return False - stddev = _number(distribution.get("stddev")) - if stddev is not None and stddev < 0: - return False - ordered_stats = tuple(_number(distribution.get(key)) for key in ("p0", "p50", "p90", "p95", "p99", "p100")) - present_stats = tuple(value for value in ordered_stats if value is not None) - if any(left > right for left, right in zip(present_stats, present_stats[1:], strict=False)): - return False - if any( - (minimum is not None and value < minimum) or (maximum is not None and value > maximum) - for value in present_stats - ): - return False - mean = _number(distribution.get("mean")) - if mean is not None: - if minimum is not None and mean < minimum: - return False - if maximum is not None and mean > maximum: - return False - p0 = _number(distribution.get("p0")) - p100 = _number(distribution.get("p100")) - if p0 is not None and mean < p0: - return False - if p100 is not None and mean > p100: - return False - return True - - -def _relation_records(value: object, required: tuple[str, ...]) -> list[dict[str, object]] | None: - if not isinstance(value, list) or not value: - return None - records: list[dict[str, object]] = [] - for record in value: - if not isinstance(record, dict): - return None - if not all(isinstance(record.get(field), str) and record[field].strip() for field in required): - return None - records.append(record) - return records - - -_SCHEMA_PATH_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") - - -def _schema_nodes_at_path(schema: SchemaRecord, path: object) -> tuple[SchemaRecord, ...]: - """Resolve the JSONPath subset emitted by the relation solver.""" - - if not isinstance(path, str) or not path.startswith("$"): - return () - if path == "$": - return (schema,) - if not path.startswith("$."): - return () - nodes: tuple[SchemaRecord, ...] = (schema,) - for raw_segment in path[2:].split("."): - if raw_segment.endswith("[*]"): - segment = raw_segment[:-3] - wants_items = True - else: - segment = raw_segment - wants_items = False - if not _SCHEMA_PATH_SEGMENT.fullmatch(segment): - return () - next_nodes: list[SchemaRecord] = [] - for node in nodes: - variants = [node] - for branch_key in ("anyOf", "oneOf"): - branches = node.get(branch_key) - if isinstance(branches, list): - variants.extend(branch for branch in branches if isinstance(branch, dict)) - for variant in variants: - properties = variant.get("properties") - child = properties.get(segment) if isinstance(properties, dict) else None - if not isinstance(child, dict): - continue - if wants_items: - child_variants = [child] - for child_branch_key in ("anyOf", "oneOf"): - child_branches = child.get(child_branch_key) - if isinstance(child_branches, list): - child_variants.extend(branch for branch in child_branches if isinstance(branch, dict)) - for child_variant in child_variants: - items = child_variant.get("items") - if isinstance(items, dict): - next_nodes.append(items) - else: - next_nodes.append(child) - nodes = tuple(next_nodes) - if not nodes: - return () - return nodes - - -def _schema_nodes_have_type(nodes: tuple[SchemaRecord, ...], allowed: set[str]) -> bool: - return bool(nodes) and all(bool(types := _schema_types(node)) and types <= allowed for node in nodes) - - -def _schema_type_family(nodes: tuple[SchemaRecord, ...]) -> str | None: - types = {_schema_type(node) for node in nodes} - if types and types <= {"string"}: - return "string" - if types and types <= {"number", "integer"}: - return "numeric" - return None - - -def _relation_annotation_paths_are_enforced( - key: str, - value: object, - root_schema: SchemaRecord, -) -> bool: - records = _relation_records( - value, - { - "x-polylogue-foreign-keys": ("source", "target"), - "x-polylogue-time-deltas": ("field_a", "field_b"), - "x-polylogue-mutually-exclusive": ("parent",), - "x-polylogue-string-lengths": ("path",), - }[key], - ) - if records is None: - return False - if key == "x-polylogue-foreign-keys": - return False - if key == "x-polylogue-time-deltas": - # The solver parses these records, but no production generation path - # calls get_time_delta yet. Keep them fail-closed until it does. - return False - if key == "x-polylogue-mutually-exclusive": - for record in records: - fields = record.get("fields") - if not isinstance(fields, list) or not all(isinstance(field, str) for field in fields): - return False - if not all( - _schema_nodes_have_type( - _schema_nodes_at_path(root_schema, f"{record['parent']}.{field}"), - {"string", "number", "integer", "boolean", "array", "object", "null"}, - ) - for field in fields - ): - return False - return True - if key == "x-polylogue-string-lengths": - return all( - bool( - _schema_nodes_at_path(root_schema, record["path"]) - and any("string" in _schema_types(node) for node in _schema_nodes_at_path(root_schema, record["path"])) - ) - for record in records - ) - return False - - -def _synthetic_annotation_is_enforced(key: str, value: object) -> bool: - """Return whether the production generator or solver enforces this payload.""" - - if key not in _SUPPORTED_SYNTHETIC_ANNOTATIONS: - return False - if key == "x-polylogue-format": - return isinstance(value, str) and value in _SUPPORTED_FORMAT_VALUES - if key == "x-polylogue-semantic-role": - return isinstance(value, str) and value in _SUPPORTED_SEMANTIC_ROLE_VALUES - if key == "x-polylogue-frequency": - frequency = _number(value) - return frequency is not None and 0 <= frequency <= 1 - if key == "x-polylogue-multiline": - return isinstance(value, bool) - if key == "x-polylogue-values": - return isinstance(value, list) and bool(value) and all(isinstance(item, str) and item for item in value) - if key == "x-polylogue-range": - return _valid_pair(value) - if key == "x-polylogue-array-lengths": - return _valid_pair(value, integral=True, nonnegative=True) - if key == "x-polylogue-observed-distribution": - return _valid_observed_distribution(value) - if key == "x-polylogue-foreign-keys": - return _relation_records(value, ("source", "target")) is not None - if key == "x-polylogue-time-deltas": - # The solver parses these records, but no production generation path - # calls get_time_delta yet. Keep them fail-closed until it does. - return False - if key == "x-polylogue-mutually-exclusive": - records = _relation_records(value, ("parent",)) - return records is not None and all( - isinstance(fields := record.get("fields"), list) - and len(fields) >= 2 - and all(isinstance(field, str) and field for field in fields) - for record in records - ) - if key == "x-polylogue-string-lengths": - records = _relation_records(value, ("path",)) - if records is None: - return False - string_records: list[tuple[int | float | None, int | float | None, int | float | None, int | float | None]] = [ - ( - _number(record.get("min")), - _number(record.get("max")), - _number(record.get("avg")), - _number(record.get("stddev")), - ) - for record in records - ] - for minimum, maximum, average, stddev in string_records: - if ( - not _valid_pair([minimum, maximum], integral=True, nonnegative=True) - or average is None - or stddev is None - or minimum is None - or maximum is None - or not minimum <= average <= maximum - or stddev < 0 - ): - return False - return True - raise AssertionError(f"missing annotation validator for {key}") - - -def _schema_type(node: Mapping[str, object]) -> str | None: - schema_type = node.get("type") - if isinstance(schema_type, str): - return schema_type - if isinstance(schema_type, list): - types = [item for item in schema_type if isinstance(item, str) and item != "null"] - return types[0] if len(types) == 1 else None - return None - - -def _schema_types(node: Mapping[str, object]) -> set[str]: - schema_type = node.get("type") - if isinstance(schema_type, str): - return {schema_type} - if isinstance(schema_type, list): - return {item for item in schema_type if isinstance(item, str)} - types: set[str] = set() - for keyword in ("anyOf", "oneOf"): - variants = node.get(keyword) - if isinstance(variants, list): - for variant in variants: - if isinstance(variant, Mapping): - types.update(_schema_types(variant)) - return types - - -def _annotation_is_enforced_at_node( - key: str, - value: object, - node: Mapping[str, object], - path: str, - root_schema: SchemaRecord, -) -> bool: - schema_type = _schema_type(node) - schema_types = _schema_types(node) - union_path = ".anyOf[" in path or ".oneOf[" in path - if key in { - "x-polylogue-foreign-keys", - "x-polylogue-time-deltas", - "x-polylogue-mutually-exclusive", - "x-polylogue-string-lengths", - }: - return ( - path == "$" - and _synthetic_annotation_is_enforced(key, value) - and _relation_annotation_paths_are_enforced(key, value, root_schema) - ) - if key == "x-polylogue-frequency": - return path != "$" and _synthetic_annotation_is_enforced(key, value) - if key in {"x-polylogue-array-lengths", "x-polylogue-observed-distribution"}: - if key == "x-polylogue-array-lengths": - return _synthetic_annotation_is_enforced(key, value) and ( - schema_type == "array" or "array" in schema_types or union_path - ) - if schema_type not in {"array", "number", "integer"} or not isinstance(value, Mapping): - return False - expected_distribution = "array_length" if schema_type == "array" else "numeric" - return expected_distribution in value and _valid_observed_distribution(value) - if key == "x-polylogue-range": - return schema_type in {"number", "integer"} and _synthetic_annotation_is_enforced(key, value) - if key in {"x-polylogue-format", "x-polylogue-values", "x-polylogue-multiline"}: - if key == "x-polylogue-format" and schema_type in {"number", "integer"}: - return value == "unix-epoch" - return _synthetic_annotation_is_enforced(key, value) and ( - schema_type == "string" or "string" in schema_types or union_path - ) - if key == "x-polylogue-semantic-role": - role = value if isinstance(value, str) else None - if role == "message_timestamp": - return bool(schema_types & {"string", "number", "integer"}) and _synthetic_annotation_is_enforced( - key, value - ) - if role == "message_container": - return schema_type == "object" - return schema_type == "string" and _synthetic_annotation_is_enforced(key, value) - return key in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS - - def _schema_constructs(schema: object) -> tuple[ConstructSupport, ...]: - """Census schema keywords and annotations at the paths production consumes.""" - - found: dict[str, ConstructSupportState] = {} - - def record_support(key: str, state: ConstructSupportState) -> None: - if found.get(key) == "unsupported": - return - found[key] = state - - def visit(node: object, path: str = "$") -> None: - if not isinstance(node, Mapping): - return - node_record = node - for key, value in node.items(): - if not isinstance(key, str): - continue - if key.startswith("x-"): - if key in _PERSISTED_SCHEMA_METADATA_ANNOTATIONS: - continue - record_support( - key, - "supported" - if _annotation_is_enforced_at_node(key, value, node_record, path, cast(SchemaRecord, schema)) - else "unsupported", - ) - continue - if key in _SUPPORTED_SCHEMA_CONSTRUCTS: - record_support(key, "supported") - elif key in _STANDARD_SCHEMA_KEYWORDS: - record_support(key, "unsupported") - else: - record_support(key, "unsupported") - - if key in _SCHEMA_MAPPING_KEYWORDS and isinstance(value, Mapping): - if key in {"$defs", "dependentSchemas", "dependencies", "patternProperties", "properties"}: - for child_name, child in value.items(): - child_path = f"{path}.{child_name}" if key == "properties" else f"{path}.{key}.{child_name}" - visit(child, child_path) - else: - visit(value, f"{path}.{key}") - elif key in _SCHEMA_ARRAY_KEYWORDS and isinstance(value, Sequence) and not isinstance(value, str): - for index, child in enumerate(value): - visit(child, f"{path}.{key}[{index}]") - elif key == "items": - if isinstance(value, Sequence) and not isinstance(value, str): - for index, child in enumerate(value): - visit(child, f"{path}.items[{index}]") - else: - visit(value, f"{path}.items") - elif key == "additionalProperties" and isinstance(value, Mapping): - visit(value, f"{path}.additionalProperties") - - visit(schema) - return tuple(ConstructSupport(construct, found[construct]) for construct in sorted(found)) + """Use the production classifier for campaign admission decisions.""" + + return classify_schema_constructs(schema) def build_inferred_corpus_convergence_handoff( diff --git a/tests/unit/schemas/test_inferred_corpus_manifest.py b/tests/unit/schemas/test_inferred_corpus_manifest.py index 09274b8b19..137cc76ffb 100644 --- a/tests/unit/schemas/test_inferred_corpus_manifest.py +++ b/tests/unit/schemas/test_inferred_corpus_manifest.py @@ -134,11 +134,64 @@ def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decision campaign_mode=True, ) + +def test_bundled_registry_relation_annotations_share_one_receipt_classification() -> None: + registry = _registry() + provider = "chatgpt" + receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest="a" * 64) + manifest = compile_inferred_corpus_manifest( + registry=registry, + providers=(provider,), + package_receipt=receipt.to_payload(), + campaign_mode=True, + ) + + expected_annotations = { + "x-polylogue-foreign-keys", + "x-polylogue-mutually-exclusive", + "x-polylogue-string-lengths", + "x-polylogue-time-deltas", + } + observed = { + item.construct + for entry in manifest.entries + for item in entry.key.construct_support + if item.construct in expected_annotations + } + assert observed == expected_annotations + receipt_decisions = { + (item.provider, item.package_version, item.element_kind, item.decision, item.reason, item.details) + for item in receipt.unsupported_decisions + if item.provider == provider + } + manifest_decisions: set[tuple[str, str, str, str, str, tuple[str, ...]]] = set() + for entry in manifest.entries: + if entry.unsupported is None: + continue + unsupported = entry.unsupported + manifest_decisions.add( + ( + entry.key.provider, + entry.key.package_version, + entry.key.element_kind, + "nonrepresentable" if unsupported.reason == "unsupported_json_schema_construct" else "unsupported", + unsupported.reason, + unsupported.details, + ) + ) + assert receipt_decisions == manifest_decisions + assert all( + annotation in details + for *_identity, details in receipt_decisions + for annotation in expected_annotations + if annotation in observed + ) + package = receipt.packages[0] if receipt.unsupported_decisions: first = receipt.unsupported_decisions[0] changed = replace(first, decision="unsupported" if first.decision == "nonrepresentable" else "nonrepresentable") - unsupported = (changed, *receipt.unsupported_decisions[1:]) + tampered_decisions = (changed, *receipt.unsupported_decisions[1:]) else: changed = SchemaInferenceUnsupportedDecision( provider=provider, @@ -148,8 +201,8 @@ def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decision reason="tampered decision", details=("tampered_construct",), ) - unsupported = (changed,) - tampered_unsupported = replace(receipt, unsupported_decisions=tuple(sorted(unsupported))) + tampered_decisions = (changed,) + tampered_unsupported = replace(receipt, unsupported_decisions=tuple(sorted(tampered_decisions))) with pytest.raises(ValueError, match="unsupported/nonrepresentable decisions"): compile_inferred_corpus_manifest( registry=registry, diff --git a/tests/unit/schemas/test_operator_commit.py b/tests/unit/schemas/test_operator_commit.py index c3bcd6d897..3c7c5a6ee2 100644 --- a/tests/unit/schemas/test_operator_commit.py +++ b/tests/unit/schemas/test_operator_commit.py @@ -8,7 +8,8 @@ called: every assertion below reads back real gzip/JSON files written by ``SchemaRegistry.replace_provider_packages`` under a real ``tmp_path``, using a fictional provider token so nothing here can read or write the repo's real -committed ``polylogue/schemas/providers/`` tree. +committed ``polylogue/schemas/providers/`` tree. The real bundled ``chatgpt`` +wire format is used for campaign execution. Only ``_build_provider_bundle`` (the sample-observation step) is mocked, the same seam ``tests/unit/core/test_schema_generation.py`` uses for @@ -28,7 +29,6 @@ import pytest -from polylogue.maintenance.schema_inference_gate import schema_inference_gate_receipt_digest from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.operator.commit import commit_provider_schema from polylogue.schemas.operator.models import SchemaCommitRequest @@ -36,25 +36,63 @@ from polylogue.schemas.packages import SchemaElementManifest, SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.tooling_models import ClusterManifest +from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation from tests.infra.inferred_corpus import compile_inferred_corpus_manifest -_PROVIDER = "commit-fixture-k45pq" +_PROVIDER = "chatgpt" def _gate_receipt(output_dir: Path) -> Path: path = output_dir.parent / "schema-inference-gate-receipt.json" - payload = {"schema": "polylogue.schema-inference-gate.v1", "gate_version": "test", "verdict": "PASS"} + archive_root = output_dir.parent + payload = { + "schema": "polylogue.schema-inference-gate.v1", + "gate_version": "2", + "generated_at": "2026-08-05T14:00:00+00:00", + "receipt_nonce": "00000000-0000-4000-8000-000000000001", + "verdict": "PASS", + "archive_root": str(archive_root.absolute()), + "archive_identity_digest": ArchiveIdentity.resolve_location( + ArchiveLocation.resolve(archive_root) + ).authority_identity_digest, + "schema_identity": {}, + "source_schema_identity": {}, + "query_results": {"pristine": {"passed": True}}, + "source_denominators": {}, + "blob_denominators": {}, + "ground_truth_denominators": {}, + "ground_truth_inputs": {"passed": True}, + "exemptions": {}, + "corpus_fidelity": {"passed": True}, + "full_blob_hash_verification": { + "passed": True, + "verifier": {"identity": "polylogue.storage.blob_store.BlobStore.verify_all"}, + "before_snapshot": {"digest": "a" * 64}, + "after_snapshot": {"digest": "a" * 64}, + "failures": [], + "missing_references": [], + "errors": [], + }, + "input_paths": {}, + "tool_versions": {}, + "pass_fail_reasons": [], + } path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") - assert schema_inference_gate_receipt_digest(payload) return path -def _request(output_dir: Path, *, dry_run: bool = False) -> SchemaCommitRequest: +def _request( + output_dir: Path, + *, + dry_run: bool = False, + gate_path: Path | None = None, +) -> SchemaCommitRequest: return SchemaCommitRequest( provider=_PROVIDER, output_dir=output_dir, + db_path=output_dir.parent / "index.db", full_corpus=True, - schema_inference_gate_receipt_path=_gate_receipt(output_dir), + schema_inference_gate_receipt_path=gate_path or _gate_receipt(output_dir), dry_run=dry_run, ) @@ -162,12 +200,85 @@ def test_commit_to_registry_to_campaign_manifest_is_a_real_route(self, tmp_path: ) assert manifest.receipt_state == "package_receipt_attached" assert len(manifest.entries) == 1 + entry = manifest.entries[0] + assert entry.spec is not None + assert entry.generator_schema is not None + assert entry.key.provider == "chatgpt" + + element_path = output_dir / _PROVIDER / "versions" / "v1" / "elements" / "session_document.schema.json.gz" + mutated_schema = _read_element_schema(output_dir, "v1") + mutated_schema["title"] = "mutation" + with gzip.open(element_path, "wt", encoding="utf-8") as handle: + json.dump(mutated_schema, handle) + with pytest.raises(ValueError, match="package/version/element hashes"): + compile_inferred_corpus_manifest( + registry=SchemaRegistry(storage_root=output_dir), + providers=(_PROVIDER,), + package_receipt=result.handoff.to_payload(), + campaign_mode=True, + ) def test_commit_requires_an_accepted_gate_receipt(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" with pytest.raises(ValueError, match="accepted schema-inference gate receipt"): commit_provider_schema(SchemaCommitRequest(provider=_PROVIDER, output_dir=output_dir, full_corpus=True)) + def test_commit_rejects_a_minimal_or_mutated_pass_payload(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + receipt_path = _gate_receipt(output_dir) + payload = json.loads(receipt_path.read_text(encoding="utf-8")) + payload.pop("full_blob_hash_verification") + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="authoritative fields"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + payload["full_blob_hash_verification"]["passed"] = False + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="full_blob_hash_verification PASS"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + payload["archive_identity_digest"] = "0" * 64 + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="archive identity"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + payload["receipt_nonce"] = "forged" + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="receipt nonce"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + payload["generated_at"] = "2020-01-01T00:00:00+00:00" + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="stale or from the future"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + def test_registry_construct_rejection_remains_an_explicit_unsupported_entry(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + bundle = _bundle( + version="v1", + schema={"type": "object", "properties": {"id": {"type": "string", "enum": ["x"]}}}, + sample_count=5, + ) + with patch("polylogue.schemas.generation.workflow._build_provider_bundle", return_value=bundle): + result = commit_provider_schema(_request(output_dir)) + + assert result.handoff is not None + manifest = compile_inferred_corpus_manifest( + registry=SchemaRegistry(storage_root=output_dir), + providers=(_PROVIDER,), + package_receipt=result.handoff.to_payload(), + campaign_mode=True, + ) + entry = manifest.entries[0] + assert entry.spec is None + assert entry.unsupported is not None + assert entry.unsupported.reason == "unsupported_json_schema_construct" + assert "enum" in entry.unsupported.details + def test_regeneration_with_new_field_reports_changed_and_added(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" first_schema = {"type": "object", "properties": {"id": {"type": "string"}}} @@ -277,6 +388,7 @@ def test_failed_generation_reports_no_success_and_no_versions(self, tmp_path: Pa SchemaCommitRequest( provider="not-a-real-provider-k45pq", output_dir=output_dir, + db_path=output_dir.parent / "index.db", full_corpus=True, schema_inference_gate_receipt_path=_gate_receipt(output_dir), ) From 50ef4a759ea55fb64063e215184e462b9cd50c8d Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 16:55:15 +0200 Subject: [PATCH 3/7] test: freeze gate receipt freshness fixtures Problem The receipt freshness regression used a wall-clock timestamp tied to the work date, which could become stale before a later CI run. What changed Freeze the gate validator clock for the commit-route tests and anchor valid fixtures to the shared deterministic test instant. Keep the stale mutation case explicit. Compatibility/migration No runtime behavior changes. --- tests/unit/schemas/test_operator_commit.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unit/schemas/test_operator_commit.py b/tests/unit/schemas/test_operator_commit.py index 3c7c5a6ee2..76e0df1169 100644 --- a/tests/unit/schemas/test_operator_commit.py +++ b/tests/unit/schemas/test_operator_commit.py @@ -37,6 +37,7 @@ from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.tooling_models import ClusterManifest from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation +from tests.infra.frozen_clock import FrozenClock from tests.infra.inferred_corpus import compile_inferred_corpus_manifest _PROVIDER = "chatgpt" @@ -48,7 +49,7 @@ def _gate_receipt(output_dir: Path) -> Path: payload = { "schema": "polylogue.schema-inference-gate.v1", "gate_version": "2", - "generated_at": "2026-08-05T14:00:00+00:00", + "generated_at": "2023-11-14T22:13:20+00:00", "receipt_nonce": "00000000-0000-4000-8000-000000000001", "verdict": "PASS", "archive_root": str(archive_root.absolute()), @@ -152,7 +153,12 @@ def _read_element_schema(output_dir: Path, version: str, element_kind: str = "se return cast("dict[str, Any]", json.load(handle)) +@pytest.mark.frozen_clock_modules("polylogue.maintenance.schema_inference_gate") class TestCommitProviderSchemaWritesRealFiles: + @pytest.fixture(autouse=True) + def _freeze_gate_clock(self, frozen_clock: FrozenClock) -> None: + pass + def test_new_provider_writes_catalog_and_element_files(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" schema = {"type": "object", "properties": {"id": {"type": "string"}}} From 0e38656f110ac4472005f9013296418fd22d75c4 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 17:39:54 +0200 Subject: [PATCH 4/7] fix: bind schema campaigns to live gate evidence Problem: Schema commit receipts could be treated as self-asserted after their hard-gate payload was mutated, and archive targeting inferred the root from db_path.parent. Campaign manifests could also outlive registry package or classifier changes and admit a provider with no executable witness. What changed: Recompute the exact source-query, corpus-fidelity, ground-truth, blob-verification, schema-identity, and ArchiveLocation evidence at commit time. Bind commit requests to the configured archive root, revalidate live package hashes and classifier output, generate a real SyntheticCorpus witness, and reject all-unsupported campaigns. Add mutation-sensitive real-route coverage and preserve the CLI error envelope. Compatibility/migration: Existing handoffs need a fresh gate receipt containing the new hard-gate evidence digest. Production commits still require an operator-generated PASS receipt for the configured archive and external ground-truth roots. --- devtools/schema_commit.py | 31 +++-- .../maintenance/schema_inference_gate.py | 114 +++++++++++++++++- polylogue/schemas/operator/commit.py | 34 +++--- polylogue/schemas/operator/models.py | 1 + polylogue/schemas/operator/receipt.py | 36 ++++-- tests/infra/inferred_corpus.py | 112 ++++++++++++++--- .../devtools/test_schema_commit_command.py | 43 ++++++- .../schemas/test_inferred_corpus_manifest.py | 48 +++++++- tests/unit/schemas/test_operator_commit.py | 96 +++++++++------ 9 files changed, 416 insertions(+), 99 deletions(-) diff --git a/devtools/schema_commit.py b/devtools/schema_commit.py index e1a7d96c54..45f285491f 100644 --- a/devtools/schema_commit.py +++ b/devtools/schema_commit.py @@ -87,18 +87,27 @@ def main(argv: list[str] | None = None) -> int: return 1 output_dir = args.output_dir if args.output_dir is not None else DEFAULT_OUTPUT_DIR - result = commit_provider_schema( - SchemaCommitRequest( - provider=str(args.provider), - output_dir=output_dir, - db_path=get_config().db_path, - max_samples=args.max_samples, - privacy_config=privacy_config, - full_corpus=bool(args.full_corpus), - dry_run=bool(args.dry_run), - schema_inference_gate_receipt_path=args.schema_inference_gate_receipt, + config = get_config() + try: + result = commit_provider_schema( + SchemaCommitRequest( + provider=str(args.provider), + output_dir=output_dir, + archive_root=config.archive_root, + db_path=config.db_path, + max_samples=args.max_samples, + privacy_config=privacy_config, + full_corpus=bool(args.full_corpus), + dry_run=bool(args.dry_run), + schema_inference_gate_receipt_path=args.schema_inference_gate_receipt, + ) ) - ) + except ValueError as exc: + if args.json: + print(json.dumps({"provider": str(args.provider), "success": False, "error": str(exc)}, sort_keys=True)) + else: + print(f"schema-commit: {exc}", file=sys.stderr) + return 1 if not result.success: error = result.generation.error or "Schema generation failed" diff --git a/polylogue/maintenance/schema_inference_gate.py b/polylogue/maintenance/schema_inference_gate.py index 67b0cb90e5..32cacb70b4 100644 --- a/polylogue/maintenance/schema_inference_gate.py +++ b/polylogue/maintenance/schema_inference_gate.py @@ -1023,6 +1023,32 @@ def schema_inference_gate_receipt_digest(payload: Mapping[str, object]) -> str: return hashlib.sha256(encoded.encode("utf-8")).hexdigest() +def _hard_gate_evidence_payload( + query_results: Mapping[str, object], full_blob_hash_verification: Mapping[str, object] +) -> dict[str, object]: + """Return the exact live evidence that a commit is allowed to consume.""" + + return { + "query_results": dict(query_results), + "full_blob_hash_verification": dict(full_blob_hash_verification), + } + + +def schema_inference_hard_gate_evidence_digest( + query_results: Mapping[str, object], full_blob_hash_verification: Mapping[str, object] +) -> str: + """Digest the complete source-query and blob-verifier evidence payload.""" + + evidence = json.dumps( + _hard_gate_evidence_payload(query_results, full_blob_hash_verification), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + return hashlib.sha256(evidence.encode("utf-8")).hexdigest() + + def validate_schema_inference_gate_receipt( payload: Mapping[str, object], *, @@ -1057,6 +1083,8 @@ def validate_schema_inference_gate_receipt( "input_paths", "tool_versions", "pass_fail_reasons", + "sample_limit", + "hard_gate_evidence_digest", } missing = sorted(required_fields - set(payload)) if missing: @@ -1088,18 +1116,90 @@ def validate_schema_inference_gate_receipt( if age_seconds < -300 or age_seconds > RECEIPT_MAX_AGE_SECONDS: raise ValueError("schema-inference gate receipt is stale or from the future") - expected_root = archive_root.absolute() + location = ArchiveLocation.resolve(Path(archive_root).absolute()) + expected_root = location.configured_root recorded_root = payload.get("archive_root") if not isinstance(recorded_root, str) or Path(recorded_root).absolute() != expected_root: raise ValueError("schema-inference gate receipt targets a different archive") try: - location = ArchiveLocation.resolve(expected_root) expected_identity_digest = ArchiveIdentity.resolve_location(location).authority_identity_digest except (OSError, ValueError, RuntimeError) as exc: raise ValueError(f"unable to resolve target archive identity: {exc}") from exc if payload.get("archive_identity_digest") != expected_identity_digest: raise ValueError("schema-inference gate receipt archive identity is stale or mismatched") + sample_limit = payload.get("sample_limit") + if not isinstance(sample_limit, int) or isinstance(sample_limit, bool) or sample_limit <= 0: + raise ValueError("schema-inference gate receipt sample_limit is invalid") + input_paths = payload.get("input_paths") + expected_input_paths = { + "archive_root": str(expected_root), + "source_db": str(expected_root / ARCHIVE_TIER_SPECS[ArchiveTier.SOURCE].filename), + "active_index_db": str(location.active_index_path), + "receipt": str(Path(str(input_paths.get("receipt"))).absolute()) + if isinstance(input_paths, Mapping) and isinstance(input_paths.get("receipt"), str) + else None, + } + if not isinstance(input_paths, Mapping) or any( + input_paths.get(key) != value for key, value in expected_input_paths.items() if key != "receipt" + ): + raise ValueError("schema-inference gate receipt input paths are not bound to the configured archive") + + try: + live_schema_identity = _tier_schema_identity(expected_root, location) + with open_readonly_connection(expected_root / ARCHIVE_TIER_SPECS[ArchiveTier.SOURCE].filename) as source: + referenced_hashes = _referenced_blob_hashes(source) + live_source_gates = _run_source_gates( + expected_root, index_path=location.active_index_path, sample_limit=sample_limit + ) + live_query_results = _as_dict(live_source_gates.get("gates")) + live_duplicate_gate = live_source_gates.get("duplicate_gate") + if isinstance(live_duplicate_gate, Mapping): + live_query_results["zero-unexplained-byte-duplicates"] = dict(live_duplicate_gate) + live_full_blob = _full_blob_hash_evidence(expected_root, referenced_hashes=referenced_hashes) + except (OSError, sqlite3.Error, ValueError) as exc: + raise ValueError(f"unable to recompute schema-inference gate evidence: {exc}") from exc + if payload.get("schema_identity") != live_schema_identity or payload.get("source_schema_identity") != _as_dict( + _as_dict(live_schema_identity.get("tiers")).get("source") + ): + raise ValueError("schema-inference gate receipt schema evidence is stale or mismatched") + if payload.get("query_results") != live_query_results: + raise ValueError("schema-inference gate receipt hard-gate query results changed") + if payload.get("source_denominators") != live_source_gates.get("source_counts", {}): + raise ValueError("schema-inference gate receipt source denominators changed") + if payload.get("blob_denominators") != live_source_gates.get("blob_denominators", {}): + raise ValueError("schema-inference gate receipt blob denominators changed") + recorded_ground_truth = payload.get("ground_truth_inputs") + recorded_origins = recorded_ground_truth.get("origins") if isinstance(recorded_ground_truth, Mapping) else None + live_ground_truth_roots: dict[str, tuple[Path, ...]] = {} + if isinstance(recorded_origins, Mapping): + for origin, raw_evidence in recorded_origins.items(): + if not isinstance(origin, str) or not isinstance(raw_evidence, Mapping): + continue + raw_roots = raw_evidence.get("declared_roots") + if isinstance(raw_roots, list) and all(isinstance(path, str) for path in raw_roots): + live_ground_truth_roots[origin] = tuple(Path(path) for path in raw_roots) + live_ground_truth = _ground_truth_evidence( + expected_root, + index_path=location.active_index_path, + source_counts=cast(Mapping[str, Mapping[str, int]], live_source_gates.get("source_counts", {})), + roots=live_ground_truth_roots, + ) + if payload.get("ground_truth_inputs") != live_ground_truth: + raise ValueError("schema-inference gate receipt ground-truth evidence changed") + try: + live_fidelity = _fidelity_evidence( + verify_archive(expected_root, checks=CORPUS_FIDELITY_CHECKS, sample_limit=sample_limit) + ) + except Exception as exc: + raise ValueError(f"unable to recompute schema-inference corpus fidelity: {exc}") from exc + fidelity_keys = ("passed", "reasons", "typed_residuals", "denominators") + recorded_fidelity = payload.get("corpus_fidelity") + if not isinstance(recorded_fidelity, Mapping) or any( + recorded_fidelity.get(key) != live_fidelity.get(key) for key in fidelity_keys + ): + raise ValueError("schema-inference gate receipt corpus-fidelity evidence changed") + full_blob = payload.get("full_blob_hash_verification") if not isinstance(full_blob, Mapping) or full_blob.get("passed") is not True: raise ValueError("schema-inference gate receipt lacks an explicit full_blob_hash_verification PASS") @@ -1121,6 +1221,10 @@ def validate_schema_inference_gate_receipt( or full_blob.get("errors") != [] ): raise ValueError("schema-inference gate receipt full blob verification evidence is incomplete") + if payload.get("hard_gate_evidence_digest") != schema_inference_hard_gate_evidence_digest( + live_query_results, live_full_blob + ): + raise ValueError("schema-inference gate receipt hard-gate evidence digest does not match live evidence") query_results = payload.get("query_results") if ( @@ -1235,6 +1339,7 @@ def run_schema_inference_gate( "gate_version": GATE_VERSION, "generated_at": datetime.now(UTC).isoformat(), "receipt_nonce": str(uuid.uuid4()), + "sample_limit": sample_limit, "verdict": "PASS" if not reasons and passed_hard_gates else "FAIL", "archive_root": str(root), "archive_identity_digest": ( @@ -1265,6 +1370,10 @@ def run_schema_inference_gate( }, "pass_fail_reasons": reasons, } + payload["hard_gate_evidence_digest"] = schema_inference_hard_gate_evidence_digest( + cast(Mapping[str, object], payload["query_results"]), + cast(Mapping[str, object], full_blob_hash_verification), + ) _write_json(safe_receipt_path, payload) return SchemaInferenceGateResult(payload) @@ -1278,6 +1387,7 @@ def run_schema_inference_gate( "SchemaInferenceGateError", "SchemaInferenceGateResult", "schema_inference_gate_receipt_digest", + "schema_inference_hard_gate_evidence_digest", "validate_schema_inference_gate_receipt", "run_schema_inference_gate", ] diff --git a/polylogue/schemas/operator/commit.py b/polylogue/schemas/operator/commit.py index fe431a75cc..a1427b7f61 100644 --- a/polylogue/schemas/operator/commit.py +++ b/polylogue/schemas/operator/commit.py @@ -41,7 +41,7 @@ from polylogue.maintenance.schema_inference_gate import ( validate_schema_inference_gate_receipt, ) -from polylogue.paths import db_path as default_index_db_path +from polylogue.paths import archive_root as default_archive_root from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.generation.workflow import generate_all_schemas from polylogue.schemas.operator.inference import privacy_config_from_payload @@ -56,6 +56,7 @@ from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.runtime_registry import canonical_schema_provider from polylogue.schemas.type_narrowing import added_paths, narrowed_paths +from polylogue.storage.archive_identity import ArchiveLocation def _element_schemas_by_kind( @@ -82,16 +83,24 @@ def _accepted_gate_receipt_digest(path: Path | None, *, archive_root: Path) -> s def _target_archive_root(request: SchemaCommitRequest) -> Path: - target_db = request.db_path or default_index_db_path() - return target_db.absolute().parent + configured_root = request.archive_root or default_archive_root() + return ArchiveLocation.resolve(configured_root).configured_root def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommitResult: provider_token = str(canonical_schema_provider(request.provider)) + output_dir = output_dir.absolute() + handoff_path = output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME + existing_handoff = load_schema_inference_receipt(handoff_path) if handoff_path.exists() else None gate_receipt_digest = _accepted_gate_receipt_digest( request.schema_inference_gate_receipt_path, archive_root=_target_archive_root(request), ) + if existing_handoff is not None and existing_handoff.gate_receipt_digest != gate_receipt_digest: + raise ValueError( + "existing schema inference handoff was produced from a different gate receipt; " + "regenerate the handoff from the accepted gate before committing" + ) registry_before = SchemaRegistry(storage_root=output_dir) # The bundled registry is a read fallback, not the prior state of this @@ -123,6 +132,8 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit ) version_reports: list[SchemaVersionCommitReport] = [] + handoff: SchemaInferenceReceipt | None = None + registry_after: SchemaRegistry | None = None if generation.success: registry_after = SchemaRegistry(storage_root=output_dir) catalog_after = registry_after.load_package_catalog(provider_token) @@ -164,22 +175,16 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit ) ) - handoff: SchemaInferenceReceipt | None = None - handoff_path: Path | None = None if generation.success: - registry_after = SchemaRegistry(storage_root=output_dir) + if registry_after is None: + raise AssertionError("successful schema generation did not produce a persisted registry") provider_handoff = build_schema_inference_receipt( registry_after, provider=provider_token, gate_receipt_digest=gate_receipt_digest, ) - handoff = provider_handoff - existing_path = output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME - if existing_path.exists(): - handoff = load_schema_inference_receipt(existing_path).merged_with(provider_handoff) - write_schema_inference_receipt(handoff, existing_path) - if not request.dry_run: - handoff_path = existing_path + handoff = existing_handoff.merged_with(provider_handoff) if existing_handoff is not None else provider_handoff + write_schema_inference_receipt(handoff, handoff_path) return SchemaCommitResult( provider=request.provider, @@ -187,7 +192,7 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit versions=tuple(version_reports), dry_run=request.dry_run, handoff=handoff, - handoff_path=handoff_path, + handoff_path=handoff_path if generation.success else None, ) @@ -220,6 +225,7 @@ def commit_provider_schema(request: SchemaCommitRequest) -> SchemaCommitResult: versions=result.versions, dry_run=True, handoff=result.handoff, + handoff_path=None, ) diff --git a/polylogue/schemas/operator/models.py b/polylogue/schemas/operator/models.py index f7cfd5c948..81b89d524d 100644 --- a/polylogue/schemas/operator/models.py +++ b/polylogue/schemas/operator/models.py @@ -366,6 +366,7 @@ class SchemaCommitRequest: full_corpus: bool = True dry_run: bool = False schema_inference_gate_receipt_path: Path | None = None + archive_root: Path | None = None @dataclass(frozen=True) diff --git a/polylogue/schemas/operator/receipt.py b/polylogue/schemas/operator/receipt.py index a32fe2fccd..37f711df9b 100644 --- a/polylogue/schemas/operator/receipt.py +++ b/polylogue/schemas/operator/receipt.py @@ -14,7 +14,7 @@ from polylogue.schemas.operator.registry import RuntimeSchemaRegistryLike from polylogue.schemas.packages import SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.runtime_registry import canonical_schema_provider -from polylogue.schemas.synthetic.classification import unsupported_schema_constructs +from polylogue.schemas.synthetic.classification import classify_schema_constructs from polylogue.schemas.synthetic.wire_formats import PROVIDER_WIRE_FORMATS SCHEMA_INFERENCE_HANDOFF_SCHEMA = "polylogue.schema-inference-handoff.v1" @@ -30,10 +30,6 @@ def _require_digest(value: object, *, field: str) -> str: return value -def _canonical_payload(payload: Mapping[str, object]) -> str: - return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False) - - @dataclass(frozen=True, order=True, slots=True) class SchemaInferenceCoverageDecision: """The explicit decision for one origin/provider pair in a handoff.""" @@ -300,7 +296,8 @@ def _package_hashes_for_package( if not package_path.exists(): raise ValueError(f"persisted schema package is missing: {package_path}") element_hashes: list[SchemaElementContentHash] = [] - version_files: list[dict[str, str]] = [{"path": "package.json", "hash": hash_file(package_path)}] + package_hash = hash_file(package_path) + version_files: list[dict[str, str]] = [{"path": "package.json", "hash": package_hash}] for element in sorted(package.elements, key=lambda item: item.element_kind): if element.schema_file is None: continue @@ -319,7 +316,7 @@ def _package_hashes_for_package( return SchemaPackageContentHash( provider=provider_token, package_version=package.version, - package_hash=hash_file(package_path), + package_hash=package_hash, version_hash=hash_payload(version_files), element_hashes=tuple(element_hashes), ) @@ -374,7 +371,9 @@ def _unsupported_for_package( ) ) continue - unsupported = unsupported_schema_constructs(schema) + unsupported = tuple( + item.construct for item in classify_schema_constructs(schema) if item.state == "unsupported" + ) if unsupported: decisions.append( SchemaInferenceUnsupportedDecision( @@ -404,12 +403,29 @@ def build_schema_inference_receipt( item for package in catalog.packages for item in _unsupported_for_package(registry, provider_token, package) ) ) + unsupported_keys = {(item.package_version, item.element_kind) for item in unsupported} + representable = { + (package.version, element.element_kind) + for package in catalog.packages + for element in package.elements + if (package.version, element.element_kind) not in unsupported_keys + } + if representable: + coverage_decision: CoverageDecision = "committed" + coverage_reason = "persisted package/version/element hashes recorded" + else: + coverage_decision = ( + "nonrepresentable" + if unsupported and all(item.decision == "nonrepresentable" for item in unsupported) + else "unsupported" + ) + coverage_reason = "provider has no executable persisted schema element" origin = origin_from_provider(provider_token).value coverage = SchemaInferenceCoverageDecision( origin=origin, provider=provider_token, - decision="committed", - reason="persisted package/version/element hashes recorded", + decision=coverage_decision, + reason=coverage_reason, ) return SchemaInferenceReceipt(gate_receipt_digest, (coverage,), packages, unsupported) diff --git a/tests/infra/inferred_corpus.py b/tests/infra/inferred_corpus.py index ebc73bb7ab..16d5abb0d0 100644 --- a/tests/infra/inferred_corpus.py +++ b/tests/infra/inferred_corpus.py @@ -378,7 +378,9 @@ def write_inferred_corpus_manifest(manifest: InferredCorpusManifest, path: Path) ) -def read_inferred_corpus_manifest(path: Path, *, campaign_mode: bool = False) -> InferredCorpusManifest: +def read_inferred_corpus_manifest( + path: Path, *, campaign_mode: bool = False, registry: RuntimeSchemaRegistryLike | None = None +) -> InferredCorpusManifest: """Read and validate a persisted manifest before exposing executable rows.""" try: @@ -394,6 +396,10 @@ def read_inferred_corpus_manifest(path: Path, *, campaign_mode: bool = False) -> manifest = InferredCorpusManifest.from_payload(payload) if campaign_mode: _require_inference_handoff(manifest) + if registry is None: + raise ValueError("campaign mode requires a live schema registry") + providers = tuple(sorted({entry.key.provider for entry in manifest.entries})) + _validate_inference_handoff(manifest, registry, providers=providers) return manifest @@ -407,14 +413,21 @@ def build_inferred_corpus_convergence_handoff( manifest: InferredCorpusManifest | Path, *, campaign_mode: bool = False, + registry: RuntimeSchemaRegistryLike | None = None, ) -> InferredCorpusConvergenceHandoff: """Bind every supported row from memory or persisted disk to convergence.""" persisted_manifest = ( - read_inferred_corpus_manifest(manifest, campaign_mode=campaign_mode) if isinstance(manifest, Path) else manifest + read_inferred_corpus_manifest(manifest, campaign_mode=campaign_mode, registry=registry) + if isinstance(manifest, Path) + else manifest ) if campaign_mode: _require_inference_handoff(persisted_manifest) + if registry is None: + raise ValueError("campaign mode requires a live schema registry") + providers = tuple(sorted({entry.key.provider for entry in persisted_manifest.entries})) + _validate_inference_handoff(persisted_manifest, registry, providers=providers) selections = tuple(_selection_for_entry(entry) for entry in persisted_manifest.entries if entry.spec is not None) handoff = InferredCorpusConvergenceHandoff( manifest_id=persisted_manifest.manifest_id, @@ -549,16 +562,23 @@ def _compile_entry( # Construct the production generator against the exact package/version/ # element. No generation is performed here, so compiling the receipt does # not turn an unverified catalog into an inference claim. - SyntheticCorpus.from_selection( - SyntheticSchemaSelection( - provider=provider, - package_version=package.version, - element_kind=element.element_kind, - schema=schema, - wire_format=wire_format, - workload_profile=workload_profile if isinstance(workload_profile, dict) else None, - ) + selection = SyntheticSchemaSelection( + provider=provider, + package_version=package.version, + element_kind=element.element_kind, + schema=schema, + wire_format=wire_format, + workload_profile=workload_profile if isinstance(workload_profile, dict) else None, ) + witness = SyntheticCorpus.from_selection(selection).generate( + count=1, + messages_per_session=range(spec.messages_min, spec.messages_min + 1), + seed=spec.seed, + style=spec.style, + session_native_ids=spec.session_native_ids[:1], + ) + if len(witness) != 1 or not witness[0]: + raise ValueError("persisted schema selection did not produce a real synthetic corpus witness") return InferredCorpusManifestEntry( key=key, spec=spec, @@ -630,14 +650,14 @@ def _validate_inference_handoff( providers: Sequence[str] | None, ) -> None: receipt = _require_inference_handoff(manifest) + if not manifest.supported_specs: + raise ValueError("campaign mode has no executable synthetic corpus selection") expected_packages = package_hashes_for_registry(cast(SchemaReceiptRegistry, registry), providers) if receipt.packages != expected_packages: raise ValueError("schema-inference handoff package/version/element hashes do not match the registry") - expected_coverage = { - (provider, origin_from_provider(provider).value) - for provider, _catalog, _package, _element in _catalog_entries(registry, providers) - } + catalog_entries = _catalog_entries(registry, providers) + expected_coverage = {(provider, origin_from_provider(provider).value) for provider, *_rest in catalog_entries} actual_coverage = {(item.provider, item.origin) for item in receipt.coverage_decisions} if actual_coverage != expected_coverage: raise ValueError( @@ -645,8 +665,66 @@ def _validate_inference_handoff( f"missing={sorted(expected_coverage - actual_coverage)!r}, " f"unexpected={sorted(actual_coverage - expected_coverage)!r}" ) - if any(item.decision != "committed" for item in receipt.coverage_decisions): - raise ValueError("schema-inference handoff contains a non-committed coverage decision") + entries_by_provider: dict[str, list[InferredCorpusManifestEntry]] = {} + for entry in manifest.entries: + entries_by_provider.setdefault(entry.key.provider, []).append(entry) + for coverage in receipt.coverage_decisions: + provider_entries = entries_by_provider.get(coverage.provider, []) + if any(entry.spec is not None for entry in provider_entries): + expected_decision = "committed" + elif provider_entries and all( + entry.unsupported is not None and entry.unsupported.reason == "unsupported_json_schema_construct" + for entry in provider_entries + ): + expected_decision = "nonrepresentable" + else: + expected_decision = "unsupported" + if coverage.decision != expected_decision: + raise ValueError("schema-inference handoff coverage decision changed") + + for provider, _catalog, package, element in catalog_entries: + live_entry = next( + ( + candidate + for candidate in manifest.entries + if (candidate.key.provider, candidate.key.package_version, candidate.key.element_kind) + == (provider, package.version, element.element_kind) + ), + None, + ) + if live_entry is None: + raise ValueError("schema-inference manifest is missing a live registry entry") + live_schema = registry.get_element_schema(provider, version=package.version, element_kind=element.element_kind) + live_constructs = _schema_constructs(live_schema) + if live_entry.key.construct_support != live_constructs: + raise ValueError("schema-inference manifest classifier output changed") + live_unsupported = _unsupported_reason( + element=element, + schema=live_schema if isinstance(live_schema, dict) else None, + wire_format=PROVIDER_WIRE_FORMATS.get(provider), + construct_support=live_constructs, + ) + if (live_entry.unsupported is None) != (live_unsupported is None): + raise ValueError("schema-inference manifest executable support changed") + if live_unsupported is not None: + if live_entry.unsupported != live_unsupported: + raise ValueError("schema-inference manifest unsupported decision changed") + continue + if live_entry.generator_schema != live_schema: + raise ValueError("schema-inference manifest generator schema changed") + selection = _selection_for_entry(live_entry) + spec = live_entry.spec + if spec is None: + raise ValueError("schema-inference manifest executable entry has no corpus spec") + witness = SyntheticCorpus.from_selection(selection).generate( + count=1, + messages_per_session=range(spec.messages_min, spec.messages_min + 1), + seed=spec.seed, + style=spec.style, + session_native_ids=spec.session_native_ids[:1], + ) + if len(witness) != 1 or not witness[0]: + raise ValueError("schema-inference manifest selection produced no executable witness") expected_unsupported = { ( diff --git a/tests/unit/devtools/test_schema_commit_command.py b/tests/unit/devtools/test_schema_commit_command.py index 8466c63f6c..8a94c6e2df 100644 --- a/tests/unit/devtools/test_schema_commit_command.py +++ b/tests/unit/devtools/test_schema_commit_command.py @@ -16,10 +16,23 @@ from devtools import schema_commit from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.operator.models import SchemaCommitRequest, SchemaCommitResult, SchemaVersionCommitReport +from polylogue.schemas.operator.receipt import ( + SchemaInferenceCoverageDecision, + SchemaInferenceReceipt, +) + +_HANDOFF = SchemaInferenceReceipt( + gate_receipt_digest="a" * 64, + coverage_decisions=( + SchemaInferenceCoverageDecision(origin="codex-session", provider="codex", decision="committed", reason=None), + ), + packages=(), +) @dataclass(frozen=True) class _ConfigStub: + archive_root: Path db_path: Path @@ -29,7 +42,7 @@ def test_schema_commit_forwards_request_and_defaults_output_dir( captured: list[SchemaCommitRequest] = [] def fake_get_config() -> _ConfigStub: - return _ConfigStub(db_path=tmp_path / "archive.db") + return _ConfigStub(archive_root=tmp_path / "archive", db_path=tmp_path / "archive.db") def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: captured.append(request) @@ -61,7 +74,11 @@ def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: def test_schema_commit_honors_output_dir_and_dry_run_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: captured: list[SchemaCommitRequest] = [] - monkeypatch.setattr(schema_commit, "get_config", lambda: _ConfigStub(db_path=tmp_path / "archive.db")) + monkeypatch.setattr( + schema_commit, + "get_config", + lambda: _ConfigStub(archive_root=tmp_path / "archive", db_path=tmp_path / "archive.db"), + ) def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: captured.append(request) @@ -99,7 +116,11 @@ def fake_commit(request: SchemaCommitRequest) -> SchemaCommitResult: def test_schema_commit_json_output_reports_success( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setattr(schema_commit, "get_config", lambda: _ConfigStub(db_path=tmp_path / "archive.db")) + monkeypatch.setattr( + schema_commit, + "get_config", + lambda: _ConfigStub(archive_root=tmp_path / "archive", db_path=tmp_path / "archive.db"), + ) monkeypatch.setattr( schema_commit, "commit_provider_schema", @@ -112,6 +133,8 @@ def test_schema_commit_json_output_reports_success( ), ), dry_run=False, + handoff=_HANDOFF, + handoff_path=tmp_path / "handoff.json", ), ) @@ -129,12 +152,18 @@ def test_schema_commit_json_output_reports_success( assert payload["sample_count"] == 42 assert payload["versions"][0]["status"] == "changed" assert payload["versions"][0]["added_paths"] == ["session_document.new"] + assert payload["handoff"]["gate_receipt_digest"] == "a" * 64 + assert payload["handoff_path"] == str(tmp_path / "handoff.json") def test_schema_commit_exits_nonzero_on_generation_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: - monkeypatch.setattr(schema_commit, "get_config", lambda: _ConfigStub(db_path=tmp_path / "archive.db")) + monkeypatch.setattr( + schema_commit, + "get_config", + lambda: _ConfigStub(archive_root=tmp_path / "archive", db_path=tmp_path / "archive.db"), + ) monkeypatch.setattr( schema_commit, "commit_provider_schema", @@ -167,7 +196,11 @@ def test_schema_commit_exits_nonzero_when_narrowed(monkeypatch: pytest.MonkeyPat """A commit that succeeds but narrows a previously-committed type must not report a clean exit code -- the whole point of the report is that a bad promotion can't land unnoticed.""" - monkeypatch.setattr(schema_commit, "get_config", lambda: _ConfigStub(db_path=tmp_path / "archive.db")) + monkeypatch.setattr( + schema_commit, + "get_config", + lambda: _ConfigStub(archive_root=tmp_path / "archive", db_path=tmp_path / "archive.db"), + ) monkeypatch.setattr( schema_commit, "commit_provider_schema", diff --git a/tests/unit/schemas/test_inferred_corpus_manifest.py b/tests/unit/schemas/test_inferred_corpus_manifest.py index 137cc76ffb..35fb4c5127 100644 --- a/tests/unit/schemas/test_inferred_corpus_manifest.py +++ b/tests/unit/schemas/test_inferred_corpus_manifest.py @@ -92,6 +92,48 @@ def test_persisted_manifest_round_trip_validates_identity_and_integrity(tmp_path assert read_inferred_corpus_manifest(path) == manifest +def test_campaign_read_revalidates_live_schema_and_classifier(tmp_path: Path) -> None: + registry = _registry() + provider = "codex" + receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest="a" * 64) + manifest = compile_inferred_corpus_manifest( + registry=registry, + providers=(provider,), + package_receipt=receipt.to_payload(), + campaign_mode=True, + ) + path = tmp_path / "campaign.json" + + supported = next(entry for entry in manifest.entries if entry.spec is not None) + tampered_schema = replace(supported, generator_schema={"type": "string"}) + tampered = replace( + manifest, + entries=tuple( + sorted( + (tampered_schema if entry is supported else entry for entry in manifest.entries), + key=lambda entry: entry.key, + ) + ), + ) + write_inferred_corpus_manifest(tampered, path) + with pytest.raises(ValueError, match="package/version/element hashes|generator schema changed"): + read_inferred_corpus_manifest(path, campaign_mode=True, registry=registry) + + tampered_key = replace(supported.key, construct_support=()) + tampered_entry = replace(supported, key=tampered_key) + tampered = replace( + manifest, + entries=tuple( + sorted( + (tampered_entry if entry is supported else entry for entry in manifest.entries), + key=lambda entry: entry.key, + ) + ), + ) + with pytest.raises(ValueError, match="classifier output changed"): + build_inferred_corpus_convergence_handoff(tampered, campaign_mode=True, registry=registry) + + def test_campaign_mode_rejects_catalog_only_manifest(tmp_path: Path) -> None: manifest = compile_inferred_corpus_manifest(registry=_registry()) path = tmp_path / "catalog-only.json" @@ -105,7 +147,7 @@ def test_campaign_mode_rejects_catalog_only_manifest(tmp_path: Path) -> None: def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decisions() -> None: registry = _registry() - provider = registry.list_providers()[0] + provider = "codex" receipt = build_schema_inference_receipt( registry, provider=provider, @@ -143,7 +185,7 @@ def test_bundled_registry_relation_annotations_share_one_receipt_classification( registry=registry, providers=(provider,), package_receipt=receipt.to_payload(), - campaign_mode=True, + campaign_mode=False, ) expected_annotations = { @@ -203,7 +245,7 @@ def test_bundled_registry_relation_annotations_share_one_receipt_classification( ) tampered_decisions = (changed,) tampered_unsupported = replace(receipt, unsupported_decisions=tuple(sorted(tampered_decisions))) - with pytest.raises(ValueError, match="unsupported/nonrepresentable decisions"): + with pytest.raises(ValueError, match="no executable synthetic corpus selection"): compile_inferred_corpus_manifest( registry=registry, providers=(provider,), diff --git a/tests/unit/schemas/test_operator_commit.py b/tests/unit/schemas/test_operator_commit.py index 76e0df1169..fd9d0c1344 100644 --- a/tests/unit/schemas/test_operator_commit.py +++ b/tests/unit/schemas/test_operator_commit.py @@ -29,6 +29,11 @@ import pytest +from polylogue.maintenance.schema_inference_gate import ( + run_schema_inference_gate, + schema_inference_gate_receipt_digest, + schema_inference_hard_gate_evidence_digest, +) from polylogue.schemas.generation.models import GenerationResult from polylogue.schemas.operator.commit import commit_provider_schema from polylogue.schemas.operator.models import SchemaCommitRequest @@ -36,49 +41,26 @@ from polylogue.schemas.packages import SchemaElementManifest, SchemaPackageCatalog, SchemaVersionPackage from polylogue.schemas.registry import SchemaRegistry from polylogue.schemas.tooling_models import ClusterManifest -from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation from tests.infra.frozen_clock import FrozenClock from tests.infra.inferred_corpus import compile_inferred_corpus_manifest +from tests.unit.maintenance.test_schema_inference_gate import _seed_archive _PROVIDER = "chatgpt" def _gate_receipt(output_dir: Path) -> Path: path = output_dir.parent / "schema-inference-gate-receipt.json" - archive_root = output_dir.parent - payload = { - "schema": "polylogue.schema-inference-gate.v1", - "gate_version": "2", - "generated_at": "2023-11-14T22:13:20+00:00", - "receipt_nonce": "00000000-0000-4000-8000-000000000001", - "verdict": "PASS", - "archive_root": str(archive_root.absolute()), - "archive_identity_digest": ArchiveIdentity.resolve_location( - ArchiveLocation.resolve(archive_root) - ).authority_identity_digest, - "schema_identity": {}, - "source_schema_identity": {}, - "query_results": {"pristine": {"passed": True}}, - "source_denominators": {}, - "blob_denominators": {}, - "ground_truth_denominators": {}, - "ground_truth_inputs": {"passed": True}, - "exemptions": {}, - "corpus_fidelity": {"passed": True}, - "full_blob_hash_verification": { - "passed": True, - "verifier": {"identity": "polylogue.storage.blob_store.BlobStore.verify_all"}, - "before_snapshot": {"digest": "a" * 64}, - "after_snapshot": {"digest": "a" * 64}, - "failures": [], - "missing_references": [], - "errors": [], - }, - "input_paths": {}, - "tool_versions": {}, - "pass_fail_reasons": [], - } - path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + archive_root = output_dir.parent / "archive" + if path.exists(): + return path + if not (archive_root / "source.db").exists(): + _seed_archive(archive_root) + ground_truth = archive_root.parent / f"{archive_root.name}-codex-ground-truth" + run_schema_inference_gate( + archive_root, + receipt_path=path, + ground_truth_roots={"codex-session": (ground_truth,)}, + ) return path @@ -91,7 +73,8 @@ def _request( return SchemaCommitRequest( provider=_PROVIDER, output_dir=output_dir, - db_path=output_dir.parent / "index.db", + archive_root=output_dir.parent / "archive", + db_path=output_dir.parent / "archive" / "index.db", full_corpus=True, schema_inference_gate_receipt_path=gate_path or _gate_receipt(output_dir), dry_run=dry_run, @@ -182,6 +165,8 @@ def test_new_provider_writes_catalog_and_element_files(self, tmp_path: Path) -> assert not version_report.narrowed_paths assert "session_document.id" in version_report.added_paths assert commit_result.handoff is not None + gate_payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + assert commit_result.handoff.gate_receipt_digest == schema_inference_gate_receipt_digest(gate_payload) assert commit_result.handoff_path == output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME assert load_schema_inference_receipt(commit_result.handoff_path) == commit_result.handoff assert commit_result.handoff.packages[0].element_hashes[0].element_kind == "session_document" @@ -238,30 +223,57 @@ def test_commit_rejects_a_minimal_or_mutated_pass_payload(self, tmp_path: Path) with pytest.raises(ValueError, match="authoritative fields"): commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + receipt_path.unlink() payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) payload["full_blob_hash_verification"]["passed"] = False receipt_path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(ValueError, match="full_blob_hash_verification PASS"): commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + receipt_path.unlink() payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) payload["archive_identity_digest"] = "0" * 64 receipt_path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(ValueError, match="archive identity"): commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + receipt_path.unlink() payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) payload["receipt_nonce"] = "forged" receipt_path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(ValueError, match="receipt nonce"): commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + receipt_path.unlink() payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) payload["generated_at"] = "2020-01-01T00:00:00+00:00" receipt_path.write_text(json.dumps(payload), encoding="utf-8") with pytest.raises(ValueError, match="stale or from the future"): commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + def test_commit_rejects_recomputed_forgery_of_live_gate_evidence(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + receipt_path = _gate_receipt(output_dir) + payload = json.loads(receipt_path.read_text(encoding="utf-8")) + payload["query_results"]["zero-surviving-quarantine"]["count"] = 99 + payload["hard_gate_evidence_digest"] = schema_inference_hard_gate_evidence_digest( + payload["query_results"], payload["full_blob_hash_verification"] + ) + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="hard-gate query results changed"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + + receipt_path.unlink() + payload = json.loads(_gate_receipt(output_dir).read_text(encoding="utf-8")) + payload["full_blob_hash_verification"]["before_snapshot"]["digest"] = "0" * 64 + payload["full_blob_hash_verification"]["after_snapshot"]["digest"] = "0" * 64 + payload["hard_gate_evidence_digest"] = schema_inference_hard_gate_evidence_digest( + payload["query_results"], payload["full_blob_hash_verification"] + ) + receipt_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="hard-gate evidence digest"): + commit_provider_schema(_request(output_dir, gate_path=receipt_path)) + def test_registry_construct_rejection_remains_an_explicit_unsupported_entry(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" bundle = _bundle( @@ -273,11 +285,18 @@ def test_registry_construct_rejection_remains_an_explicit_unsupported_entry(self result = commit_provider_schema(_request(output_dir)) assert result.handoff is not None + with pytest.raises(ValueError, match="no executable synthetic corpus selection"): + compile_inferred_corpus_manifest( + registry=SchemaRegistry(storage_root=output_dir), + providers=(_PROVIDER,), + package_receipt=result.handoff.to_payload(), + campaign_mode=True, + ) manifest = compile_inferred_corpus_manifest( registry=SchemaRegistry(storage_root=output_dir), providers=(_PROVIDER,), package_receipt=result.handoff.to_payload(), - campaign_mode=True, + campaign_mode=False, ) entry = manifest.entries[0] assert entry.spec is None @@ -366,6 +385,7 @@ def test_dry_run_does_not_touch_output_dir(self, tmp_path: Path) -> None: commit_provider_schema(_request(output_dir)) catalog_before_bytes = (output_dir / _PROVIDER / "catalog.json").read_bytes() + handoff_before_bytes = (output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME).read_bytes() second_schema = { "type": "object", @@ -382,6 +402,7 @@ def test_dry_run_does_not_touch_output_dir(self, tmp_path: Path) -> None: assert "session_document.would_be_added" in commit_result.versions[0].added_paths # The real committed directory was never touched. assert (output_dir / _PROVIDER / "catalog.json").read_bytes() == catalog_before_bytes + assert (output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME).read_bytes() == handoff_before_bytes on_disk = _read_element_schema(output_dir, "v1") assert "would_be_added" not in on_disk["properties"] @@ -394,6 +415,7 @@ def test_failed_generation_reports_no_success_and_no_versions(self, tmp_path: Pa SchemaCommitRequest( provider="not-a-real-provider-k45pq", output_dir=output_dir, + archive_root=output_dir.parent / "archive", db_path=output_dir.parent / "index.db", full_corpus=True, schema_inference_gate_receipt_path=_gate_receipt(output_dir), From 28e63fb527f2d977e1bb53764653d5eba55ce0db Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 18:14:49 +0200 Subject: [PATCH 5/7] fix: bind schema inference to authoritative archives Problem: schema commits could validate a gate receipt for one archive while schema generation read an independently supplied index from another archive. Campaign manifests also accepted any syntactically valid handoff digest without proving it came from a fresh PASS receipt for the admitted archive. What changed: schema commit resolves one active archive index and rejects mismatched db_path values before generation. Campaign admission now requires and validates the authoritative gate receipt against the supplied archive root, then compares its recomputed digest with the handoff. Real-route mutation-sensitive tests cover both attacks. Compatibility/migration: non-campaign inferred-corpus fixtures may still construct handoffs with test digests; campaign callers must provide the authoritative gate receipt path and archive root. --- polylogue/schemas/operator/commit.py | 16 +++-- tests/infra/inferred_corpus.py | 70 ++++++++++++++++-- .../schemas/test_inferred_corpus_manifest.py | 71 +++++++++++++++++-- tests/unit/schemas/test_operator_commit.py | 25 ++++++- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/polylogue/schemas/operator/commit.py b/polylogue/schemas/operator/commit.py index a1427b7f61..6034eeff9a 100644 --- a/polylogue/schemas/operator/commit.py +++ b/polylogue/schemas/operator/commit.py @@ -82,9 +82,16 @@ def _accepted_gate_receipt_digest(path: Path | None, *, archive_root: Path) -> s ) -def _target_archive_root(request: SchemaCommitRequest) -> Path: +def _target_archive_location(request: SchemaCommitRequest) -> ArchiveLocation: configured_root = request.archive_root or default_archive_root() - return ArchiveLocation.resolve(configured_root).configured_root + location = ArchiveLocation.resolve(configured_root) + expected_db_path = location.active_index_path.resolve(strict=False) + if request.db_path is not None and request.db_path.resolve(strict=False) != expected_db_path: + raise ValueError( + "schema commit db_path must identify the active index of the configured archive; " + f"expected={expected_db_path}, actual={request.db_path.resolve(strict=False)}" + ) + return location def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommitResult: @@ -92,9 +99,10 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit output_dir = output_dir.absolute() handoff_path = output_dir / SCHEMA_INFERENCE_HANDOFF_FILENAME existing_handoff = load_schema_inference_receipt(handoff_path) if handoff_path.exists() else None + archive_location = _target_archive_location(request) gate_receipt_digest = _accepted_gate_receipt_digest( request.schema_inference_gate_receipt_path, - archive_root=_target_archive_root(request), + archive_root=archive_location.configured_root, ) if existing_handoff is not None and existing_handoff.gate_receipt_digest != gate_receipt_digest: raise ValueError( @@ -117,7 +125,7 @@ def _commit_into(request: SchemaCommitRequest, output_dir: Path) -> SchemaCommit generation_results = generate_all_schemas( output_dir, - db_path=request.db_path, + db_path=archive_location.active_index_path, providers=[request.provider], max_samples=request.max_samples, privacy_config=privacy_config_from_payload(request.privacy_config), diff --git a/tests/infra/inferred_corpus.py b/tests/infra/inferred_corpus.py index 16d5abb0d0..d9ab786660 100644 --- a/tests/infra/inferred_corpus.py +++ b/tests/infra/inferred_corpus.py @@ -18,6 +18,7 @@ from polylogue.core.json import JSONDocument from polylogue.core.sources import origin_from_provider +from polylogue.maintenance.schema_inference_gate import validate_schema_inference_gate_receipt from polylogue.scenarios import CorpusSpec from polylogue.schemas.operator.receipt import ( SchemaInferenceReceipt, @@ -224,6 +225,25 @@ def _require_inference_handoff(manifest: InferredCorpusManifest) -> SchemaInfere return SchemaInferenceReceipt.from_payload(manifest.package_receipt) +def _validate_authoritative_gate_binding( + receipt: SchemaInferenceReceipt, + *, + gate_receipt_path: Path | None, + archive_root: Path | None, +) -> None: + if gate_receipt_path is None or archive_root is None: + raise ValueError("campaign mode requires an authoritative gate receipt path and archive root") + try: + payload = json.loads(gate_receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError) as exc: + raise ValueError(f"unable to read authoritative schema-inference gate receipt: {exc}") from exc + if not isinstance(payload, Mapping): + raise ValueError("authoritative schema-inference gate receipt must be a JSON object") + gate_digest = validate_schema_inference_gate_receipt(payload, archive_root=archive_root) + if receipt.gate_receipt_digest != gate_digest: + raise ValueError("schema-inference handoff gate receipt digest does not match the authoritative PASS receipt") + + @dataclass(frozen=True) class InferredCorpusConvergenceHandoff: """Exact executable manifest subset admitted to the convergence loop.""" @@ -379,7 +399,12 @@ def write_inferred_corpus_manifest(manifest: InferredCorpusManifest, path: Path) def read_inferred_corpus_manifest( - path: Path, *, campaign_mode: bool = False, registry: RuntimeSchemaRegistryLike | None = None + path: Path, + *, + campaign_mode: bool = False, + registry: RuntimeSchemaRegistryLike | None = None, + gate_receipt_path: Path | None = None, + archive_root: Path | None = None, ) -> InferredCorpusManifest: """Read and validate a persisted manifest before exposing executable rows.""" @@ -399,7 +424,13 @@ def read_inferred_corpus_manifest( if registry is None: raise ValueError("campaign mode requires a live schema registry") providers = tuple(sorted({entry.key.provider for entry in manifest.entries})) - _validate_inference_handoff(manifest, registry, providers=providers) + _validate_inference_handoff( + manifest, + registry, + providers=providers, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) return manifest @@ -414,11 +445,19 @@ def build_inferred_corpus_convergence_handoff( *, campaign_mode: bool = False, registry: RuntimeSchemaRegistryLike | None = None, + gate_receipt_path: Path | None = None, + archive_root: Path | None = None, ) -> InferredCorpusConvergenceHandoff: """Bind every supported row from memory or persisted disk to convergence.""" persisted_manifest = ( - read_inferred_corpus_manifest(manifest, campaign_mode=campaign_mode, registry=registry) + read_inferred_corpus_manifest( + manifest, + campaign_mode=campaign_mode, + registry=registry, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) if isinstance(manifest, Path) else manifest ) @@ -427,7 +466,13 @@ def build_inferred_corpus_convergence_handoff( if registry is None: raise ValueError("campaign mode requires a live schema registry") providers = tuple(sorted({entry.key.provider for entry in persisted_manifest.entries})) - _validate_inference_handoff(persisted_manifest, registry, providers=providers) + _validate_inference_handoff( + persisted_manifest, + registry, + providers=providers, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) selections = tuple(_selection_for_entry(entry) for entry in persisted_manifest.entries if entry.spec is not None) handoff = InferredCorpusConvergenceHandoff( manifest_id=persisted_manifest.manifest_id, @@ -618,6 +663,8 @@ def compile_inferred_corpus_manifest( wire_formats: Mapping[str, WireFormat] | None = None, providers: Sequence[str] | None = None, campaign_mode: bool = False, + gate_receipt_path: Path | None = None, + archive_root: Path | None = None, ) -> InferredCorpusManifest: """Compile every persisted package/version/element into a typed manifest.""" @@ -639,7 +686,13 @@ def compile_inferred_corpus_manifest( ) assert_inferred_corpus_manifest_complete(manifest, registry, providers=providers) if campaign_mode: - _validate_inference_handoff(manifest, registry, providers=providers) + _validate_inference_handoff( + manifest, + registry, + providers=providers, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) return manifest @@ -648,8 +701,15 @@ def _validate_inference_handoff( registry: RuntimeSchemaRegistryLike, *, providers: Sequence[str] | None, + gate_receipt_path: Path | None, + archive_root: Path | None, ) -> None: receipt = _require_inference_handoff(manifest) + _validate_authoritative_gate_binding( + receipt, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) if not manifest.supported_specs: raise ValueError("campaign mode has no executable synthetic corpus selection") expected_packages = package_hashes_for_registry(cast(SchemaReceiptRegistry, registry), providers) diff --git a/tests/unit/schemas/test_inferred_corpus_manifest.py b/tests/unit/schemas/test_inferred_corpus_manifest.py index 35fb4c5127..a706ba0fcf 100644 --- a/tests/unit/schemas/test_inferred_corpus_manifest.py +++ b/tests/unit/schemas/test_inferred_corpus_manifest.py @@ -8,6 +8,10 @@ import pytest from polylogue.core.json import JSONValue +from polylogue.maintenance.schema_inference_gate import ( + run_schema_inference_gate, + schema_inference_gate_receipt_digest, +) from polylogue.schemas.operator.receipt import ( SchemaInferenceUnsupportedDecision, build_schema_inference_receipt, @@ -25,12 +29,26 @@ read_inferred_corpus_manifest, write_inferred_corpus_manifest, ) +from tests.unit.maintenance.test_schema_inference_gate import _seed_archive def _registry() -> SchemaRegistry: return SchemaRegistry(storage_root=SCHEMA_DIR) +def _authoritative_gate(tmp_path: Path) -> tuple[Path, Path, str]: + archive_root = tmp_path / "archive" + receipt_path = tmp_path / "schema-inference-gate-receipt.json" + _seed_archive(archive_root) + result = run_schema_inference_gate( + archive_root, + receipt_path=receipt_path, + ground_truth_roots={"codex-session": (tmp_path / "archive-codex-ground-truth",)}, + ) + assert result.passed + return archive_root, receipt_path, schema_inference_gate_receipt_digest(result.payload) + + def _catalog_keys(registry: SchemaRegistry) -> set[CorpusManifestKey]: keys: set[CorpusManifestKey] = set() for provider in registry.list_providers(): @@ -95,12 +113,15 @@ def test_persisted_manifest_round_trip_validates_identity_and_integrity(tmp_path def test_campaign_read_revalidates_live_schema_and_classifier(tmp_path: Path) -> None: registry = _registry() provider = "codex" - receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest="a" * 64) + archive_root, gate_receipt_path, gate_digest = _authoritative_gate(tmp_path) + receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest=gate_digest) manifest = compile_inferred_corpus_manifest( registry=registry, providers=(provider,), package_receipt=receipt.to_payload(), campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, ) path = tmp_path / "campaign.json" @@ -117,7 +138,13 @@ def test_campaign_read_revalidates_live_schema_and_classifier(tmp_path: Path) -> ) write_inferred_corpus_manifest(tampered, path) with pytest.raises(ValueError, match="package/version/element hashes|generator schema changed"): - read_inferred_corpus_manifest(path, campaign_mode=True, registry=registry) + read_inferred_corpus_manifest( + path, + campaign_mode=True, + registry=registry, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) tampered_key = replace(supported.key, construct_support=()) tampered_entry = replace(supported, key=tampered_key) @@ -131,7 +158,13 @@ def test_campaign_read_revalidates_live_schema_and_classifier(tmp_path: Path) -> ), ) with pytest.raises(ValueError, match="classifier output changed"): - build_inferred_corpus_convergence_handoff(tampered, campaign_mode=True, registry=registry) + build_inferred_corpus_convergence_handoff( + tampered, + campaign_mode=True, + registry=registry, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) def test_campaign_mode_rejects_catalog_only_manifest(tmp_path: Path) -> None: @@ -145,19 +178,22 @@ def test_campaign_mode_rejects_catalog_only_manifest(tmp_path: Path) -> None: compile_inferred_corpus_manifest(registry=_registry(), campaign_mode=True) -def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decisions() -> None: +def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decisions(tmp_path: Path) -> None: registry = _registry() provider = "codex" + archive_root, gate_receipt_path, gate_digest = _authoritative_gate(tmp_path) receipt = build_schema_inference_receipt( registry, provider=provider, - gate_receipt_digest="a" * 64, + gate_receipt_digest=gate_digest, ) compile_inferred_corpus_manifest( registry=registry, providers=(provider,), package_receipt=receipt.to_payload(), campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, ) tampered_gate = replace(receipt, gate_receipt_digest="b" * 64) @@ -174,13 +210,32 @@ def test_campaign_receipt_rejects_tampered_gate_package_and_unsupported_decision providers=(provider,), package_receipt=tampered_package.to_payload(), campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) + + +def test_campaign_rejects_fabricated_gate_digest_even_when_shape_is_valid(tmp_path: Path) -> None: + registry = _registry() + archive_root, gate_receipt_path, _gate_digest = _authoritative_gate(tmp_path) + receipt = build_schema_inference_receipt(registry, provider="codex", gate_receipt_digest="a" * 64) + + with pytest.raises(ValueError, match="does not match the authoritative PASS receipt"): + compile_inferred_corpus_manifest( + registry=registry, + providers=("codex",), + package_receipt=receipt.to_payload(), + campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, ) -def test_bundled_registry_relation_annotations_share_one_receipt_classification() -> None: +def test_bundled_registry_relation_annotations_share_one_receipt_classification(tmp_path: Path) -> None: registry = _registry() provider = "chatgpt" - receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest="a" * 64) + archive_root, gate_receipt_path, gate_digest = _authoritative_gate(tmp_path) + receipt = build_schema_inference_receipt(registry, provider=provider, gate_receipt_digest=gate_digest) manifest = compile_inferred_corpus_manifest( registry=registry, providers=(provider,), @@ -251,6 +306,8 @@ def test_bundled_registry_relation_annotations_share_one_receipt_classification( providers=(provider,), package_receipt=tampered_unsupported.to_payload(), campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, ) diff --git a/tests/unit/schemas/test_operator_commit.py b/tests/unit/schemas/test_operator_commit.py index fd9d0c1344..0c4e0c0510 100644 --- a/tests/unit/schemas/test_operator_commit.py +++ b/tests/unit/schemas/test_operator_commit.py @@ -22,6 +22,7 @@ import gzip import json +from dataclasses import replace from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -188,6 +189,8 @@ def test_commit_to_registry_to_campaign_manifest_is_a_real_route(self, tmp_path: providers=(_PROVIDER,), package_receipt=result.handoff.to_payload(), campaign_mode=True, + gate_receipt_path=_gate_receipt(output_dir), + archive_root=output_dir.parent / "archive", ) assert manifest.receipt_state == "package_receipt_attached" assert len(manifest.entries) == 1 @@ -207,8 +210,26 @@ def test_commit_to_registry_to_campaign_manifest_is_a_real_route(self, tmp_path: providers=(_PROVIDER,), package_receipt=result.handoff.to_payload(), campaign_mode=True, + gate_receipt_path=_gate_receipt(output_dir), + archive_root=output_dir.parent / "archive", ) + def test_commit_rejects_receipt_for_archive_a_when_generation_targets_archive_b(self, tmp_path: Path) -> None: + output_dir = tmp_path / "providers" + request = replace( + _request(output_dir), + db_path=tmp_path / "archive-b" / "index.db", + ) + bundle = _bundle( + version="v1", + schema={"type": "object", "properties": {"id": {"type": "string"}}}, + sample_count=5, + ) + + with patch("polylogue.schemas.generation.workflow._build_provider_bundle", return_value=bundle): + with pytest.raises(ValueError, match="db_path must identify the active index"): + commit_provider_schema(request) + def test_commit_requires_an_accepted_gate_receipt(self, tmp_path: Path) -> None: output_dir = tmp_path / "providers" with pytest.raises(ValueError, match="accepted schema-inference gate receipt"): @@ -291,6 +312,8 @@ def test_registry_construct_rejection_remains_an_explicit_unsupported_entry(self providers=(_PROVIDER,), package_receipt=result.handoff.to_payload(), campaign_mode=True, + gate_receipt_path=_gate_receipt(output_dir), + archive_root=output_dir.parent / "archive", ) manifest = compile_inferred_corpus_manifest( registry=SchemaRegistry(storage_root=output_dir), @@ -416,7 +439,7 @@ def test_failed_generation_reports_no_success_and_no_versions(self, tmp_path: Pa provider="not-a-real-provider-k45pq", output_dir=output_dir, archive_root=output_dir.parent / "archive", - db_path=output_dir.parent / "index.db", + db_path=output_dir.parent / "archive" / "index.db", full_corpus=True, schema_inference_gate_receipt_path=_gate_receipt(output_dir), ) From 50bb0d1dd84262ebe6b73e81c431b7c8bb01cfe9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Wed, 5 Aug 2026 18:36:27 +0200 Subject: [PATCH 6/7] fix: validate schema gate ground-truth denominators Problem: the authoritative receipt exposed ground-truth fidelity denominators, but validator admission did not compare that top-level field with live corpus evidence. A tampered field could therefore be covered by a recomputed handoff digest. What changed: validator admission now compares ground_truth_denominators exactly with live corpus-fidelity denominators. The campaign test admits the valid receipt, tampers only that field, recomputes the receipt digest, and asserts rejection. Compatibility/migration: valid receipts retain the existing contract and all prior archive, index, freshness, blob, query, ground-truth, corpus-fidelity, and persisted-resume checks. Co-Authored-By: Claude --- .../maintenance/schema_inference_gate.py | 2 + .../schemas/test_inferred_corpus_manifest.py | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/polylogue/maintenance/schema_inference_gate.py b/polylogue/maintenance/schema_inference_gate.py index 32cacb70b4..5abcf94eaa 100644 --- a/polylogue/maintenance/schema_inference_gate.py +++ b/polylogue/maintenance/schema_inference_gate.py @@ -1199,6 +1199,8 @@ def validate_schema_inference_gate_receipt( recorded_fidelity.get(key) != live_fidelity.get(key) for key in fidelity_keys ): raise ValueError("schema-inference gate receipt corpus-fidelity evidence changed") + if payload.get("ground_truth_denominators") != live_fidelity.get("denominators"): + raise ValueError("schema-inference gate receipt ground-truth denominators changed") full_blob = payload.get("full_blob_hash_verification") if not isinstance(full_blob, Mapping) or full_blob.get("passed") is not True: diff --git a/tests/unit/schemas/test_inferred_corpus_manifest.py b/tests/unit/schemas/test_inferred_corpus_manifest.py index a706ba0fcf..94086a4488 100644 --- a/tests/unit/schemas/test_inferred_corpus_manifest.py +++ b/tests/unit/schemas/test_inferred_corpus_manifest.py @@ -231,6 +231,44 @@ def test_campaign_rejects_fabricated_gate_digest_even_when_shape_is_valid(tmp_pa ) +def test_campaign_rejects_tampered_ground_truth_denominators_after_digest_recompute(tmp_path: Path) -> None: + registry = _registry() + archive_root, gate_receipt_path, gate_digest = _authoritative_gate(tmp_path) + receipt = build_schema_inference_receipt(registry, provider="codex", gate_receipt_digest=gate_digest) + + valid_manifest = compile_inferred_corpus_manifest( + registry=registry, + providers=("codex",), + package_receipt=receipt.to_payload(), + campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) + assert valid_manifest.receipt_state == "package_receipt_attached" + + tampered_gate = json.loads(gate_receipt_path.read_text(encoding="utf-8")) + denominators = dict(tampered_gate["ground_truth_denominators"]) + denominators["documents_known"] += 1 + tampered_gate["ground_truth_denominators"] = denominators + gate_receipt_path.write_text(json.dumps(tampered_gate, sort_keys=True) + "\n", encoding="utf-8") + tampered_digest = schema_inference_gate_receipt_digest(tampered_gate) + tampered_receipt = build_schema_inference_receipt( + registry, + provider="codex", + gate_receipt_digest=tampered_digest, + ) + + with pytest.raises(ValueError, match="ground-truth denominators changed"): + compile_inferred_corpus_manifest( + registry=registry, + providers=("codex",), + package_receipt=tampered_receipt.to_payload(), + campaign_mode=True, + gate_receipt_path=gate_receipt_path, + archive_root=archive_root, + ) + + def test_bundled_registry_relation_annotations_share_one_receipt_classification(tmp_path: Path) -> None: registry = _registry() provider = "chatgpt" From 88fd939c5586c8dad9b8533e7d06fe75c46efc51 Mon Sep 17 00:00:00 2001 From: Sinity Date: Thu, 6 Aug 2026 02:17:43 +0200 Subject: [PATCH 7/7] fix(schema): type gate receipt route Problem: the unified path-or-payload receipt validator returned a union at two statically distinct call sites. What changed: narrow the path-based authorization result and the in-memory handoff digest at their call boundaries. Verification: the pre-push quick verification is being rerun after this correction. --- polylogue/maintenance/schema_inference_gate.py | 5 ++++- polylogue/schemas/operator/commit.py | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/polylogue/maintenance/schema_inference_gate.py b/polylogue/maintenance/schema_inference_gate.py index 30cef9665a..668666ea41 100644 --- a/polylogue/maintenance/schema_inference_gate.py +++ b/polylogue/maintenance/schema_inference_gate.py @@ -1913,7 +1913,10 @@ def authorize_schema_generation(archive_root: Path, receipt_path: Path) -> Itera """Hold quiescence for one fresh schema operation or compatible short sequence.""" with schema_inference_quiescence(archive_root): - yield validate_schema_inference_gate_receipt(receipt_path, archive_root=archive_root) + yield cast( + dict[str, object], + validate_schema_inference_gate_receipt(receipt_path, archive_root=archive_root), + ) def _run_schema_inference_gate_locked( diff --git a/polylogue/schemas/operator/commit.py b/polylogue/schemas/operator/commit.py index 6034eeff9a..129a17d7d3 100644 --- a/polylogue/schemas/operator/commit.py +++ b/polylogue/schemas/operator/commit.py @@ -76,9 +76,12 @@ def _accepted_gate_receipt_digest(path: Path | None, *, archive_root: Path) -> s raise ValueError(f"unable to read schema-inference gate receipt {path}: {exc}") from exc if not isinstance(payload, Mapping): raise ValueError("schema-inference gate receipt must be a JSON object") - return validate_schema_inference_gate_receipt( - cast(Mapping[str, object], payload), - archive_root=archive_root, + return cast( + str, + validate_schema_inference_gate_receipt( + cast(Mapping[str, object], payload), + archive_root=archive_root, + ), )