"""Provider protocols and one versioned graph configuration contract.""" from __future__ import annotations import re from collections.abc import Iterator from dataclasses import dataclass from enum import StrEnum from threading import Event from typing import Final, Protocol from .contracts import ( LocalObstacleMap, ObjectProposal2D, ObstacleObservation, SourceEnvelope, TemporalObstacle, ThreatAssessment, ) REFERENCE_GRAPH_CONFIG_SCHEMA: Final = "missioncore.reference-perception-graph-config/v1" REFERENCE_GRAPH_CONFIG_SCHEMA_V2: Final = "missioncore.reference-perception-graph-config/v2" REFERENCE_GRAPH_STAGE_IDS: Final = frozenset({"detector", "geometry", "temporal", "threat"}) REFERENCE_GRAPH_STAGE_IDS_V2: Final = frozenset( {"detector", "geometry", "temporal", "rolling", "threat"} ) _SHA256 = re.compile(r"^[a-f0-9]{64}$") _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$") class ProviderContractError(ValueError): """A provider pin or graph execution policy is ambiguous or unsafe.""" class ProviderRole(StrEnum): SOURCE = "source" DETECTOR = "detector" GEOMETRY = "geometry" TEMPORAL = "temporal" MOTION = "motion" ROLLING = "rolling" THREAT = "threat" REFERENCE_GRAPH_PROVIDER_ROLES: Final = frozenset( { ProviderRole.SOURCE, ProviderRole.DETECTOR, ProviderRole.GEOMETRY, ProviderRole.TEMPORAL, ProviderRole.MOTION, ProviderRole.THREAT, } ) REFERENCE_GRAPH_PROVIDER_ROLES_V2: Final = frozenset(ProviderRole) @dataclass(frozen=True, slots=True) class SourcePacket: """Execution-only carrier; the graph never interprets opaque sensor payloads.""" envelope: SourceEnvelope image_payload: object | None registered_point_increment_payload: object | None pose_payload: object | None def __post_init__(self) -> None: bindings = ( (self.envelope.image.available, self.image_payload, "image"), ( self.envelope.registered_point_increment.available, self.registered_point_increment_payload, "registered point increment", ), (self.envelope.pose.available, self.pose_payload, "pose"), ) for available, payload, label in bindings: if available is not (payload is not None): raise ProviderContractError(f"{label} availability and payload disagree") @dataclass(frozen=True, slots=True) class ProviderPin: role: ProviderRole provider_id: str version: str revision: str sha256: str def __post_init__(self) -> None: for value, label in ( (self.provider_id, "provider id"), (self.version, "provider version"), (self.revision, "provider revision"), ): _identifier(value, label) _digest(self.sha256, "provider digest") def to_dict(self) -> dict[str, object]: return { "role": self.role.value, "provider_id": self.provider_id, "version": self.version, "revision": self.revision, "sha256": self.sha256, } @classmethod def from_dict(cls, value: object) -> ProviderPin: document = _object(value, "provider pin") _exact_keys( document, {"role", "provider_id", "version", "revision", "sha256"}, "provider pin", ) return cls( role=_enum(ProviderRole, document.get("role"), "provider role"), provider_id=_string(document, "provider_id"), version=_string(document, "version"), revision=_string(document, "revision"), sha256=_string(document, "sha256"), ) @dataclass(frozen=True, slots=True) class QueuePolicy: stage_id: str capacity: int deadline_ns: int terminal_timeout_ns: int def __post_init__(self) -> None: _identifier(self.stage_id, "queue stage id") _positive_integer(self.capacity, "queue capacity") _positive_integer(self.deadline_ns, "queue deadline") _positive_integer(self.terminal_timeout_ns, "terminal timeout") if self.terminal_timeout_ns < self.deadline_ns: raise ProviderContractError("terminal timeout cannot precede stage deadline") def to_dict(self) -> dict[str, object]: return { "stage_id": self.stage_id, "capacity": self.capacity, "deadline_ns": self.deadline_ns, "terminal_timeout_ns": self.terminal_timeout_ns, } @classmethod def from_dict(cls, value: object) -> QueuePolicy: document = _object(value, "queue policy") _exact_keys( document, {"stage_id", "capacity", "deadline_ns", "terminal_timeout_ns"}, "queue policy", ) return cls( stage_id=_string(document, "stage_id"), capacity=_integer(document, "capacity"), deadline_ns=_integer(document, "deadline_ns"), terminal_timeout_ns=_integer(document, "terminal_timeout_ns"), ) @dataclass(frozen=True, slots=True) class GraphAuthority: mode: str = "replay-simulated" physical_live: bool = False commands_enabled: bool = False actuation_allowed: bool = False navigation_or_safety_accepted: bool = False def __post_init__(self) -> None: if self.mode != "replay-simulated": raise ProviderContractError("M4 graph authority must be replay-simulated") if any( ( self.physical_live, self.commands_enabled, self.actuation_allowed, self.navigation_or_safety_accepted, ) ): raise ProviderContractError("M4 graph cannot publish physical or command authority") def to_dict(self) -> dict[str, object]: return { "mode": self.mode, "physical_live": self.physical_live, "commands_enabled": self.commands_enabled, "actuation_allowed": self.actuation_allowed, "navigation_or_safety_accepted": self.navigation_or_safety_accepted, } @classmethod def from_dict(cls, value: object) -> GraphAuthority: document = _object(value, "graph authority") _exact_keys( document, { "mode", "physical_live", "commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", }, "graph authority", ) return cls( mode=_string(document, "mode"), physical_live=_boolean(document, "physical_live"), commands_enabled=_boolean(document, "commands_enabled"), actuation_allowed=_boolean(document, "actuation_allowed"), navigation_or_safety_accepted=_boolean( document, "navigation_or_safety_accepted", ), ) @dataclass(frozen=True, slots=True) class ReferencePerceptionGraphConfig: graph_id: str source_profile_id: str providers: tuple[ProviderPin, ...] queues: tuple[QueuePolicy, ...] authority: GraphAuthority = GraphAuthority() def __post_init__(self) -> None: _identifier(self.graph_id, "graph id") _identifier(self.source_profile_id, "source profile id") roles = [provider.role for provider in self.providers] if len(set(roles)) != len(roles) or set(roles) != REFERENCE_GRAPH_PROVIDER_ROLES: raise ProviderContractError("graph must pin each provider role exactly once") stage_ids = [queue.stage_id for queue in self.queues] if len(set(stage_ids)) != len(stage_ids): raise ProviderContractError("graph queue policies must be unique") if set(stage_ids) != REFERENCE_GRAPH_STAGE_IDS: raise ProviderContractError("graph must bound each reference stage exactly once") def to_dict(self) -> dict[str, object]: return { "schema_version": REFERENCE_GRAPH_CONFIG_SCHEMA, "graph_id": self.graph_id, "source_profile_id": self.source_profile_id, "providers": [provider.to_dict() for provider in self.providers], "queues": [queue.to_dict() for queue in self.queues], "authority": self.authority.to_dict(), } @classmethod def from_dict(cls, value: object) -> ReferencePerceptionGraphConfig: document = _object(value, "reference graph config") _exact_keys( document, {"schema_version", "graph_id", "source_profile_id", "providers", "queues", "authority"}, "reference graph config", ) if document.get("schema_version") != REFERENCE_GRAPH_CONFIG_SCHEMA: raise ProviderContractError("reference graph config schema is incompatible") return cls( graph_id=_string(document, "graph_id"), source_profile_id=_string(document, "source_profile_id"), providers=tuple(ProviderPin.from_dict(item) for item in _array(document, "providers")), queues=tuple(QueuePolicy.from_dict(item) for item in _array(document, "queues")), authority=GraphAuthority.from_dict(document.get("authority")), ) @dataclass(frozen=True, slots=True) class ReferencePerceptionGraphConfigV2: """Final M4 graph contract with explicit retained rolling occupancy.""" graph_id: str source_profile_id: str providers: tuple[ProviderPin, ...] queues: tuple[QueuePolicy, ...] authority: GraphAuthority = GraphAuthority() def __post_init__(self) -> None: _identifier(self.graph_id, "graph id") _identifier(self.source_profile_id, "source profile id") roles = [provider.role for provider in self.providers] if len(set(roles)) != len(roles) or set(roles) != REFERENCE_GRAPH_PROVIDER_ROLES_V2: raise ProviderContractError("graph v2 must pin each provider role exactly once") stage_ids = [queue.stage_id for queue in self.queues] if len(set(stage_ids)) != len(stage_ids): raise ProviderContractError("graph v2 queue policies must be unique") if set(stage_ids) != REFERENCE_GRAPH_STAGE_IDS_V2: raise ProviderContractError("graph v2 must bound each reference stage exactly once") def to_dict(self) -> dict[str, object]: return { "schema_version": REFERENCE_GRAPH_CONFIG_SCHEMA_V2, "graph_id": self.graph_id, "source_profile_id": self.source_profile_id, "providers": [provider.to_dict() for provider in self.providers], "queues": [queue.to_dict() for queue in self.queues], "authority": self.authority.to_dict(), } @classmethod def from_dict(cls, value: object) -> ReferencePerceptionGraphConfigV2: document = _object(value, "reference graph v2 config") _exact_keys( document, {"schema_version", "graph_id", "source_profile_id", "providers", "queues", "authority"}, "reference graph v2 config", ) if document.get("schema_version") != REFERENCE_GRAPH_CONFIG_SCHEMA_V2: raise ProviderContractError("reference graph v2 config schema is incompatible") return cls( graph_id=_string(document, "graph_id"), source_profile_id=_string(document, "source_profile_id"), providers=tuple(ProviderPin.from_dict(item) for item in _array(document, "providers")), queues=tuple(QueuePolicy.from_dict(item) for item in _array(document, "queues")), authority=GraphAuthority.from_dict(document.get("authority")), ) class SourceProvider(Protocol): provider_id: str def packets(self, stop_event: Event) -> Iterator[SourcePacket]: ... class DetectorProvider(Protocol): provider_id: str def detect(self, packet: SourcePacket) -> tuple[ObjectProposal2D, ...]: ... class GeometryAssociationProvider(Protocol): provider_id: str def associate( self, packet: SourcePacket, proposals: tuple[ObjectProposal2D, ...], ) -> tuple[ObstacleObservation, ...]: ... class TemporalStateProvider(Protocol): provider_id: str def update( self, packet: SourcePacket, observations: tuple[ObstacleObservation, ...], ) -> tuple[TemporalObstacle, ...]: ... class MotionProvider(Protocol): provider_id: str def estimate( self, packet: SourcePacket, obstacles: tuple[TemporalObstacle, ...], ) -> tuple[TemporalObstacle, ...]: ... class RollingMapProvider(Protocol): provider_id: str def update( self, packet: SourcePacket, obstacles: tuple[TemporalObstacle, ...], ) -> tuple[TemporalObstacle, ...]: ... class ThreatProvider(Protocol): provider_id: str def assess(self, obstacle_map: LocalObstacleMap) -> tuple[ThreatAssessment, ...]: ... def _object(value: object, label: str) -> dict[str, object]: if not isinstance(value, dict) or any(not isinstance(key, str) for key in value): raise ProviderContractError(f"{label} must be an object") return value def _exact_keys(document: dict[str, object], expected: set[str], label: str) -> None: if set(document) != expected: raise ProviderContractError(f"{label} fields are incompatible") def _array(document: dict[str, object], key: str) -> list[object]: value = document.get(key) if not isinstance(value, list): raise ProviderContractError(f"{key} must be an array") return value def _string(document: dict[str, object], key: str) -> str: value = document.get(key) if not isinstance(value, str) or not value: raise ProviderContractError(f"{key} must be a nonempty string") return value def _boolean(document: dict[str, object], key: str) -> bool: value = document.get(key) if not isinstance(value, bool): raise ProviderContractError(f"{key} must be boolean") return value def _integer(document: dict[str, object], key: str) -> int: value = document.get(key) if not isinstance(value, int) or isinstance(value, bool): raise ProviderContractError(f"{key} must be an integer") return value def _positive_integer(value: object, label: str) -> int: if not isinstance(value, int) or isinstance(value, bool) or value < 1: raise ProviderContractError(f"{label} must be positive") return value def _identifier(value: str, label: str) -> str: if _IDENTIFIER.fullmatch(value) is None: raise ProviderContractError(f"{label} is not a safe identifier") return value def _digest(value: str, label: str) -> str: if _SHA256.fullmatch(value) is None: raise ProviderContractError(f"{label} must be a SHA-256 digest") return value def _enum(enum_type: type[ProviderRole], value: object, label: str) -> ProviderRole: if not isinstance(value, str): raise ProviderContractError(f"{label} must be a string") try: return enum_type(value) except ValueError as exc: raise ProviderContractError(f"{label} is incompatible") from exc