442 lines
15 KiB
Python
442 lines
15 KiB
Python
"""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
|