feat(perception): establish object centric contracts
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""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 typing import Final, Protocol
|
||||
|
||||
from .contracts import (
|
||||
LocalObstacleMap,
|
||||
ObjectProposal2D,
|
||||
ObstacleObservation,
|
||||
SourceEnvelope,
|
||||
TemporalObstacle,
|
||||
ThreatAssessment,
|
||||
)
|
||||
|
||||
REFERENCE_GRAPH_CONFIG_SCHEMA: Final = "missioncore.reference-perception-graph-config/v1"
|
||||
_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"
|
||||
THREAT = "threat"
|
||||
|
||||
|
||||
@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) != set(ProviderRole):
|
||||
raise ProviderContractError("graph must pin each provider role exactly once")
|
||||
stage_ids = [queue.stage_id for queue in self.queues]
|
||||
if not stage_ids or len(set(stage_ids)) != len(stage_ids):
|
||||
raise ProviderContractError("graph queue policies must be nonempty and unique")
|
||||
|
||||
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")),
|
||||
)
|
||||
|
||||
|
||||
class SourceProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def envelopes(self) -> Iterator[SourceEnvelope]: ...
|
||||
|
||||
|
||||
class DetectorProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def detect(self, envelope: SourceEnvelope) -> tuple[ObjectProposal2D, ...]: ...
|
||||
|
||||
|
||||
class GeometryAssociationProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def associate(
|
||||
self,
|
||||
envelope: SourceEnvelope,
|
||||
proposals: tuple[ObjectProposal2D, ...],
|
||||
) -> tuple[ObstacleObservation, ...]: ...
|
||||
|
||||
|
||||
class TemporalStateProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def update(
|
||||
self,
|
||||
envelope: SourceEnvelope,
|
||||
observations: tuple[ObstacleObservation, ...],
|
||||
) -> tuple[TemporalObstacle, ...]: ...
|
||||
|
||||
|
||||
class MotionProvider(Protocol):
|
||||
provider_id: str
|
||||
|
||||
def estimate(
|
||||
self,
|
||||
envelope: SourceEnvelope,
|
||||
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
|
||||
Reference in New Issue
Block a user