Files
NODEDC_MISSION_CORE/src/k1link/perception/providers.py
T

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