Add game data capture vertical slice

This commit is contained in:
jb
2026-07-31 14:34:24 -04:00
parent 5859f84034
commit fee4dc3a78
75 changed files with 12654 additions and 5 deletions
@@ -0,0 +1,43 @@
"""Side-effect-free domain contracts for the game data capture utility."""
from .state_machines import InvalidStateTransition, StateMachine, require_transition
from .taxonomy import (
ABSTENTION_REASONS,
GEAR_SLOTS,
RECORD_TYPES_BY_VIEW,
SCREEN_VIEWS,
AbstentionReason,
Family,
GearSlot,
RecordType,
)
from .validation import (
ContractValidationError,
ensure_supported_version,
validate_archive_document,
validate_archive_references,
validate_classification,
validate_handoff_document,
validate_handoff_references,
)
__all__ = [
"ABSTENTION_REASONS",
"GEAR_SLOTS",
"RECORD_TYPES_BY_VIEW",
"SCREEN_VIEWS",
"AbstentionReason",
"ContractValidationError",
"Family",
"GearSlot",
"InvalidStateTransition",
"RecordType",
"StateMachine",
"ensure_supported_version",
"require_transition",
"validate_archive_document",
"validate_archive_references",
"validate_classification",
"validate_handoff_document",
"validate_handoff_references",
]
@@ -0,0 +1,118 @@
"""Typed Python views of the portable contract structures."""
import typing
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
type MissingReason = typing.Literal[
"not_visible",
"occluded",
"unreadable",
"not_present_on_screen",
"unsupported",
"ambiguous",
"extraction_failed",
"not_applicable",
"redacted",
]
class SourceEngine(typing.TypedDict):
kind: typing.Literal["local_ocr", "openai_vision", "classifier", "manual", "video_decoder"]
engine_id: str
engine_version: str
class NormalizedRegion(typing.TypedDict):
coordinate_space: typing.Literal["normalized"]
x: float
y: float
width: float
height: float
class PixelRegion(typing.TypedDict):
coordinate_space: typing.Literal["pixels"]
x: int
y: int
width: int
height: int
type BoundingRegion = NormalizedRegion | PixelRegion
class ValueState(typing.TypedDict):
raw_text: str | None
normalized_value: JsonValue
missing_reason: MissingReason | None
class Correction(typing.TypedDict):
correction_id: str
corrected_at: str
actor: typing.Literal["local_user", "system_reprocess"]
reason: str
before: ValueState
after: ValueState
class EvidenceValue(ValueState):
evidence_id: str
field_path: str
confidence: float
source_engine: SourceEngine
image_artifact_id: str
image_sha256: str
region: BoundingRegion
correction_history: list[Correction]
class FieldClaim(typing.TypedDict):
path: str
value: JsonValue
missing_reason: MissingReason | None
evidence_refs: list[str]
uncertainty_note: str | None
class ScreenshotClassification(typing.TypedDict):
family: typing.Literal[
"weapon", "gear", "skill_chip", "os_protocol", "specialization", "unknown"
]
view: str
gear_slot: (
typing.Literal["Mask", "Gloves", "Backpack", "Body Armor", "Holster", "Knee Pads"] | None
)
record_types: list[str]
confidence: float
abstention_reason: str | None
class ObservedContext(typing.TypedDict):
level: FieldClaim
quality: FieldClaim
tier: FieldClaim
upgrade_state: FieldClaim
class ObservedItemInstance(typing.TypedDict):
"""Values shown for one configured item; they are not reusable definitions."""
instance_id: str
family: str
source_screen_ids: list[str]
observed_context: ObservedContext
fields: list[FieldClaim]
class CanonicalDefinitionCandidate(typing.TypedDict):
"""Evidence-backed reusable claims, kept separate from the observed item."""
candidate_id: str
record_type: str
source_instance_ids: list[str]
evidence_refs: list[str]
fields: list[FieldClaim]
completeness: typing.Literal["complete", "incomplete", "unsupported"]
relationship_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
mechanic_status: typing.Literal["evidenced", "unresolved", "unsupported", "not_applicable"]
@@ -0,0 +1,214 @@
"""Trust-boundary ports. Implementations belong to downstream tasks."""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from math import isfinite
from typing import Protocol
@dataclass(frozen=True, slots=True)
class ResourceBudget:
deadline_monotonic: float | None
max_input_bytes: int
max_output_bytes: int
max_items: int
def __post_init__(self) -> None:
limits: tuple[tuple[str, object], ...] = (
("max_input_bytes", self.max_input_bytes),
("max_output_bytes", self.max_output_bytes),
("max_items", self.max_items),
)
for name, value in limits:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer")
deadline: object = self.deadline_monotonic
if deadline is not None:
if isinstance(deadline, bool) or not isinstance(deadline, int | float):
raise ValueError("deadline_monotonic must be a finite number or null")
if not isfinite(deadline):
raise ValueError("deadline_monotonic must be finite or null")
class CancellationToken(Protocol):
def is_cancelled(self) -> bool: ...
def raise_if_cancelled(self) -> None: ...
class Clock(Protocol):
def now_utc(self) -> datetime: ...
def monotonic(self) -> float: ...
class IdGenerator(Protocol):
def new_id(self) -> str: ...
@dataclass(frozen=True, slots=True)
class BlobReceipt:
sha256: str
byte_size: int
media_type: str
class BlobStorage(Protocol):
"""Stores immutable bytes by digest without exposing filesystem paths."""
def put(
self,
chunks: Iterable[bytes],
*,
expected_sha256: str | None,
media_type: str,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> BlobReceipt: ...
def read(
self,
sha256: str,
*,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> Iterable[bytes]: ...
def exists(self, sha256: str) -> bool: ...
class Repository[RecordT](Protocol):
def get(self, record_id: str) -> RecordT | None: ...
def add(self, record: RecordT) -> None: ...
def replace(self, record: RecordT, *, expected_revision: int) -> None: ...
type CaptureRepository = Repository[object]
type JobRepository = Repository[object]
type ExtractionRepository = Repository[object]
type GroupingRepository = Repository[object]
type ReviewRepository = Repository[object]
type ExportRepository = Repository[object]
type ErrorRepository = Repository[object]
class UnitOfWork(Protocol):
captures: CaptureRepository
jobs: JobRepository
extractions: ExtractionRepository
groupings: GroupingRepository
reviews: ReviewRepository
exports: ExportRepository
errors: ErrorRepository
def commit(self) -> None: ...
def rollback(self) -> None: ...
@dataclass(frozen=True, slots=True)
class ImageInput:
artifact_id: str
sha256: str
media_type: str
byte_size: int
@dataclass(frozen=True, slots=True)
class EngineResult:
engine_id: str
engine_version: str
payload: Mapping[str, object]
class OcrEngine(Protocol):
def extract(
self,
image: ImageInput,
*,
regions: Sequence[Mapping[str, int | float | str]],
budget: ResourceBudget,
cancellation: CancellationToken,
) -> EngineResult: ...
class VisionFallback(Protocol):
"""Accepts bounded artifacts and never receives credentials in a request."""
def extract(
self,
images: Sequence[ImageInput],
*,
response_schema: Mapping[str, object],
request_fingerprint: str,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> EngineResult: ...
@dataclass(frozen=True, slots=True)
class SubprocessRequest:
tool: SubprocessTool
arguments: tuple[str, ...]
environment: Mapping[SubprocessEnvironmentKey, str]
stdin: bytes | None
budget: ResourceBudget
class SubprocessTool(StrEnum):
WL_PASTE = "wl-paste"
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
class SubprocessEnvironmentKey(StrEnum):
WAYLAND_DISPLAY = "WAYLAND_DISPLAY"
XDG_RUNTIME_DIR = "XDG_RUNTIME_DIR"
PATH = "PATH"
HOME = "HOME"
LANG = "LANG"
LC_ALL = "LC_ALL"
@dataclass(frozen=True, slots=True)
class SubprocessResult:
return_code: int
stdout: bytes
stderr: bytes
timed_out: bool
cancelled: bool
class SubprocessRunner(Protocol):
"""Runs an argument array without a shell and with an explicit environment."""
def run(
self,
request: SubprocessRequest,
*,
cancellation: CancellationToken,
) -> SubprocessResult: ...
@dataclass(frozen=True, slots=True)
class FrameScore:
timestamp_ms: int
stability: float
sharpness: float
ui_coverage: float
class FrameScorer(Protocol):
def score(
self,
frames: Sequence[ImageInput],
*,
budget: ResourceBudget,
cancellation: CancellationToken,
) -> Sequence[FrameScore]: ...
@@ -0,0 +1,211 @@
"""State vocabularies and allowed transitions for recoverable workflows."""
from collections.abc import Mapping
from dataclasses import dataclass
from enum import StrEnum
from types import MappingProxyType
from typing import Final
class CaptureState(StrEnum):
RECEIVED = "received"
SPOOLING = "spooling"
STORED = "stored"
DEDUPLICATED = "deduplicated"
QUEUED = "queued"
FAILED = "failed"
CANCELLED = "cancelled"
class JobState(StrEnum):
QUEUED = "queued"
RUNNING = "running"
RETRY_WAIT = "retry_wait"
SUCCEEDED = "succeeded"
FAILED = "failed"
CANCELLED = "cancelled"
class ExtractionState(StrEnum):
PENDING = "pending"
LOCAL_PROCESSING = "local_processing"
FALLBACK_PENDING = "fallback_pending"
FALLBACK_PROCESSING = "fallback_processing"
CANDIDATE_READY = "candidate_ready"
ABSTAINED = "abstained"
FAILED = "failed"
CANCELLED = "cancelled"
class GroupingState(StrEnum):
UNGROUPED = "ungrouped"
PROPOSED = "proposed"
GROUPED = "grouped"
SPLIT = "split"
SUPERSEDED = "superseded"
REJECTED = "rejected"
CANCELLED = "cancelled"
class ReviewState(StrEnum):
PENDING = "pending"
IN_REVIEW = "in_review"
CHANGES_REQUESTED = "changes_requested"
APPROVED = "approved"
REJECTED = "rejected"
CANCELLED = "cancelled"
class ExportState(StrEnum):
NOT_REQUESTED = "not_requested"
QUEUED = "queued"
BUILDING = "building"
READY = "ready"
FAILED = "failed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class ErrorState(StrEnum):
OPEN = "open"
RETRYABLE = "retryable"
RETRYING = "retrying"
RECOVERED = "recovered"
TERMINAL = "terminal"
DISMISSED = "dismissed"
@dataclass(frozen=True, slots=True)
class StateMachine:
name: str
transitions: Mapping[str, frozenset[str]]
def allows(self, current: str, target: str) -> bool:
return target in self.transitions.get(current, frozenset())
def _machine(name: str, transitions: dict[str, set[str]]) -> StateMachine:
return StateMachine(
name=name,
transitions=MappingProxyType(
{state: frozenset(targets) for state, targets in transitions.items()}
),
)
STATE_MACHINES: Final[Mapping[str, StateMachine]] = MappingProxyType(
{
"capture": _machine(
"capture",
{
"received": {"spooling", "deduplicated", "failed", "cancelled"},
"spooling": {"stored", "failed", "cancelled"},
"stored": {"queued", "failed"},
"deduplicated": set(),
"queued": set(),
"failed": {"spooling", "cancelled"},
"cancelled": set(),
},
),
"job": _machine(
"job",
{
"queued": {"running", "cancelled"},
"running": {"succeeded", "retry_wait", "failed", "cancelled"},
"retry_wait": {"queued", "failed", "cancelled"},
"succeeded": set(),
"failed": {"queued"},
"cancelled": set(),
},
),
"extraction": _machine(
"extraction",
{
"pending": {"local_processing", "cancelled"},
"local_processing": {
"candidate_ready",
"fallback_pending",
"abstained",
"failed",
"cancelled",
},
"fallback_pending": {"fallback_processing", "cancelled"},
"fallback_processing": {
"candidate_ready",
"abstained",
"failed",
"cancelled",
},
"candidate_ready": set(),
"abstained": set(),
"failed": {"pending"},
"cancelled": set(),
},
),
"grouping": _machine(
"grouping",
{
"ungrouped": {"proposed", "cancelled"},
"proposed": {"grouped", "rejected", "cancelled"},
"grouped": {"split", "superseded"},
"split": {"superseded"},
"superseded": set(),
"rejected": {"ungrouped"},
"cancelled": set(),
},
),
"review": _machine(
"review",
{
"pending": {"in_review", "cancelled"},
"in_review": {
"changes_requested",
"approved",
"rejected",
"cancelled",
},
"changes_requested": {"in_review", "rejected", "cancelled"},
"approved": {"in_review"},
"rejected": {"in_review"},
"cancelled": set(),
},
),
"export": _machine(
"export",
{
"not_requested": {"queued"},
"queued": {"building", "cancelled"},
"building": {"ready", "failed", "cancelled"},
"ready": {"expired"},
"failed": {"queued", "cancelled"},
"cancelled": set(),
"expired": {"queued"},
},
),
"error": _machine(
"error",
{
"open": {"retryable", "terminal", "dismissed"},
"retryable": {"retrying", "terminal", "dismissed"},
"retrying": {"recovered", "retryable", "terminal"},
"recovered": set(),
"terminal": {"dismissed"},
"dismissed": set(),
},
),
}
)
class InvalidStateTransition(ValueError):
"""Raised when a caller attempts a transition absent from the contract."""
def require_transition(machine_name: str, current: str, target: str) -> None:
machine = STATE_MACHINES.get(machine_name)
if machine is None:
raise InvalidStateTransition(f"Unknown state machine: {machine_name}")
if current not in machine.transitions:
raise InvalidStateTransition(f"Unknown {machine_name} state: {current}")
if not machine.allows(current, target):
raise InvalidStateTransition(f"Invalid {machine_name} transition: {current} -> {target}")
@@ -0,0 +1,122 @@
"""Frozen screenshot taxonomy and canonical record vocabulary for contract v1."""
from collections.abc import Mapping
from enum import StrEnum
from types import MappingProxyType
from typing import Final
class Family(StrEnum):
WEAPON = "weapon"
GEAR = "gear"
SKILL_CHIP = "skill_chip"
OS_PROTOCOL = "os_protocol"
SPECIALIZATION = "specialization"
UNKNOWN = "unknown"
class GearSlot(StrEnum):
MASK = "Mask"
GLOVES = "Gloves"
BACKPACK = "Backpack"
BODY_ARMOR = "Body Armor"
HOLSTER = "Holster"
KNEE_PADS = "Knee Pads"
class AbstentionReason(StrEnum):
LOW_CONFIDENCE = "low_confidence"
AMBIGUOUS_FAMILY = "ambiguous_family"
AMBIGUOUS_VIEW = "ambiguous_view"
UNSUPPORTED_FAMILY = "unsupported_family"
UNSUPPORTED_VIEW = "unsupported_view"
INSUFFICIENT_VISUAL_EVIDENCE = "insufficient_visual_evidence"
OCCLUDED = "occluded"
CORRUPT_IMAGE = "corrupt_image"
RESOURCE_LIMIT = "resource_limit"
CANCELLED = "cancelled"
class RecordType(StrEnum):
WEAPON = "weapon"
WEAPON_TALENT = "weapon_talent"
WEAPON_AUGMENTATION_PROFILE = "weapon_augmentation_profile"
GEAR = "gear"
BRAND_SET = "brand_set"
GEAR_TALENT = "gear_talent"
GEAR_AUGMENTATION_PROFILE = "gear_augmentation_profile"
SKILL_CHIP_SET = "skill_chip_set"
SKILL_CHIP_SET_BONUS = "skill_chip_set_bonus"
SKILL_CHIP_ROLL_POOL = "skill_chip_roll_pool"
SKILL_CHIP_TALENT = "skill_chip_talent"
OS_PROTOCOL = "os_protocol"
OS_PROTOCOL_SECONDARY_PROFILE = "os_protocol_secondary_profile"
OS_PROTOCOL_TALENT = "os_protocol_talent"
SPECIALIZATION = "specialization"
SPECIALIZATION_FOCUS = "specialization_focus"
SPECIALIZATION_ABILITY = "specialization_ability"
SPECIALIZATION_TREE_NODE = "specialization_tree_node"
SPECIALIZATION_TREE_RANK = "specialization_tree_rank"
SCREEN_VIEWS: Final[Mapping[Family, tuple[str, ...]]] = MappingProxyType(
{
Family.WEAPON: (
"detail",
"blueprint",
"crafting",
"stat_range",
"talent_grid",
"augmentation",
),
Family.GEAR: ("detail", "blueprint", "talent", "augmentation"),
Family.SKILL_CHIP: ("detail", "set_bonus", "talent"),
Family.OS_PROTOCOL: ("detail", "secondary_profile", "talent"),
Family.SPECIALIZATION: (
"overview",
"details",
"ability",
"focus_details",
"focus_tree",
"rank_details",
),
Family.UNKNOWN: ("unknown",),
}
)
GEAR_SLOTS: Final[tuple[str, ...]] = tuple(slot.value for slot in GearSlot)
ABSTENTION_REASONS: Final[tuple[str, ...]] = tuple(reason.value for reason in AbstentionReason)
RECORD_TYPES_BY_VIEW: Final[Mapping[tuple[Family, str], tuple[RecordType, ...]]] = MappingProxyType(
{
(Family.WEAPON, "detail"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
(Family.WEAPON, "blueprint"): (RecordType.WEAPON, RecordType.WEAPON_TALENT),
(Family.WEAPON, "crafting"): (RecordType.WEAPON,),
(Family.WEAPON, "stat_range"): (RecordType.WEAPON,),
(Family.WEAPON, "talent_grid"): (RecordType.WEAPON_TALENT,),
(Family.WEAPON, "augmentation"): (RecordType.WEAPON_AUGMENTATION_PROFILE,),
(Family.GEAR, "detail"): (RecordType.GEAR, RecordType.BRAND_SET),
(Family.GEAR, "blueprint"): (RecordType.GEAR, RecordType.BRAND_SET),
(Family.GEAR, "talent"): (RecordType.GEAR_TALENT,),
(Family.GEAR, "augmentation"): (RecordType.GEAR_AUGMENTATION_PROFILE,),
(Family.SKILL_CHIP, "detail"): (
RecordType.SKILL_CHIP_SET,
RecordType.SKILL_CHIP_ROLL_POOL,
),
(Family.SKILL_CHIP, "set_bonus"): (RecordType.SKILL_CHIP_SET_BONUS,),
(Family.SKILL_CHIP, "talent"): (RecordType.SKILL_CHIP_TALENT,),
(Family.OS_PROTOCOL, "detail"): (RecordType.OS_PROTOCOL,),
(Family.OS_PROTOCOL, "secondary_profile"): (RecordType.OS_PROTOCOL_SECONDARY_PROFILE,),
(Family.OS_PROTOCOL, "talent"): (RecordType.OS_PROTOCOL_TALENT,),
(Family.SPECIALIZATION, "overview"): (RecordType.SPECIALIZATION,),
(Family.SPECIALIZATION, "details"): (RecordType.SPECIALIZATION,),
(Family.SPECIALIZATION, "ability"): (RecordType.SPECIALIZATION_ABILITY,),
(Family.SPECIALIZATION, "focus_details"): (RecordType.SPECIALIZATION_FOCUS,),
(Family.SPECIALIZATION, "focus_tree"): (
RecordType.SPECIALIZATION_FOCUS,
RecordType.SPECIALIZATION_TREE_NODE,
),
(Family.SPECIALIZATION, "rank_details"): (RecordType.SPECIALIZATION_TREE_RANK,),
(Family.UNKNOWN, "unknown"): (),
}
)
@@ -0,0 +1,384 @@
"""Semantic checks that portable JSON Schema cannot express."""
from collections.abc import Iterable, Mapping
from datetime import datetime
from . import taxonomy
SUPPORTED_ARCHIVE_SCHEMA_VERSION = 1
SUPPORTED_HANDOFF_SCHEMA_VERSION = 1
class ContractValidationError(ValueError):
"""Raised when a document violates a version or referential contract."""
def ensure_supported_version(document: Mapping[str, object], expected: int) -> None:
version = document.get("schema_version")
if type(version) is not int or version != expected:
raise ContractValidationError(
f"Unsupported schema_version {version!r}; expected {expected}"
)
def _objects(value: object, field: str) -> Iterable[Mapping[str, object]]:
if not isinstance(value, list):
raise ContractValidationError(f"{field} must be an array")
for index, item in enumerate(value):
if not isinstance(item, Mapping):
raise ContractValidationError(f"{field}[{index}] must be an object")
yield item
def _ids(items: Iterable[Mapping[str, object]], field: str) -> set[str]:
result: set[str] = set()
for item in items:
value = item.get(field)
if not isinstance(value, str):
raise ContractValidationError(f"{field} must be a string")
if value in result:
raise ContractValidationError(f"Duplicate {field}: {value}")
result.add(value)
return result
def _references(item: Mapping[str, object], field: str) -> set[str]:
raw = item.get(field, [])
if not isinstance(raw, list) or any(not isinstance(value, str) for value in raw):
raise ContractValidationError(f"{field} must contain string IDs")
return set(raw)
def _require_subset(values: set[str], allowed: set[str], context: str) -> None:
dangling = values - allowed
if dangling:
raise ContractValidationError(f"{context} contains dangling references: {sorted(dangling)}")
def validate_classification(classification: Mapping[str, object]) -> None:
family_raw = classification.get("family")
if not isinstance(family_raw, str):
raise ContractValidationError("Unknown classification family")
try:
family = taxonomy.Family(family_raw)
except ValueError as error:
raise ContractValidationError("Unknown classification family") from error
view = classification.get("view")
if not isinstance(view, str) or view not in taxonomy.SCREEN_VIEWS[family]:
raise ContractValidationError(f"Unknown {family.value} view: {view!r}")
confidence = classification.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Classification confidence must be between 0 and 1")
gear_slot = classification.get("gear_slot")
if family is taxonomy.Family.GEAR:
if gear_slot not in taxonomy.GEAR_SLOTS:
raise ContractValidationError(f"Unknown Gear slot: {gear_slot!r}")
elif gear_slot is not None:
raise ContractValidationError("Only Gear classifications may set gear_slot")
record_types = classification.get("record_types")
if not isinstance(record_types, list) or any(
not isinstance(record_type, str) for record_type in record_types
):
raise ContractValidationError("record_types must contain strings")
if len(record_types) != len(set(record_types)):
raise ContractValidationError("record_types must be unique")
allowed = {record_type.value for record_type in taxonomy.RECORD_TYPES_BY_VIEW[(family, view)]}
if family is taxonomy.Family.UNKNOWN:
if record_types:
raise ContractValidationError("Unknown screens cannot claim canonical record types")
reason = classification.get("abstention_reason")
if reason not in taxonomy.ABSTENTION_REASONS:
raise ContractValidationError("Unknown screens require an abstention reason")
else:
if not record_types or not set(record_types).issubset(allowed):
raise ContractValidationError(
f"record_types are incompatible with {family.value}/{view}"
)
if classification.get("abstention_reason") is not None:
raise ContractValidationError("Known screens cannot set abstention_reason")
def _validate_evidence(value: Mapping[str, object]) -> None:
_validate_value_state(value, "evidence")
confidence = value.get("confidence")
if (
isinstance(confidence, bool)
or not isinstance(confidence, int | float)
or not 0 <= confidence <= 1
):
raise ContractValidationError("Evidence confidence must be between 0 and 1")
region = value.get("region")
if not isinstance(region, Mapping):
raise ContractValidationError("Evidence region must be an object")
coordinate_space = region.get("coordinate_space")
numeric_coordinates: list[int | float] = []
for name in ("x", "y", "width", "height"):
number = region.get(name)
if isinstance(number, bool) or not isinstance(number, int | float):
raise ContractValidationError("Evidence bounds must be numeric")
numeric_coordinates.append(number)
x, y, width, height = numeric_coordinates
if coordinate_space == "normalized":
if x < 0 or y < 0 or width <= 0 or height <= 0 or x + width > 1 or y + height > 1:
raise ContractValidationError("Normalized evidence bounds must fit within the image")
elif coordinate_space == "pixels":
if any(not isinstance(number, int) for number in numeric_coordinates):
raise ContractValidationError("Pixel evidence bounds must be integers")
if x < 0 or y < 0 or width <= 0 or height <= 0:
raise ContractValidationError("Pixel evidence bounds must be positive and non-negative")
else:
raise ContractValidationError(f"Unknown coordinate space: {coordinate_space!r}")
def _validate_value_state(value: Mapping[str, object], context: str) -> None:
raw_text = value.get("raw_text")
normalized = value.get("normalized_value")
missing_reason = value.get("missing_reason")
if raw_text is not None and not isinstance(raw_text, str):
raise ContractValidationError(f"{context} raw_text must be a string or null")
if normalized is None and not isinstance(missing_reason, str):
raise ContractValidationError(f"Null {context} requires missing_reason")
if normalized is not None and missing_reason is not None:
raise ContractValidationError(f"Present {context} cannot set missing_reason")
def _value_state(value: Mapping[str, object], context: str) -> tuple[object, object, object]:
_validate_value_state(value, context)
return value.get("raw_text"), value.get("normalized_value"), value.get("missing_reason")
def _parse_correction_time(value: object) -> datetime:
if not isinstance(value, str):
raise ContractValidationError("Correction corrected_at must be a timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ContractValidationError("Correction corrected_at must be a timestamp") from error
if parsed.tzinfo is None:
raise ContractValidationError("Correction corrected_at must include a UTC offset")
return parsed
def _validate_correction_history(
evidence: Mapping[str, object], seen_correction_ids: set[str]
) -> None:
history = evidence.get("correction_history")
if not isinstance(history, list):
raise ContractValidationError("correction_history must be an array")
previous_after: tuple[object, object, object] | None = None
previous_time: datetime | None = None
for index, correction in enumerate(history):
if not isinstance(correction, Mapping):
raise ContractValidationError(f"correction_history[{index}] must be an object")
correction_id = correction.get("correction_id")
if not isinstance(correction_id, str):
raise ContractValidationError("correction_id must be a string")
if correction_id in seen_correction_ids:
raise ContractValidationError(f"Duplicate correction_id: {correction_id}")
seen_correction_ids.add(correction_id)
corrected_at = _parse_correction_time(correction.get("corrected_at"))
if previous_time is not None and corrected_at <= previous_time:
raise ContractValidationError("Correction history timestamps must be strictly ordered")
before = correction.get("before")
after = correction.get("after")
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
raise ContractValidationError("Correction before and after must be value states")
before_state = _value_state(before, "correction before")
after_state = _value_state(after, "correction after")
if previous_after is not None and before_state != previous_after:
raise ContractValidationError("Correction history contains a disconnected value state")
previous_after = after_state
previous_time = corrected_at
if previous_after is not None and previous_after != _value_state(evidence, "evidence"):
raise ContractValidationError("Correction history final state does not match evidence")
def validate_archive_document(document: Mapping[str, object]) -> None:
validate_archive_references(document)
for screen in _objects(document.get("screens"), "screens"):
classification = screen.get("classification")
if not isinstance(classification, Mapping):
raise ContractValidationError("screen.classification must be an object")
validate_classification(classification)
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_handoff_document(document: Mapping[str, object]) -> None:
validate_handoff_references(document)
expected_boundary: dict[str, object] = {
"intended_destination": "authenticated_isac_intake",
"mutation_authority": "none",
"requires_authenticated_action": True,
"preserves_moderation": True,
"creates_audit_events": False,
}
if document.get("trust_boundary") != expected_boundary:
raise ContractValidationError("The handoff trust boundary is invalid")
seen_correction_ids: set[str] = set()
for value in _objects(document.get("evidence_values"), "evidence_values"):
_validate_evidence(value)
_validate_correction_history(value, seen_correction_ids)
def validate_archive_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_ARCHIVE_SCHEMA_VERSION)
artifacts = list(_objects(document.get("artifacts"), "artifacts"))
captures = list(_objects(document.get("captures"), "captures"))
screens = list(_objects(document.get("screens"), "screens"))
jobs = list(_objects(document.get("jobs"), "jobs"))
extractions = list(_objects(document.get("extractions"), "extractions"))
groups = list(_objects(document.get("groups"), "groups"))
reviews = list(_objects(document.get("reviews"), "reviews"))
exports = list(_objects(document.get("exports"), "exports"))
errors = list(_objects(document.get("errors"), "errors"))
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
instances = list(_objects(document.get("observed_instances"), "observed_instances"))
candidates = list(_objects(document.get("canonical_candidates"), "canonical_candidates"))
artifact_ids = _ids(artifacts, "artifact_id")
_ids(captures, "capture_id")
screen_ids = _ids(screens, "screen_id")
_ids(jobs, "job_id")
_ids(extractions, "extraction_id")
group_ids = _ids(groups, "group_id")
_ids(reviews, "review_id")
_ids(exports, "export_id")
error_ids = _ids(errors, "error_id")
evidence_ids = _ids(evidence, "evidence_id")
instance_ids = _ids(instances, "instance_id")
candidate_ids = _ids(candidates, "candidate_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
for capture in captures:
_require_subset({str(capture.get("artifact_id"))}, artifact_ids, "capture.artifact_id")
for artifact in artifacts:
parent_id = artifact.get("parent_artifact_id")
if parent_id is not None:
if str(parent_id) == str(artifact.get("artifact_id")):
raise ContractValidationError("artifact.parent_artifact_id cannot reference itself")
_require_subset({str(parent_id)}, artifact_ids, "artifact.parent_artifact_id")
for screen in screens:
_require_subset({str(screen.get("artifact_id"))}, artifact_ids, "screen.artifact_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for job in jobs:
last_error_id = job.get("last_error_id")
if last_error_id is not None:
_require_subset({str(last_error_id)}, error_ids, "job.last_error_id")
for extraction in extractions:
_require_subset({str(extraction.get("screen_id"))}, screen_ids, "extraction.screen_id")
_require_subset(
_references(extraction, "evidence_refs"),
evidence_ids,
"extraction.evidence_refs",
)
for group in groups:
_require_subset(_references(group, "screen_ids"), screen_ids, "group.screen_ids")
supersedes_group_ids = _references(group, "supersedes_group_ids")
if str(group.get("group_id")) in supersedes_group_ids:
raise ContractValidationError("group.supersedes_group_ids cannot reference itself")
_require_subset(
supersedes_group_ids,
group_ids,
"group.supersedes_group_ids",
)
for instance in instances:
_require_subset(
_references(instance, "source_screen_ids"),
screen_ids,
"observed_instance.source_screen_ids",
)
for field in _objects(instance.get("fields"), "observed_instance.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"observed_instance.fields.evidence_refs",
)
context = instance.get("observed_context")
if not isinstance(context, Mapping):
raise ContractValidationError("observed_instance.observed_context must be an object")
for context_name in ("level", "quality", "tier", "upgrade_state"):
context_field = context.get(context_name)
if not isinstance(context_field, Mapping):
raise ContractValidationError(f"observed_context.{context_name} must be an object")
_require_subset(
_references(context_field, "evidence_refs"),
evidence_ids,
f"observed_context.{context_name}.evidence_refs",
)
for candidate in candidates:
_require_subset(
_references(candidate, "source_instance_ids"),
instance_ids,
"canonical_candidate.source_instance_ids",
)
_require_subset(
_references(candidate, "evidence_refs"),
evidence_ids,
"canonical_candidate.evidence_refs",
)
for field in _objects(candidate.get("fields"), "canonical_candidate.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"canonical_candidate.fields.evidence_refs",
)
for review in reviews:
_require_subset({str(review.get("candidate_id"))}, candidate_ids, "review.candidate_id")
for export in exports:
export_artifact_id = export.get("artifact_id")
if export_artifact_id is not None:
_require_subset({str(export_artifact_id)}, artifact_ids, "export.artifact_id")
def validate_handoff_references(document: Mapping[str, object]) -> None:
ensure_supported_version(document, SUPPORTED_HANDOFF_SCHEMA_VERSION)
evidence = list(_objects(document.get("evidence_values"), "evidence_values"))
records = list(_objects(document.get("records"), "records"))
evidence_ids = _ids(evidence, "evidence_id")
artifacts = list(_objects(document.get("source_artifacts"), "source_artifacts"))
artifact_ids = _ids(artifacts, "artifact_id")
artifact_hashes = {
str(artifact.get("artifact_id")): str(artifact.get("sha256")) for artifact in artifacts
}
_ids(records, "handoff_record_id")
for value in evidence:
artifact_id = str(value.get("image_artifact_id"))
_require_subset(
{artifact_id},
artifact_ids,
"evidence.image_artifact_id",
)
if artifact_hashes.get(artifact_id) != value.get("image_sha256"):
raise ContractValidationError("Evidence image_sha256 does not match its artifact")
for record in records:
for field in _objects(record.get("fields"), "record.fields"):
_require_subset(
_references(field, "evidence_refs"),
evidence_ids,
"record.fields.evidence_refs",
)
for section_name in ("relationships", "mechanics"):
section = record.get(section_name)
if not isinstance(section, Mapping):
raise ContractValidationError(f"record.{section_name} must be an object")
_require_subset(
_references(section, "evidence_refs"),
evidence_ids,
f"record.{section_name}.evidence_refs",
)