Add game data capture vertical slice
This commit is contained in:
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user