1414 lines
54 KiB
Python
1414 lines
54 KiB
Python
"""Versioned verifier decisions and core exact/numeric implementations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import hashlib
|
|
import hmac
|
|
import math
|
|
import re
|
|
import struct
|
|
from decimal import Decimal
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from types import MappingProxyType
|
|
from typing import Any, Callable, Iterable, Mapping, Sequence, cast
|
|
|
|
from ._validation import (
|
|
enum_value,
|
|
canonical_json,
|
|
freeze_json_mapping,
|
|
require_exact_keys,
|
|
require_identifier,
|
|
require_positive_int,
|
|
require_sha256,
|
|
require_string,
|
|
require_task_key,
|
|
parse_release,
|
|
thaw_json,
|
|
)
|
|
from .artifacts import OutputManifest, PortSpec
|
|
from .identity import ComponentRef, SchemaRef, WorkloadId
|
|
from .manifest import TrustMode
|
|
|
|
|
|
class VerificationStatus(str, Enum):
|
|
"""The outcome of verification.
|
|
|
|
Only ``ACCEPTED`` satisfies a stage; ``INCONCLUSIVE`` must never be
|
|
treated as success by a reducer default.
|
|
"""
|
|
|
|
ACCEPTED = "accepted"
|
|
REJECTED = "rejected"
|
|
INCONCLUSIVE = "inconclusive"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VerificationDecision:
|
|
"""An immutable verifier outcome with bounded sanitized evidence.
|
|
|
|
``accepted_digest`` is set only for accepted decisions; evidence is
|
|
limited to 16 KiB and rejects local paths and transport URLs.
|
|
"""
|
|
|
|
status: VerificationStatus
|
|
verifier: ComponentRef
|
|
reason_code: str
|
|
evidence: Mapping[str, Any]
|
|
accepted_digest: str | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(
|
|
self,
|
|
"status",
|
|
enum_value(VerificationStatus, self.status, "verification.status"),
|
|
)
|
|
if not isinstance(self.verifier, ComponentRef):
|
|
raise ValueError("verification verifier must be a ComponentRef")
|
|
object.__setattr__(
|
|
self,
|
|
"reason_code",
|
|
require_identifier(self.reason_code, "verification.reason_code"),
|
|
)
|
|
evidence = freeze_json_mapping(
|
|
self.evidence, "verification.evidence", forbid_locations=True
|
|
)
|
|
if (
|
|
len(
|
|
json.dumps(thaw_json(evidence), sort_keys=True, allow_nan=False).encode(
|
|
"utf-8"
|
|
)
|
|
)
|
|
> 16_384
|
|
):
|
|
raise ValueError("verification evidence exceeds 16 KiB")
|
|
object.__setattr__(self, "evidence", evidence)
|
|
if self.accepted_digest is not None:
|
|
object.__setattr__(
|
|
self,
|
|
"accepted_digest",
|
|
require_sha256(self.accepted_digest, "accepted_digest"),
|
|
)
|
|
if self.status is VerificationStatus.ACCEPTED and self.accepted_digest is None:
|
|
raise ValueError("accepted verification requires an accepted_digest")
|
|
if (
|
|
self.status is not VerificationStatus.ACCEPTED
|
|
and self.accepted_digest is not None
|
|
):
|
|
raise ValueError("only accepted verification may carry an accepted_digest")
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"status": self.status.value,
|
|
"verifier": self.verifier.canonical,
|
|
"reason_code": self.reason_code,
|
|
"evidence": thaw_json(self.evidence),
|
|
"accepted_digest": self.accepted_digest,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: object) -> "VerificationDecision":
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("verification decision must be an object")
|
|
fields = {"status", "verifier", "reason_code", "evidence", "accepted_digest"}
|
|
require_exact_keys(value, fields, "verification decision")
|
|
return cls(
|
|
status=value["status"], # type: ignore[arg-type]
|
|
verifier=ComponentRef.from_dict(value["verifier"]),
|
|
reason_code=value["reason_code"], # type: ignore[arg-type]
|
|
evidence=value["evidence"], # type: ignore[arg-type]
|
|
accepted_digest=value["accepted_digest"], # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VerificationBinding:
|
|
"""Coordinator-owned scientific identity shared by accepted attempts."""
|
|
|
|
workload: WorkloadId
|
|
task_key: str
|
|
package_digest: str
|
|
manifest_digest: str
|
|
environment_digest: str
|
|
parameters_digest: str
|
|
input_collection_digest: str
|
|
execution_contract_digest: str
|
|
selected_features: Mapping[str, str]
|
|
optional_fallbacks: Mapping[str, str]
|
|
job_id: str
|
|
task_id: str
|
|
verifier: ComponentRef
|
|
sdk_api_version: str
|
|
protocol_version: str
|
|
manifest_schema_version: int
|
|
workflow_schema_version: int
|
|
artifact_schemas: tuple[SchemaRef, ...]
|
|
trust_mode: TrustMode
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.workload, WorkloadId):
|
|
raise ValueError("verification binding workload must be a WorkloadId")
|
|
object.__setattr__(
|
|
self,
|
|
"task_key",
|
|
require_task_key(self.task_key, "verification task_key"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"package_digest",
|
|
require_sha256(
|
|
self.package_digest, "verification package_digest", prefixed=True
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"manifest_digest",
|
|
require_sha256(self.manifest_digest, "verification manifest_digest"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"environment_digest",
|
|
require_sha256(
|
|
self.environment_digest,
|
|
"verification environment_digest",
|
|
prefixed=True,
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"parameters_digest",
|
|
require_sha256(self.parameters_digest, "verification parameters_digest"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"input_collection_digest",
|
|
require_sha256(
|
|
self.input_collection_digest,
|
|
"verification input_collection_digest",
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"execution_contract_digest",
|
|
require_sha256(
|
|
self.execution_contract_digest,
|
|
"verification execution_contract_digest",
|
|
),
|
|
)
|
|
selected_features = freeze_json_mapping(
|
|
self.selected_features,
|
|
"verification selected_features",
|
|
)
|
|
optional_fallbacks = freeze_json_mapping(
|
|
self.optional_fallbacks,
|
|
"verification optional_fallbacks",
|
|
)
|
|
for name, version in selected_features.items():
|
|
require_identifier(name, "verification selected feature")
|
|
require_string(
|
|
version, "verification selected feature version", max_length=32
|
|
)
|
|
parse_release(version, "verification selected feature version")
|
|
for name, fallback in optional_fallbacks.items():
|
|
require_identifier(name, "verification fallback feature")
|
|
require_identifier(fallback, "verification fallback")
|
|
if set(selected_features).intersection(optional_fallbacks):
|
|
raise ValueError("verification feature cannot be selected and fallbacked")
|
|
object.__setattr__(self, "selected_features", selected_features)
|
|
object.__setattr__(self, "optional_fallbacks", optional_fallbacks)
|
|
from ._validation import require_uuid
|
|
|
|
object.__setattr__(
|
|
self, "job_id", require_uuid(self.job_id, "verification job_id")
|
|
)
|
|
object.__setattr__(
|
|
self, "task_id", require_uuid(self.task_id, "verification task_id")
|
|
)
|
|
if not isinstance(self.verifier, ComponentRef):
|
|
raise ValueError("verification binding verifier must be a ComponentRef")
|
|
object.__setattr__(
|
|
self,
|
|
"sdk_api_version",
|
|
require_string(
|
|
self.sdk_api_version, "verification sdk_api_version", max_length=32
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"protocol_version",
|
|
require_string(
|
|
self.protocol_version, "verification protocol_version", max_length=32
|
|
),
|
|
)
|
|
parse_release(self.sdk_api_version, "verification sdk_api_version")
|
|
parse_release(self.protocol_version, "verification protocol_version")
|
|
object.__setattr__(
|
|
self,
|
|
"manifest_schema_version",
|
|
require_positive_int(
|
|
self.manifest_schema_version,
|
|
"verification manifest_schema_version",
|
|
),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"workflow_schema_version",
|
|
require_positive_int(
|
|
self.workflow_schema_version,
|
|
"verification workflow_schema_version",
|
|
),
|
|
)
|
|
schemas = tuple(self.artifact_schemas)
|
|
if not schemas or any(not isinstance(schema, SchemaRef) for schema in schemas):
|
|
raise ValueError(
|
|
"verification artifact_schemas must contain schema identities"
|
|
)
|
|
if len(schemas) != len(set(schemas)) or schemas != tuple(
|
|
sorted(schemas, key=lambda schema: schema.canonical)
|
|
):
|
|
raise ValueError(
|
|
"verification artifact_schemas must be unique and canonical"
|
|
)
|
|
object.__setattr__(self, "artifact_schemas", schemas)
|
|
try:
|
|
trust_mode = TrustMode(self.trust_mode)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("verification trust_mode is unsupported") from error
|
|
object.__setattr__(self, "trust_mode", trust_mode)
|
|
|
|
def matches(self, manifest: OutputManifest) -> bool:
|
|
if not isinstance(manifest, OutputManifest):
|
|
return False
|
|
provenance = manifest.provenance
|
|
return (
|
|
manifest.task_key == self.task_key
|
|
and provenance.workload == self.workload
|
|
and provenance.package_digest == self.package_digest
|
|
and provenance.manifest_digest == self.manifest_digest
|
|
and provenance.environment_digest == self.environment_digest
|
|
and provenance.parameters_digest == self.parameters_digest
|
|
and provenance.input_collection_digest == self.input_collection_digest
|
|
and provenance.execution_contract_digest == self.execution_contract_digest
|
|
and provenance.selected_features == self.selected_features
|
|
and provenance.optional_fallbacks == self.optional_fallbacks
|
|
and provenance.job_id == self.job_id
|
|
and provenance.task_id == self.task_id
|
|
and provenance.verifier == self.verifier
|
|
and provenance.sdk_api_version == self.sdk_api_version
|
|
and provenance.protocol_version == self.protocol_version
|
|
and provenance.manifest_schema_version == self.manifest_schema_version
|
|
and provenance.workflow_schema_version == self.workflow_schema_version
|
|
and provenance.artifact_schemas == self.artifact_schemas
|
|
and provenance.trust_mode == self.trust_mode.value
|
|
)
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"workload": self.workload.to_dict(),
|
|
"task_key": self.task_key,
|
|
"package_digest": self.package_digest,
|
|
"manifest_digest": self.manifest_digest,
|
|
"environment_digest": self.environment_digest,
|
|
"parameters_digest": self.parameters_digest,
|
|
"input_collection_digest": self.input_collection_digest,
|
|
"execution_contract_digest": self.execution_contract_digest,
|
|
"selected_features": thaw_json(self.selected_features),
|
|
"optional_fallbacks": thaw_json(self.optional_fallbacks),
|
|
"job_id": self.job_id,
|
|
"task_id": self.task_id,
|
|
"verifier": self.verifier.canonical,
|
|
"sdk_api_version": self.sdk_api_version,
|
|
"protocol_version": self.protocol_version,
|
|
"manifest_schema_version": self.manifest_schema_version,
|
|
"workflow_schema_version": self.workflow_schema_version,
|
|
"artifact_schemas": [schema.canonical for schema in self.artifact_schemas],
|
|
"trust_mode": self.trust_mode.value,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: object) -> "VerificationBinding":
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("verification binding must be an object")
|
|
fields = {
|
|
"workload",
|
|
"task_key",
|
|
"package_digest",
|
|
"manifest_digest",
|
|
"environment_digest",
|
|
"parameters_digest",
|
|
"input_collection_digest",
|
|
"execution_contract_digest",
|
|
"selected_features",
|
|
"optional_fallbacks",
|
|
"job_id",
|
|
"task_id",
|
|
"verifier",
|
|
"sdk_api_version",
|
|
"protocol_version",
|
|
"manifest_schema_version",
|
|
"workflow_schema_version",
|
|
"artifact_schemas",
|
|
"trust_mode",
|
|
}
|
|
require_exact_keys(value, fields, "verification binding")
|
|
schemas = value["artifact_schemas"]
|
|
if not isinstance(schemas, list):
|
|
raise ValueError("verification artifact_schemas must be an array")
|
|
return cls(
|
|
workload=WorkloadId.from_dict(value["workload"]),
|
|
task_key=value["task_key"], # type: ignore[arg-type]
|
|
package_digest=value["package_digest"], # type: ignore[arg-type]
|
|
manifest_digest=value["manifest_digest"], # type: ignore[arg-type]
|
|
environment_digest=value["environment_digest"], # type: ignore[arg-type]
|
|
parameters_digest=value["parameters_digest"], # type: ignore[arg-type]
|
|
input_collection_digest=value["input_collection_digest"], # type: ignore[arg-type]
|
|
execution_contract_digest=value["execution_contract_digest"], # type: ignore[arg-type]
|
|
selected_features=value["selected_features"], # type: ignore[arg-type]
|
|
optional_fallbacks=value["optional_fallbacks"], # type: ignore[arg-type]
|
|
job_id=value["job_id"], # type: ignore[arg-type]
|
|
task_id=value["task_id"], # type: ignore[arg-type]
|
|
verifier=ComponentRef.from_dict(value["verifier"]),
|
|
sdk_api_version=value["sdk_api_version"], # type: ignore[arg-type]
|
|
protocol_version=value["protocol_version"], # type: ignore[arg-type]
|
|
manifest_schema_version=value["manifest_schema_version"], # type: ignore[arg-type]
|
|
workflow_schema_version=value["workflow_schema_version"], # type: ignore[arg-type]
|
|
artifact_schemas=tuple(SchemaRef.from_dict(item) for item in schemas),
|
|
trust_mode=value["trust_mode"], # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class VerifyContext:
|
|
"""Coordinator-owned verification inputs: expected outputs, byte budget,
|
|
quorum size, an optional reference, and the binding for non-trusted modes.
|
|
|
|
Multi-vote contexts automatically require distinct authenticated owners.
|
|
"""
|
|
|
|
expected_outputs: Mapping[str, PortSpec]
|
|
max_output_bytes: int
|
|
minimum_matches: int = 1
|
|
reference: OutputManifest | None = None
|
|
require_distinct_owners: bool = False
|
|
binding: VerificationBinding | None = None
|
|
trust_mode: TrustMode = TrustMode.TRUSTED
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.expected_outputs, Mapping) or not self.expected_outputs:
|
|
raise ValueError("expected_outputs must be a non-empty object")
|
|
ports: dict[str, PortSpec] = {}
|
|
for name, port in self.expected_outputs.items():
|
|
canonical = require_identifier(name, "expected output port")
|
|
if not isinstance(port, PortSpec):
|
|
raise ValueError("expected_outputs values must be PortSpec values")
|
|
ports[canonical] = port
|
|
object.__setattr__(self, "expected_outputs", MappingProxyType(ports))
|
|
object.__setattr__(
|
|
self,
|
|
"max_output_bytes",
|
|
require_positive_int(self.max_output_bytes, "max_output_bytes"),
|
|
)
|
|
object.__setattr__(
|
|
self,
|
|
"minimum_matches",
|
|
require_positive_int(self.minimum_matches, "minimum_matches"),
|
|
)
|
|
if not isinstance(self.require_distinct_owners, bool):
|
|
raise ValueError("require_distinct_owners must be a boolean")
|
|
# A multi-vote quorum is never allowed to fall back to anonymous
|
|
# candidate counting. Single-candidate trusted verification remains
|
|
# convenient, while every quorum must carry coordinator-owned owners.
|
|
if self.minimum_matches > 1:
|
|
object.__setattr__(self, "require_distinct_owners", True)
|
|
if self.binding is not None and not isinstance(
|
|
self.binding, VerificationBinding
|
|
):
|
|
raise ValueError("binding must be a VerificationBinding")
|
|
try:
|
|
trust_mode = TrustMode(self.trust_mode)
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("verification trust_mode is unsupported") from error
|
|
object.__setattr__(self, "trust_mode", trust_mode)
|
|
if self.binding is not None and self.binding.trust_mode is not trust_mode:
|
|
raise ValueError(
|
|
"verification context trust mode does not match its binding"
|
|
)
|
|
if trust_mode is not TrustMode.TRUSTED and self.binding is None:
|
|
raise ValueError("non-trusted verification requires a coordinator binding")
|
|
if trust_mode is TrustMode.UNTRUSTED_QUORUM:
|
|
if self.minimum_matches < 2:
|
|
raise ValueError(
|
|
"untrusted quorum requires at least two matching owners"
|
|
)
|
|
object.__setattr__(self, "require_distinct_owners", True)
|
|
if self.require_distinct_owners and self.binding is None:
|
|
raise ValueError("multi-owner verification requires a coordinator binding")
|
|
if self.reference is not None:
|
|
if not isinstance(self.reference, OutputManifest):
|
|
raise ValueError("reference must be an OutputManifest")
|
|
self.reference.validate_against(
|
|
self.expected_outputs, max_output_bytes=self.max_output_bytes
|
|
)
|
|
if self.binding is not None and not self.binding.matches(self.reference):
|
|
raise ValueError(
|
|
"reference output does not match the coordinator binding"
|
|
)
|
|
|
|
|
|
_CANDIDATE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
|
|
|
|
|
def _candidate_identity(value: object, field: str) -> str:
|
|
text = require_string(value, field, max_length=128)
|
|
if not _CANDIDATE_ID_PATTERN.fullmatch(text):
|
|
raise ValueError(f"{field} must be an opaque coordinator identity")
|
|
return text
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CandidateOutput:
|
|
"""Coordinator-authenticated identity envelope for one output attempt.
|
|
|
|
The SDK validates the envelope shape, but the coordinator is responsible
|
|
for constructing it from authenticated attempt and owner records. Worker
|
|
payloads must never be allowed to choose these identity fields directly.
|
|
``owner_id`` may be omitted only for a trusted single-candidate path.
|
|
"""
|
|
|
|
candidate_id: str
|
|
owner_id: str | None
|
|
manifest: OutputManifest
|
|
authentication_tag: str | None = None
|
|
_coordinator_authenticated: bool = field(default=False, init=False, repr=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(
|
|
self,
|
|
"candidate_id",
|
|
_candidate_identity(self.candidate_id, "candidate_id"),
|
|
)
|
|
if self.owner_id is not None:
|
|
object.__setattr__(
|
|
self,
|
|
"owner_id",
|
|
_candidate_identity(self.owner_id, "owner_id"),
|
|
)
|
|
if not isinstance(self.manifest, OutputManifest):
|
|
raise ValueError("candidate manifest must be an OutputManifest")
|
|
if self.authentication_tag is not None:
|
|
if self.owner_id is None:
|
|
raise ValueError("authenticated candidate requires an owner_id")
|
|
object.__setattr__(
|
|
self,
|
|
"authentication_tag",
|
|
require_sha256(self.authentication_tag, "candidate authentication_tag"),
|
|
)
|
|
|
|
def _authentication_payload(self) -> bytes:
|
|
return canonical_json(
|
|
{
|
|
"candidate_id": self.candidate_id,
|
|
"owner_id": self.owner_id,
|
|
"manifest": self.manifest.to_dict(),
|
|
}
|
|
).encode("utf-8")
|
|
|
|
def authenticated_by(self, key: bytes) -> bool:
|
|
if (
|
|
self.authentication_tag is None
|
|
or not isinstance(key, bytes)
|
|
or len(key) < 32
|
|
):
|
|
return False
|
|
expected = hmac.new(
|
|
key, self._authentication_payload(), hashlib.sha256
|
|
).hexdigest()
|
|
return hmac.compare_digest(self.authentication_tag, expected)
|
|
|
|
@classmethod
|
|
def from_coordinator_record(
|
|
cls,
|
|
candidate_id: str,
|
|
owner_id: str,
|
|
manifest: OutputManifest,
|
|
authentication_key: bytes,
|
|
) -> "CandidateOutput":
|
|
"""Create an envelope from authenticated coordinator-owned records.
|
|
|
|
Worker wire payloads must use :meth:`from_dict`, which deliberately
|
|
cannot confer this process-local authority marker.
|
|
"""
|
|
if not isinstance(authentication_key, bytes) or len(authentication_key) < 32:
|
|
raise ValueError(
|
|
"candidate authentication key must contain at least 32 bytes"
|
|
)
|
|
unsigned = cls(candidate_id, owner_id, manifest)
|
|
tag = hmac.new(
|
|
authentication_key,
|
|
unsigned._authentication_payload(),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
value = cls(candidate_id, owner_id, manifest, tag)
|
|
object.__setattr__(value, "_coordinator_authenticated", True)
|
|
return value
|
|
|
|
@property
|
|
def coordinator_authenticated(self) -> bool:
|
|
return self._coordinator_authenticated
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"candidate_id": self.candidate_id,
|
|
"owner_id": self.owner_id,
|
|
"manifest": self.manifest.to_dict(),
|
|
"authentication_tag": self.authentication_tag,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: object) -> "CandidateOutput":
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("candidate output must be an object")
|
|
require_exact_keys(
|
|
value,
|
|
{"candidate_id", "owner_id", "manifest", "authentication_tag"},
|
|
"candidate output",
|
|
)
|
|
return cls(
|
|
candidate_id=value["candidate_id"], # type: ignore[arg-type]
|
|
owner_id=value["owner_id"], # type: ignore[arg-type]
|
|
manifest=OutputManifest.from_dict(value["manifest"]),
|
|
authentication_tag=value["authentication_tag"], # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True, init=False)
|
|
class CandidateOutputs:
|
|
"""Immutable candidate set with replay-safe coordinator identities.
|
|
|
|
For source compatibility, ``CandidateOutputs((manifest,))`` creates one
|
|
anonymous synthetic envelope for trusted local verification. Raw manifests
|
|
cannot be combined or used to form a quorum; distributed callers provide
|
|
explicit :class:`CandidateOutput` envelopes instead.
|
|
"""
|
|
|
|
candidates: tuple[CandidateOutput, ...]
|
|
|
|
def __init__(
|
|
self,
|
|
manifests: Sequence[CandidateOutput | OutputManifest] = (),
|
|
*,
|
|
candidates: Sequence[CandidateOutput | OutputManifest] | None = None,
|
|
) -> None:
|
|
if candidates is not None:
|
|
if manifests:
|
|
raise ValueError("provide candidate values only once")
|
|
manifests = candidates
|
|
values = tuple(manifests)
|
|
raw = tuple(value for value in values if isinstance(value, OutputManifest))
|
|
if raw:
|
|
if len(values) != 1:
|
|
raise ValueError(
|
|
"raw OutputManifest compatibility is limited to one trusted candidate"
|
|
)
|
|
manifest = raw[0]
|
|
normalized = (
|
|
CandidateOutput(
|
|
candidate_id=f"trusted-{manifest.manifest_digest}",
|
|
owner_id=None,
|
|
manifest=manifest,
|
|
),
|
|
)
|
|
else:
|
|
if any(not isinstance(value, CandidateOutput) for value in values):
|
|
raise ValueError("candidates must contain CandidateOutput values")
|
|
normalized = cast(tuple[CandidateOutput, ...], values)
|
|
candidate_ids = [value.candidate_id for value in normalized]
|
|
if len(candidate_ids) != len(set(candidate_ids)):
|
|
raise ValueError("candidate_id values must be unique")
|
|
object.__setattr__(self, "candidates", normalized)
|
|
|
|
@property
|
|
def manifests(self) -> tuple[OutputManifest, ...]:
|
|
"""Compatibility view for trusted code that only needs manifests."""
|
|
return tuple(candidate.manifest for candidate in self.candidates)
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {"candidates": [candidate.to_dict() for candidate in self.candidates]}
|
|
|
|
@classmethod
|
|
def from_dict(cls, value: object) -> "CandidateOutputs":
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("candidate outputs must be an object")
|
|
require_exact_keys(value, {"candidates"}, "candidate outputs")
|
|
candidates = value["candidates"]
|
|
if not isinstance(candidates, list):
|
|
raise ValueError("candidate outputs candidates must be an array")
|
|
return cls(
|
|
candidates=tuple(CandidateOutput.from_dict(item) for item in candidates)
|
|
)
|
|
|
|
@classmethod
|
|
def from_authenticated_dict(
|
|
cls,
|
|
value: object,
|
|
authentication_key: bytes,
|
|
) -> "CandidateOutputs":
|
|
"""Decode an internal coordinator envelope and verify every MAC.
|
|
|
|
The signing key remains in the SDK-owned transport adapter and is never
|
|
placed in :class:`VerifyContext` or exposed to package verifier code.
|
|
"""
|
|
decoded = cls.from_dict(value)
|
|
if not isinstance(authentication_key, bytes) or len(authentication_key) < 32:
|
|
raise ValueError(
|
|
"candidate authentication key must contain at least 32 bytes"
|
|
)
|
|
for candidate in decoded.candidates:
|
|
if not candidate.authenticated_by(authentication_key):
|
|
raise ValueError("candidate envelope authentication failed")
|
|
object.__setattr__(candidate, "_coordinator_authenticated", True)
|
|
return decoded
|
|
|
|
|
|
def _authentication_failure(
|
|
context: VerifyContext,
|
|
candidates: CandidateOutputs,
|
|
identity: ComponentRef,
|
|
) -> VerificationDecision | None:
|
|
if (
|
|
context.trust_mode is TrustMode.TRUSTED
|
|
and context.minimum_matches == 1
|
|
and not context.require_distinct_owners
|
|
):
|
|
return None
|
|
invalid = sum(
|
|
candidate.owner_id is None or not candidate.coordinator_authenticated
|
|
for candidate in candidates.candidates
|
|
)
|
|
if invalid:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
identity,
|
|
"coordinator-authentication-required",
|
|
{
|
|
"candidate_count": len(candidates.candidates),
|
|
"unauthenticated_count": invalid,
|
|
},
|
|
)
|
|
return None
|
|
|
|
|
|
def _verify_loaded_candidates(
|
|
context: VerifyContext,
|
|
candidates: CandidateOutputs,
|
|
identity: ComponentRef,
|
|
compare: Callable[[OutputManifest, OutputManifest], VerificationDecision],
|
|
) -> VerificationDecision:
|
|
"""Apply a package-owned loader/comparator without trusting vote replay."""
|
|
if not isinstance(context, VerifyContext) or not isinstance(
|
|
candidates, CandidateOutputs
|
|
):
|
|
raise ValueError("verifier requires VerifyContext and CandidateOutputs")
|
|
authentication_failure = _authentication_failure(context, candidates, identity)
|
|
if authentication_failure is not None:
|
|
return authentication_failure
|
|
if context.reference is None:
|
|
return VerificationDecision(
|
|
VerificationStatus.INCONCLUSIVE,
|
|
identity,
|
|
"reference-required",
|
|
{"candidate_count": len(candidates.candidates)},
|
|
)
|
|
if context.require_distinct_owners and any(
|
|
candidate.owner_id is None for candidate in candidates.candidates
|
|
):
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
identity,
|
|
"owner-identity-required",
|
|
{"candidate_count": len(candidates.candidates)},
|
|
)
|
|
matched = 0
|
|
compared = 0
|
|
invalid = 0
|
|
seen_owners: set[str] = set()
|
|
for candidate in sorted(candidates.candidates, key=lambda item: item.candidate_id):
|
|
if candidate.owner_id is not None:
|
|
if candidate.owner_id in seen_owners:
|
|
continue
|
|
seen_owners.add(candidate.owner_id)
|
|
try:
|
|
if context.binding is not None and not context.binding.matches(
|
|
candidate.manifest
|
|
):
|
|
raise ValueError("candidate does not match the coordinator binding")
|
|
candidate.manifest.validate_against(
|
|
context.expected_outputs,
|
|
max_output_bytes=context.max_output_bytes,
|
|
)
|
|
decision = compare(context.reference, candidate.manifest)
|
|
except Exception:
|
|
invalid += 1
|
|
continue
|
|
compared += 1
|
|
if decision.status is VerificationStatus.ACCEPTED:
|
|
matched += 1
|
|
evidence = {
|
|
"matched": matched,
|
|
"required": context.minimum_matches,
|
|
"compared": compared,
|
|
"invalid_count": invalid,
|
|
}
|
|
if matched >= context.minimum_matches:
|
|
return VerificationDecision(
|
|
VerificationStatus.ACCEPTED,
|
|
identity,
|
|
"reference-match",
|
|
evidence,
|
|
context.reference.digest,
|
|
)
|
|
if compared >= context.minimum_matches:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
identity,
|
|
"reference-mismatch",
|
|
evidence,
|
|
)
|
|
return VerificationDecision(
|
|
VerificationStatus.INCONCLUSIVE,
|
|
identity,
|
|
"insufficient-evidence",
|
|
evidence,
|
|
)
|
|
|
|
|
|
class ExactArtifactVerifier:
|
|
"""Whole-artifact SHA-256 acceptance for byte-exact workloads.
|
|
|
|
Compares logical port/collection/schema/content digests while ignoring
|
|
coordinator UUIDs, timestamps, and worker identity; counts at most one
|
|
vote per owner and accepts only a declared reference match or an
|
|
unambiguous quorum.
|
|
"""
|
|
|
|
identity = ComponentRef("exact-artifact", 1)
|
|
configuration: Mapping[str, object] = MappingProxyType({})
|
|
|
|
def verify(
|
|
self,
|
|
context: VerifyContext,
|
|
candidates: CandidateOutputs,
|
|
) -> VerificationDecision:
|
|
if not isinstance(context, VerifyContext) or not isinstance(
|
|
candidates, CandidateOutputs
|
|
):
|
|
raise ValueError(
|
|
"exact verifier requires VerifyContext and CandidateOutputs"
|
|
)
|
|
authentication_failure = _authentication_failure(
|
|
context, candidates, self.identity
|
|
)
|
|
if authentication_failure is not None:
|
|
return authentication_failure
|
|
if context.require_distinct_owners:
|
|
missing_owner_count = sum(
|
|
candidate.owner_id is None for candidate in candidates.candidates
|
|
)
|
|
if missing_owner_count:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"owner-identity-required",
|
|
{
|
|
"candidate_count": len(candidates.candidates),
|
|
"missing_owner_count": missing_owner_count,
|
|
},
|
|
)
|
|
|
|
valid: list[CandidateOutput] = []
|
|
invalid = 0
|
|
for candidate in candidates.candidates:
|
|
try:
|
|
if context.binding is not None and not context.binding.matches(
|
|
candidate.manifest
|
|
):
|
|
raise ValueError("candidate does not match the coordinator binding")
|
|
candidate.manifest.validate_against(
|
|
context.expected_outputs,
|
|
max_output_bytes=context.max_output_bytes,
|
|
)
|
|
except ValueError:
|
|
invalid += 1
|
|
else:
|
|
valid.append(candidate)
|
|
if not valid:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED
|
|
if candidates.candidates
|
|
else VerificationStatus.INCONCLUSIVE,
|
|
self.identity,
|
|
"no-valid-candidates" if candidates.candidates else "no-candidates",
|
|
{
|
|
"candidate_count": len(candidates.candidates),
|
|
"invalid_count": invalid,
|
|
},
|
|
)
|
|
owner_digests: dict[str, set[str]] = {}
|
|
for candidate in valid:
|
|
if candidate.owner_id is not None:
|
|
owner_digests.setdefault(candidate.owner_id, set()).add(
|
|
candidate.manifest.digest
|
|
)
|
|
equivocating_owner_count = sum(
|
|
len(digests) > 1 for digests in owner_digests.values()
|
|
)
|
|
if equivocating_owner_count:
|
|
# Do not echo owner IDs or conflicting digests into durable
|
|
# evidence. Counts are sufficient for policy and audit routing.
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"owner-equivocation",
|
|
{
|
|
"candidate_count": len(candidates.candidates),
|
|
"equivocating_owner_count": equivocating_owner_count,
|
|
},
|
|
)
|
|
counts: dict[str, int] = {}
|
|
seen_owners: set[str] = set()
|
|
duplicate_owner_candidates = 0
|
|
for candidate in sorted(valid, key=lambda value: value.candidate_id):
|
|
if candidate.owner_id is not None:
|
|
if candidate.owner_id in seen_owners:
|
|
duplicate_owner_candidates += 1
|
|
continue
|
|
seen_owners.add(candidate.owner_id)
|
|
digest = candidate.manifest.digest
|
|
counts[digest] = counts.get(digest, 0) + 1
|
|
|
|
def with_duplicate_evidence(evidence: dict[str, int]) -> dict[str, int]:
|
|
if duplicate_owner_candidates:
|
|
evidence["duplicate_owner_candidates"] = duplicate_owner_candidates
|
|
return evidence
|
|
|
|
if context.reference is not None:
|
|
expected_digest = context.reference.digest
|
|
matched = counts.get(expected_digest, 0)
|
|
if matched >= context.minimum_matches:
|
|
return VerificationDecision(
|
|
VerificationStatus.ACCEPTED,
|
|
self.identity,
|
|
"reference-match",
|
|
with_duplicate_evidence(
|
|
{
|
|
"matched": matched,
|
|
"required": context.minimum_matches,
|
|
"invalid_count": invalid,
|
|
}
|
|
),
|
|
expected_digest,
|
|
)
|
|
status = (
|
|
VerificationStatus.REJECTED
|
|
if sum(counts.values()) >= context.minimum_matches
|
|
else VerificationStatus.INCONCLUSIVE
|
|
)
|
|
return VerificationDecision(
|
|
status,
|
|
self.identity,
|
|
"reference-mismatch"
|
|
if status is VerificationStatus.REJECTED
|
|
else "insufficient-evidence",
|
|
with_duplicate_evidence(
|
|
{
|
|
"matched": matched,
|
|
"required": context.minimum_matches,
|
|
"invalid_count": invalid,
|
|
}
|
|
),
|
|
)
|
|
ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
|
|
digest, matches = ordered[0]
|
|
tied = len(ordered) > 1 and ordered[1][1] == matches
|
|
if matches >= context.minimum_matches and not tied:
|
|
return VerificationDecision(
|
|
VerificationStatus.ACCEPTED,
|
|
self.identity,
|
|
"quorum-match",
|
|
with_duplicate_evidence(
|
|
{
|
|
"matched": matches,
|
|
"required": context.minimum_matches,
|
|
"distinct_digests": len(counts),
|
|
"invalid_count": invalid,
|
|
}
|
|
),
|
|
digest,
|
|
)
|
|
if tied and matches >= context.minimum_matches:
|
|
status = VerificationStatus.REJECTED
|
|
reason = "conflicting-quorums"
|
|
else:
|
|
status = VerificationStatus.INCONCLUSIVE
|
|
reason = "insufficient-evidence"
|
|
return VerificationDecision(
|
|
status,
|
|
self.identity,
|
|
reason,
|
|
with_duplicate_evidence(
|
|
{
|
|
"largest_group": matches,
|
|
"required": context.minimum_matches,
|
|
"distinct_digests": len(counts),
|
|
"invalid_count": invalid,
|
|
}
|
|
),
|
|
)
|
|
|
|
|
|
def _float_ulp_distance(left: float, right: float) -> int:
|
|
"""Return IEEE-754 binary64 representable steps between two finite values."""
|
|
sign = 0x8000000000000000
|
|
magnitude = sign - 1
|
|
|
|
def ordered(value: float) -> int:
|
|
bits = struct.unpack(">Q", struct.pack(">d", value))[0]
|
|
return sign - (bits & magnitude) if bits & sign else sign + bits
|
|
|
|
return abs(ordered(left) - ordered(right))
|
|
|
|
|
|
def _numeric_digest_value(value: object, depth: int = 0) -> object:
|
|
if depth > 64:
|
|
raise ValueError("numeric value nesting exceeds 64 levels")
|
|
if isinstance(value, Mapping):
|
|
if any(not isinstance(key, str) for key in value):
|
|
raise ValueError("numeric objects must use JSON string keys")
|
|
return {
|
|
key: _numeric_digest_value(child, depth + 1) for key, child in value.items()
|
|
}
|
|
if isinstance(value, (list, tuple)):
|
|
return [_numeric_digest_value(child, depth + 1) for child in value]
|
|
if isinstance(value, float) and math.isnan(value):
|
|
return {"$number": "nan"}
|
|
return value
|
|
|
|
|
|
def _numeric_value_within_limit(value: object, max_elements: int) -> bool:
|
|
"""Bound a loaded numeric shape before comparison or digest allocation."""
|
|
pending = [value]
|
|
visited = 0
|
|
while pending:
|
|
current = pending.pop()
|
|
visited += 1
|
|
if visited > max_elements:
|
|
return False
|
|
if isinstance(current, Mapping):
|
|
if len(current) > max_elements - visited:
|
|
return False
|
|
pending.extend(current.values())
|
|
elif isinstance(current, (list, tuple)):
|
|
if len(current) > max_elements - visited:
|
|
return False
|
|
pending.extend(current)
|
|
return True
|
|
|
|
|
|
def _decimal_evidence(value: Decimal) -> int | float | str:
|
|
"""Return bounded, JSON-safe numeric evidence without range exceptions."""
|
|
if value == value.to_integral_value() and abs(value).adjusted() <= 18:
|
|
return int(value)
|
|
try:
|
|
converted = float(value)
|
|
except (OverflowError, ValueError):
|
|
converted = math.inf
|
|
if math.isfinite(converted):
|
|
return converted
|
|
text = format(value, ".16E")
|
|
return text if len(text) <= 64 else text[:64]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NumericTolerance:
|
|
"""Bounded numeric comparison policy: absolute/relative/ULP tolerances,
|
|
NaN policy, and a maximum element count for structured values."""
|
|
|
|
absolute: float = 0.0
|
|
relative: float = 0.0
|
|
max_ulps: int = 0
|
|
nan_policy: str = "reject"
|
|
max_elements: int = 1_000_000
|
|
|
|
def __post_init__(self) -> None:
|
|
for field in ("absolute", "relative"):
|
|
value = getattr(self, field)
|
|
if (
|
|
isinstance(value, bool)
|
|
or not isinstance(value, (int, float))
|
|
or value < 0
|
|
):
|
|
raise ValueError(
|
|
f"numeric tolerance {field} must be a finite non-negative number"
|
|
)
|
|
try:
|
|
converted = float(value)
|
|
except (OverflowError, ValueError) as error:
|
|
raise ValueError(
|
|
f"numeric tolerance {field} must be a finite non-negative number"
|
|
) from error
|
|
if not math.isfinite(converted):
|
|
raise ValueError(
|
|
f"numeric tolerance {field} must be a finite non-negative number"
|
|
)
|
|
object.__setattr__(self, field, converted)
|
|
if (
|
|
isinstance(self.max_ulps, bool)
|
|
or not isinstance(self.max_ulps, int)
|
|
or self.max_ulps < 0
|
|
):
|
|
raise ValueError(
|
|
"numeric tolerance max_ulps must be a non-negative integer"
|
|
)
|
|
if self.nan_policy not in {"reject", "equal"}:
|
|
raise ValueError("numeric tolerance nan_policy must be reject or equal")
|
|
object.__setattr__(
|
|
self,
|
|
"max_elements",
|
|
require_positive_int(self.max_elements, "numeric tolerance max_elements"),
|
|
)
|
|
|
|
|
|
class NumericToleranceVerifier:
|
|
"""Reference-based structured numeric comparison verifier.
|
|
|
|
Requires a package-owned ``value_loader`` to turn artifacts into bounded
|
|
structured values; without one, verification returns ``inconclusive``
|
|
rather than accepting bytes it did not parse.
|
|
"""
|
|
|
|
identity = ComponentRef("numeric-tolerance", 1)
|
|
|
|
def __init__(
|
|
self,
|
|
tolerance: NumericTolerance,
|
|
value_loader: Callable[[OutputManifest], object] | None = None,
|
|
) -> None:
|
|
if not isinstance(tolerance, NumericTolerance):
|
|
raise ValueError("tolerance must be NumericTolerance")
|
|
if value_loader is not None and not callable(value_loader):
|
|
raise ValueError("value_loader must be callable")
|
|
self.tolerance = tolerance
|
|
self._value_loader = value_loader
|
|
|
|
@property
|
|
def configuration(self) -> Mapping[str, object]:
|
|
return MappingProxyType(
|
|
{
|
|
"absolute": self.tolerance.absolute,
|
|
"relative": self.tolerance.relative,
|
|
"max_ulps": self.tolerance.max_ulps,
|
|
"nan_policy": self.tolerance.nan_policy,
|
|
"max_elements": self.tolerance.max_elements,
|
|
}
|
|
)
|
|
|
|
def verify(
|
|
self,
|
|
context: VerifyContext,
|
|
candidates: CandidateOutputs,
|
|
) -> VerificationDecision:
|
|
if not isinstance(context, VerifyContext) or not isinstance(
|
|
candidates, CandidateOutputs
|
|
):
|
|
raise ValueError(
|
|
"numeric verifier requires VerifyContext and CandidateOutputs"
|
|
)
|
|
if self._value_loader is None:
|
|
return VerificationDecision(
|
|
VerificationStatus.INCONCLUSIVE,
|
|
self.identity,
|
|
"loader-unavailable",
|
|
{"candidate_count": len(candidates.candidates)},
|
|
)
|
|
|
|
def compare(
|
|
reference: OutputManifest, candidate: OutputManifest
|
|
) -> VerificationDecision:
|
|
assert self._value_loader is not None
|
|
return self.verify_values(
|
|
self._value_loader(reference),
|
|
self._value_loader(candidate),
|
|
)
|
|
|
|
return _verify_loaded_candidates(context, candidates, self.identity, compare)
|
|
|
|
def verify_values(self, expected: object, actual: object) -> VerificationDecision:
|
|
if not _numeric_value_within_limit(
|
|
expected,
|
|
self.tolerance.max_elements,
|
|
) or not _numeric_value_within_limit(actual, self.tolerance.max_elements):
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"element-limit",
|
|
{"max_elements": self.tolerance.max_elements},
|
|
)
|
|
mismatch = self._compare(expected, actual, "$", 0)
|
|
if mismatch is None:
|
|
digest = hashlib.sha256(
|
|
canonical_json(_numeric_digest_value(actual)).encode("utf-8")
|
|
).hexdigest()
|
|
return VerificationDecision(
|
|
VerificationStatus.ACCEPTED,
|
|
self.identity,
|
|
"within-tolerance",
|
|
{
|
|
"absolute": self.tolerance.absolute,
|
|
"relative": self.tolerance.relative,
|
|
"max_ulps": self.tolerance.max_ulps,
|
|
},
|
|
digest,
|
|
)
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
mismatch[0],
|
|
{"location": mismatch[1], **mismatch[2]},
|
|
)
|
|
|
|
def _compare(
|
|
self,
|
|
expected: object,
|
|
actual: object,
|
|
location: str,
|
|
depth: int,
|
|
) -> tuple[str, str, dict[str, object]] | None:
|
|
if depth > 64:
|
|
return "nesting-limit", location, {}
|
|
if isinstance(expected, bool) or isinstance(actual, bool):
|
|
return None if expected is actual else ("value-mismatch", location, {})
|
|
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
|
|
if isinstance(expected, int) and isinstance(actual, int):
|
|
if max(abs(expected).bit_length(), abs(actual).bit_length()) > 1024:
|
|
return "numeric-range", location, {}
|
|
difference_int = abs(actual - expected)
|
|
allowed_decimal = max(
|
|
Decimal(str(self.tolerance.absolute)),
|
|
Decimal(str(self.tolerance.relative))
|
|
* Decimal(max(abs(expected), abs(actual))),
|
|
)
|
|
if Decimal(difference_int) <= allowed_decimal:
|
|
return None
|
|
return (
|
|
"numeric-mismatch",
|
|
location,
|
|
{
|
|
"absolute_error": difference_int,
|
|
"allowed_error": _decimal_evidence(allowed_decimal),
|
|
"ulp_distance": 0,
|
|
},
|
|
)
|
|
if (
|
|
isinstance(expected, int)
|
|
and abs(expected).bit_length() > 1024
|
|
or isinstance(actual, int)
|
|
and abs(actual).bit_length() > 1024
|
|
):
|
|
return "numeric-range", location, {}
|
|
left = float(expected) if isinstance(expected, int) else expected
|
|
right = float(actual) if isinstance(actual, int) else actual
|
|
if math.isnan(left) or math.isnan(right):
|
|
if (
|
|
self.tolerance.nan_policy == "equal"
|
|
and math.isnan(left)
|
|
and math.isnan(right)
|
|
):
|
|
return None
|
|
return "nan-policy", location, {}
|
|
if not math.isfinite(left) or not math.isfinite(right):
|
|
return "non-finite", location, {}
|
|
expected_decimal = (
|
|
Decimal(expected)
|
|
if isinstance(expected, int)
|
|
else Decimal.from_float(expected)
|
|
)
|
|
actual_decimal = (
|
|
Decimal(actual)
|
|
if isinstance(actual, int)
|
|
else Decimal.from_float(actual)
|
|
)
|
|
difference_decimal = abs(actual_decimal - expected_decimal)
|
|
allowed_decimal = max(
|
|
Decimal(str(self.tolerance.absolute)),
|
|
Decimal(str(self.tolerance.relative))
|
|
* max(abs(expected_decimal), abs(actual_decimal)),
|
|
)
|
|
ulp_distance = (
|
|
_float_ulp_distance(expected, actual)
|
|
if isinstance(expected, float) and isinstance(actual, float)
|
|
else None
|
|
)
|
|
if difference_decimal <= allowed_decimal or (
|
|
ulp_distance is not None and ulp_distance <= self.tolerance.max_ulps
|
|
):
|
|
return None
|
|
return (
|
|
"numeric-mismatch",
|
|
location,
|
|
{
|
|
"absolute_error": _decimal_evidence(difference_decimal),
|
|
"allowed_error": _decimal_evidence(allowed_decimal),
|
|
"ulp_distance": ulp_distance if ulp_distance is not None else 0,
|
|
},
|
|
)
|
|
if isinstance(expected, Mapping) and isinstance(actual, Mapping):
|
|
if any(not isinstance(key, str) for key in expected) or any(
|
|
not isinstance(key, str) for key in actual
|
|
):
|
|
return "type-mismatch", location, {}
|
|
if set(expected) != set(actual):
|
|
return (
|
|
"shape-mismatch",
|
|
location,
|
|
{
|
|
"missing_keys": sorted(
|
|
str(key) for key in set(expected) - set(actual)
|
|
)[:32],
|
|
"extra_keys": sorted(
|
|
str(key) for key in set(actual) - set(expected)
|
|
)[:32],
|
|
},
|
|
)
|
|
for key in sorted(expected, key=str):
|
|
mismatch = self._compare(
|
|
expected[key], actual[key], f"{location}.{key}", depth + 1
|
|
)
|
|
if mismatch is not None:
|
|
return mismatch
|
|
return None
|
|
if isinstance(expected, (list, tuple)) and isinstance(actual, (list, tuple)):
|
|
if len(expected) != len(actual):
|
|
return (
|
|
"shape-mismatch",
|
|
location,
|
|
{"expected_length": len(expected), "actual_length": len(actual)},
|
|
)
|
|
for index, (left, right) in enumerate(zip(expected, actual)):
|
|
mismatch = self._compare(left, right, f"{location}[{index}]", depth + 1)
|
|
if mismatch is not None:
|
|
return mismatch
|
|
return None
|
|
if type(expected) is not type(actual):
|
|
return (
|
|
"type-mismatch",
|
|
location,
|
|
{
|
|
"expected_type": type(expected).__name__,
|
|
"actual_type": type(actual).__name__,
|
|
},
|
|
)
|
|
return None if expected == actual else ("value-mismatch", location, {})
|
|
|
|
|
|
class CanonicalRecordVerifier:
|
|
"""Package-owned record canonicalizer with bounded core comparison logic."""
|
|
|
|
identity = ComponentRef("canonical-record", 1)
|
|
|
|
def __init__(
|
|
self,
|
|
canonicalizer: Callable[[object], bytes],
|
|
*,
|
|
max_records: int = 1_000_000,
|
|
record_loader: Callable[[OutputManifest], Iterable[object]] | None = None,
|
|
) -> None:
|
|
if not callable(canonicalizer):
|
|
raise ValueError("canonicalizer must be callable")
|
|
self._canonicalizer = canonicalizer
|
|
self._max_records = require_positive_int(max_records, "max_records")
|
|
if record_loader is not None and not callable(record_loader):
|
|
raise ValueError("record_loader must be callable")
|
|
self._record_loader = record_loader
|
|
|
|
@property
|
|
def configuration(self) -> Mapping[str, object]:
|
|
return MappingProxyType({"max_records": self._max_records})
|
|
|
|
def verify(
|
|
self,
|
|
context: VerifyContext,
|
|
candidates: CandidateOutputs,
|
|
) -> VerificationDecision:
|
|
if not isinstance(context, VerifyContext) or not isinstance(
|
|
candidates, CandidateOutputs
|
|
):
|
|
raise ValueError(
|
|
"canonical verifier requires VerifyContext and CandidateOutputs"
|
|
)
|
|
if self._record_loader is None:
|
|
return VerificationDecision(
|
|
VerificationStatus.INCONCLUSIVE,
|
|
self.identity,
|
|
"loader-unavailable",
|
|
{"candidate_count": len(candidates.candidates)},
|
|
)
|
|
|
|
def compare(
|
|
reference: OutputManifest, candidate: OutputManifest
|
|
) -> VerificationDecision:
|
|
assert self._record_loader is not None
|
|
return self.verify_records(
|
|
self._record_loader(reference),
|
|
self._record_loader(candidate),
|
|
)
|
|
|
|
return _verify_loaded_candidates(context, candidates, self.identity, compare)
|
|
|
|
def verify_records(
|
|
self, expected: Iterable[object], actual: Iterable[object]
|
|
) -> VerificationDecision:
|
|
expected_digest = hashlib.sha256()
|
|
actual_digest = hashlib.sha256()
|
|
counts = [0, 0]
|
|
try:
|
|
for index, (stream, digest) in enumerate(
|
|
((expected, expected_digest), (actual, actual_digest))
|
|
):
|
|
for record in stream:
|
|
counts[index] += 1
|
|
if counts[index] > self._max_records:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"record-limit",
|
|
{"max_records": self._max_records},
|
|
)
|
|
encoded = self._canonicalizer(record)
|
|
if not isinstance(encoded, bytes):
|
|
raise ValueError("canonicalizer must return bytes")
|
|
digest.update(len(encoded).to_bytes(8, "big"))
|
|
digest.update(encoded)
|
|
except Exception:
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"canonicalization-failed",
|
|
{},
|
|
)
|
|
if counts[0] != counts[1] or expected_digest.digest() != actual_digest.digest():
|
|
return VerificationDecision(
|
|
VerificationStatus.REJECTED,
|
|
self.identity,
|
|
"canonical-mismatch",
|
|
{"expected_records": counts[0], "actual_records": counts[1]},
|
|
)
|
|
digest = expected_digest.hexdigest()
|
|
return VerificationDecision(
|
|
VerificationStatus.ACCEPTED,
|
|
self.identity,
|
|
"canonical-match",
|
|
{"records": counts[0]},
|
|
digest,
|
|
)
|