feat(perception): establish object centric contracts

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 13:32:34 +03:00
parent 8ef215e98f
commit ae1e41f7fe
8 changed files with 2665 additions and 0 deletions
+334
View File
@@ -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