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

962 lines
35 KiB
Python

"""Persistent bounded reference graph for object-centric recorded-realtime CV."""
from __future__ import annotations
import time
from collections.abc import Callable
from contextlib import AbstractContextManager
from dataclasses import dataclass, replace
from queue import Empty, Full, Queue
from threading import Event, Lock, Thread
from typing import TYPE_CHECKING, TypeVar, cast
if TYPE_CHECKING:
from k1link.compute.pipeline_telemetry import (
PipelineStageOutcome,
PipelineTelemetryEmitter,
PipelineTelemetryIdentity,
PipelineTelemetrySink,
)
from .baseline import BASELINE_PROFILE_ID, BASELINE_SOURCE_ID
from .contracts import (
LocalObstacleMap,
ObjectProposal2D,
ObstacleObservation,
SourceAccounting,
SourceEnvelope,
TemporalObstacle,
TemporalState,
validate_exclusive_point_ownership,
)
from .graph_contracts import (
GRAPH_RESULT_SCHEMA,
GRAPH_RESULT_SCHEMA_V2,
REFERENCE_GRAPH_ID,
REFERENCE_GRAPH_ID_V2,
TERMINAL_OUTCOME_SCHEMA,
DeliveredFrame,
GraphExecutionError,
GraphRunMode,
GraphRunResult,
GraphRunResultV2,
GraphState,
TerminalOutcome,
TerminalOutcomeType,
build_graph_run_result,
build_graph_run_result_v2,
)
from .graph_validation import (
validate_observations,
validate_proposals,
validate_rolling,
validate_temporal,
validate_threats,
)
from .providers import (
DetectorProvider,
GeometryAssociationProvider,
MotionProvider,
ProviderRole,
ReferencePerceptionGraphConfig,
ReferencePerceptionGraphConfigV2,
RollingMapProvider,
SourcePacket,
SourceProvider,
TemporalStateProvider,
ThreatProvider,
)
@dataclass(frozen=True, slots=True)
class _Detected:
packet: SourcePacket
proposals: tuple[ObjectProposal2D, ...]
@dataclass(frozen=True, slots=True)
class _Associated:
packet: SourcePacket
proposals: tuple[ObjectProposal2D, ...]
observations: tuple[ObstacleObservation, ...]
@dataclass(frozen=True, slots=True)
class _Temporal:
packet: SourcePacket
proposals: tuple[ObjectProposal2D, ...]
associated_proposal_ids: frozenset[str]
obstacles: tuple[TemporalObstacle, ...]
@dataclass(frozen=True, slots=True)
class _Rolled:
packet: SourcePacket
proposals: tuple[ObjectProposal2D, ...]
associated_proposal_ids: frozenset[str]
obstacles: tuple[TemporalObstacle, ...]
retained: tuple[TemporalObstacle, ...]
@dataclass(frozen=True, slots=True)
class _StopSignal:
pass
_STOP = _StopSignal()
_DetectorItem = SourcePacket | _StopSignal
_GeometryItem = _Detected | _StopSignal
_TemporalItem = _Associated | _StopSignal
_RollingItem = _Temporal | _StopSignal
_ThreatItem = _Temporal | _Rolled | _StopSignal
_QueueItem = SourcePacket | _Detected | _Associated | _Temporal | _Rolled | _StopSignal
_QueueItemT = TypeVar("_QueueItemT", bound=_QueueItem)
DeliveryEvidenceObserver = Callable[
[DeliveredFrame, SourcePacket, tuple[ObjectProposal2D, ...], frozenset[str], int],
None,
]
class ReferencePerceptionGraphV1:
"""Run one source through bounded provider stages with exact terminal accounting."""
def __init__(
self,
*,
config: ReferencePerceptionGraphConfig,
source: SourceProvider,
detector: DetectorProvider,
geometry: GeometryAssociationProvider,
temporal: TemporalStateProvider,
motion: MotionProvider,
threat: ThreatProvider,
telemetry_identity: PipelineTelemetryIdentity | None = None,
telemetry_sink: PipelineTelemetrySink | None = None,
delivery_observer: Callable[[DeliveredFrame, int], None] | None = None,
delivery_evidence_observer: DeliveryEvidenceObserver | None = None,
clock_ns: Callable[[], int] = time.monotonic_ns,
) -> None:
if config.graph_id != REFERENCE_GRAPH_ID:
raise GraphExecutionError("reference graph id is incompatible")
if config.source_profile_id != BASELINE_PROFILE_ID:
raise GraphExecutionError("reference source profile is incompatible")
self._initialize(
config=config,
source=source,
detector=detector,
geometry=geometry,
temporal=temporal,
motion=motion,
rolling=None,
threat=threat,
run_mode=GraphRunMode.SOURCE_PACED_LATEST_WINS,
telemetry_identity=telemetry_identity,
telemetry_sink=telemetry_sink,
delivery_observer=delivery_observer,
delivery_evidence_observer=delivery_evidence_observer,
clock_ns=clock_ns,
)
def _initialize(
self,
*,
config: ReferencePerceptionGraphConfig | ReferencePerceptionGraphConfigV2,
source: SourceProvider,
detector: DetectorProvider,
geometry: GeometryAssociationProvider,
temporal: TemporalStateProvider,
motion: MotionProvider,
rolling: RollingMapProvider | None,
threat: ThreatProvider,
run_mode: GraphRunMode,
telemetry_identity: PipelineTelemetryIdentity | None,
telemetry_sink: PipelineTelemetrySink | None,
delivery_observer: Callable[[DeliveredFrame, int], None] | None,
delivery_evidence_observer: DeliveryEvidenceObserver | None,
clock_ns: Callable[[], int],
) -> None:
if (telemetry_identity is None) is not (telemetry_sink is None):
raise GraphExecutionError("telemetry identity and sink must be configured together")
self.config = config
self.source = source
self.detector = detector
self.geometry = geometry
self.temporal = temporal
self.motion = motion
self.rolling = rolling
self.threat = threat
self.run_mode = run_mode
self.telemetry_identity = telemetry_identity
self.telemetry_sink = telemetry_sink
self.delivery_observer = delivery_observer
self.delivery_evidence_observer = delivery_evidence_observer
self._clock_ns = clock_ns
self._state = GraphState.CREATED
self._state_lock = Lock()
self._result_lock = Lock()
self._cancel_event = Event()
self._threads: list[Thread] = []
self._outcomes: dict[int, TerminalOutcome] = {}
self._deliveries: list[DeliveredFrame] = []
# Accounting needs immutable source identity, not the decoded payload. Keeping
# SourcePacket here retained every camera raster until the run completed.
self._admitted: dict[int, SourceEnvelope] = {}
self._admitted_at_ns: dict[int, int] = {}
self._queue_high_watermarks: dict[str, int] = {
stage_id: 0 for stage_id in ("detector", "geometry", "temporal", "rolling", "threat")
}
self._validate_provider_pins()
@property
def state(self) -> GraphState:
with self._state_lock:
return self._state
@property
def worker_threads_alive(self) -> int:
return sum(thread.is_alive() for thread in self._threads)
def cancel(self) -> None:
self._cancel_event.set()
def run(self) -> GraphRunResult | GraphRunResultV2:
self._begin_run()
started_ns = self._now()
detector_queue: Queue[_DetectorItem] = Queue(self._capacity("detector"))
geometry_queue: Queue[_GeometryItem] = Queue(self._capacity("geometry"))
temporal_queue: Queue[_TemporalItem] = Queue(self._capacity("temporal"))
threat_queue: Queue[_ThreatItem] = Queue(self._capacity("threat"))
rolling_queue: Queue[_RollingItem] | None = (
Queue(self._capacity("rolling")) if self.rolling is not None else None
)
threads = [
Thread(
target=self._detector_loop,
args=(detector_queue, geometry_queue),
name="m4-detector",
daemon=True,
),
Thread(
target=self._geometry_loop,
args=(geometry_queue, temporal_queue),
name="m4-geometry",
daemon=True,
),
Thread(
target=self._temporal_loop,
args=(temporal_queue, rolling_queue or threat_queue),
name="m4-temporal",
daemon=True,
),
]
if rolling_queue is not None:
threads.append(
Thread(
target=self._rolling_loop,
args=(rolling_queue, threat_queue),
name="m4-rolling",
daemon=True,
)
)
threads.append(
Thread(
target=self._threat_loop,
args=(threat_queue,),
name="m4-threat",
daemon=True,
)
)
self._threads = threads
for thread in self._threads:
thread.start()
run_emitter = self._run_emitter()
if run_emitter is not None:
run_emitter.run("started")
self._set_state(GraphState.RUNNING)
source_failed = False
try:
for packet in self.source.packets(self._cancel_event):
if self._cancel_event.is_set():
break
if not self._admit(packet):
continue
self._put_latest(detector_queue, packet, "detector")
except Exception:
source_failed = True
finally:
self._set_state(GraphState.STOPPING)
self._put_stop(detector_queue, "detector")
self._join_workers()
self._close_accounting()
final_state = (
GraphState.CANCELLED
if self._cancel_event.is_set()
else GraphState.FAILED
if source_failed
else GraphState.STOPPED
)
self._set_state(final_state)
result = self._result(final_state)
if run_emitter is not None:
duration_ms = max(0.0, (self._now() - started_ns) / 1_000_000)
if final_state is GraphState.STOPPED:
run_emitter.run("completed", duration_ms=duration_ms, exit_code=0)
else:
run_emitter.run(
"failed",
duration_ms=duration_ms,
exit_code=1,
error_type=(
"GraphCancelled" if final_state is GraphState.CANCELLED else "SourceFailure"
),
)
return result
def _begin_run(self) -> None:
with self._state_lock:
if self._state not in {
GraphState.CREATED,
GraphState.STOPPED,
GraphState.CANCELLED,
GraphState.FAILED,
}:
raise GraphExecutionError("reference graph is already running")
self._state = GraphState.STARTING
self._cancel_event = Event()
with self._result_lock:
self._outcomes.clear()
self._deliveries.clear()
self._admitted.clear()
self._admitted_at_ns.clear()
for stage_id in self._queue_high_watermarks:
self._queue_high_watermarks[stage_id] = 0
def _admit(self, packet: SourcePacket) -> bool:
envelope = packet.envelope
with self._result_lock:
if envelope.sequence in self._admitted:
return False
self._admitted[envelope.sequence] = envelope
self._admitted_at_ns[envelope.sequence] = self._now()
if envelope.source_id != BASELINE_SOURCE_ID:
self._terminal(packet, TerminalOutcomeType.REJECTED, "source", "source-not-admitted")
return False
if not envelope.image.available:
self._terminal(packet, TerminalOutcomeType.UNAVAILABLE, "source", "image-unavailable")
return False
if envelope.source_age_ns > self._deadline("detector"):
self._terminal(packet, TerminalOutcomeType.STALE, "source", "source-deadline-exceeded")
return False
return True
def _detector_loop(
self,
incoming: Queue[_DetectorItem],
outgoing: Queue[_GeometryItem],
) -> None:
while True:
item = incoming.get()
try:
if isinstance(item, _StopSignal):
self._put_stop(outgoing, "geometry")
return
if self._cancelled(item):
continue
if self._expired(item, "detector"):
continue
with self._stage(item, "detector", 1) as stage:
proposals = self.detector.detect(item)
validate_proposals(item, proposals)
if stage is not None:
stage.output_count = len(proposals)
if self._expired(item, "detector"):
continue
self._put_latest(outgoing, _Detected(item, proposals), "geometry")
except Exception as exc:
if not isinstance(item, _StopSignal):
self._failed(item, "detector", exc)
finally:
incoming.task_done()
def _geometry_loop(
self,
incoming: Queue[_GeometryItem],
outgoing: Queue[_TemporalItem],
) -> None:
while True:
item = incoming.get()
try:
if isinstance(item, _StopSignal):
self._put_stop(outgoing, "temporal")
return
if self._cancelled(item.packet):
continue
if self._expired(item.packet, "geometry"):
continue
with self._stage(item.packet, "geometry", len(item.proposals)) as stage:
observations = self.geometry.associate(item.packet, item.proposals)
validate_observations(item.packet, item.proposals, observations)
validate_exclusive_point_ownership(observations)
if stage is not None:
stage.output_count = len(observations)
if self._expired(item.packet, "geometry"):
continue
self._put_latest(
outgoing,
_Associated(item.packet, item.proposals, observations),
"temporal",
)
except Exception as exc:
if not isinstance(item, _StopSignal):
self._failed(item.packet, "geometry", exc)
finally:
incoming.task_done()
def _temporal_loop(
self,
incoming: Queue[_TemporalItem],
outgoing: Queue[_RollingItem] | Queue[_ThreatItem],
) -> None:
while True:
item = incoming.get()
try:
if isinstance(item, _StopSignal):
self._put_stop(
cast(Queue[_QueueItem], outgoing),
"rolling" if self.rolling is not None else "threat",
)
return
if self._cancelled(item.packet):
continue
if self._expired(item.packet, "temporal"):
continue
with self._stage(item.packet, "temporal", len(item.observations)) as stage:
obstacles = self.temporal.update(item.packet, item.observations)
obstacles = self.motion.estimate(item.packet, obstacles)
validate_temporal(item.packet, obstacles)
if stage is not None:
stage.output_count = len(obstacles)
if self._expired(item.packet, "temporal"):
continue
self._put_latest(
cast(Queue[_QueueItem], outgoing),
_Temporal(
item.packet,
item.proposals,
frozenset(
proposal_id
for observation in item.observations
if observation.occupied_support
for proposal_id in observation.proposal_ids
),
obstacles,
),
"rolling" if self.rolling is not None else "threat",
)
except Exception as exc:
if not isinstance(item, _StopSignal):
self._failed(item.packet, "temporal", exc)
finally:
incoming.task_done()
def _rolling_loop(
self,
incoming: Queue[_RollingItem],
outgoing: Queue[_ThreatItem],
) -> None:
rolling = self.rolling
if rolling is None:
raise GraphExecutionError("rolling loop requires a rolling provider")
while True:
item = incoming.get()
try:
if isinstance(item, _StopSignal):
self._put_stop(outgoing, "threat")
return
if self._cancelled(item.packet):
continue
if self._expired(item.packet, "rolling"):
continue
with self._stage(item.packet, "rolling", len(item.obstacles)) as stage:
retained = rolling.update(item.packet, item.obstacles)
validate_rolling(item.obstacles, retained)
if stage is not None:
stage.output_count = len(retained)
if self._expired(item.packet, "rolling"):
continue
self._put_latest(
outgoing,
_Rolled(
item.packet,
item.proposals,
item.associated_proposal_ids,
item.obstacles,
retained,
),
"threat",
)
except Exception as exc:
if not isinstance(item, _StopSignal):
self._failed(item.packet, "rolling", exc)
finally:
incoming.task_done()
def _threat_loop(self, incoming: Queue[_ThreatItem]) -> None:
while True:
item = incoming.get()
try:
if isinstance(item, _StopSignal):
return
if self._cancelled(item.packet):
continue
if self._expired(item.packet, "threat"):
continue
obstacle_map = self._obstacle_map(item)
with self._stage(item.packet, "threat", len(item.obstacles)) as stage:
threats = self.threat.assess(obstacle_map)
validate_threats(obstacle_map, threats)
if stage is not None:
stage.output_count = len(threats)
if self._expired(item.packet, "threat"):
continue
delivery = DeliveredFrame(
sequence=item.packet.envelope.sequence,
obstacle_map=obstacle_map,
threats=threats,
)
completed_ns = self._now()
with self._result_lock:
admitted_at_ns = self._admitted_at_ns[item.packet.envelope.sequence]
completion_age_ns = item.packet.envelope.source_age_ns + max(
0,
completed_ns - admitted_at_ns,
)
if self.delivery_observer is not None:
self.delivery_observer(delivery, completion_age_ns)
if self.delivery_evidence_observer is not None:
self.delivery_evidence_observer(
delivery,
item.packet,
item.proposals,
item.associated_proposal_ids,
completion_age_ns,
)
with self._result_lock:
self._deliveries.append(delivery)
self._terminal(
item.packet,
TerminalOutcomeType.DELIVERED,
"threat",
"object-payload-delivered",
)
except Exception as exc:
if not isinstance(item, _StopSignal):
self._failed(item.packet, "threat", exc)
finally:
incoming.task_done()
def _obstacle_map(self, item: _Temporal | _Rolled) -> LocalObstacleMap:
packet = item.packet
now_ns = self._now()
with self._result_lock:
admitted_at_ns = self._admitted_at_ns[packet.envelope.sequence]
current = tuple(
obstacle for obstacle in item.obstacles if obstacle.state is TemporalState.CURRENT
)
occupied = (*current, *item.retained) if isinstance(item, _Rolled) else current
unknown = tuple(
obstacle for obstacle in item.obstacles if obstacle.state is not TemporalState.CURRENT
)
camera_uncertainty = tuple(
proposal
for proposal in item.proposals
if proposal.proposal_id not in item.associated_proposal_ids
)
return LocalObstacleMap(
source_id=packet.envelope.source_id,
session_id=packet.envelope.session_id,
frame_id=packet.envelope.frame_id,
graph_id=self.config.graph_id,
generated_monotonic_ns=now_ns,
output_age_ns=(
packet.envelope.source_age_ns + max(0, now_ns - admitted_at_ns)
),
occupied=occupied,
unknown=unknown,
camera_uncertainty=camera_uncertainty,
accounting=SourceAccounting(1, 1, 0, 0),
)
def _cancelled(self, packet: SourcePacket) -> bool:
if not self._cancel_event.is_set():
return False
self._terminal(packet, TerminalOutcomeType.FAILED, "graph", "graph-cancelled")
return True
def _expired(self, packet: SourcePacket, stage_id: str) -> bool:
with self._result_lock:
admitted_at_ns = self._admitted_at_ns[packet.envelope.sequence]
execution_age_ns = max(0, self._now() - admitted_at_ns)
if packet.envelope.source_age_ns + execution_age_ns <= self._deadline(stage_id):
return False
self._terminal(
packet,
TerminalOutcomeType.STALE,
stage_id,
"stage-deadline-exceeded",
)
return True
def _failed(self, packet: SourcePacket, stage_id: str, exc: Exception) -> None:
self._terminal(
packet,
TerminalOutcomeType.FAILED,
stage_id,
f"provider-error-{type(exc).__name__.lower()}",
)
def _terminal(
self,
packet: SourcePacket,
outcome: TerminalOutcomeType,
stage_id: str,
reason: str,
) -> None:
self._terminal_envelope(packet.envelope, outcome, stage_id, reason)
def _terminal_envelope(
self,
envelope: SourceEnvelope,
outcome: TerminalOutcomeType,
stage_id: str,
reason: str,
) -> None:
terminal = TerminalOutcome(
source_id=envelope.source_id,
session_id=envelope.session_id,
frame_id=envelope.frame_id,
sequence=envelope.sequence,
outcome=outcome,
stage_id=stage_id,
reason=reason,
)
with self._result_lock:
self._outcomes.setdefault(envelope.sequence, terminal)
self._admitted_at_ns.pop(envelope.sequence, None)
def _put_latest(
self,
queue: Queue[_QueueItemT],
item: _QueueItemT,
stage_id: str,
) -> None:
if self.run_mode is GraphRunMode.LOSSLESS_REPLAY:
self._put_lossless(queue, item, stage_id)
return
while True:
try:
queue.put_nowait(item)
self._record_queue_depth(stage_id, queue.qsize())
return
except Full:
pass
try:
dropped = queue.get_nowait()
except Empty:
continue
try:
if not isinstance(dropped, _StopSignal):
packet = _packet_from_item(dropped)
self._terminal(
packet,
TerminalOutcomeType.SUPERSEDED,
stage_id,
"bounded-queue-latest-wins",
)
finally:
queue.task_done()
def _put_lossless(
self,
queue: Queue[_QueueItemT],
item: _QueueItemT,
stage_id: str,
) -> None:
while True:
try:
queue.put(item, timeout=0.05)
self._record_queue_depth(stage_id, queue.qsize())
return
except Full:
if self._cancel_event.is_set() and not isinstance(item, _StopSignal):
packet = _packet_from_item(item)
self._terminal(
packet,
TerminalOutcomeType.FAILED,
stage_id,
"graph-cancelled",
)
return
def _record_queue_depth(self, stage_id: str, depth: int) -> None:
with self._result_lock:
self._queue_high_watermarks[stage_id] = max(
self._queue_high_watermarks.get(stage_id, 0),
depth,
)
def _put_stop(self, queue: Queue[_QueueItemT], stage_id: str) -> None:
timeout = self._terminal_timeout(stage_id) / 1_000_000_000
try:
queue.put(cast(_QueueItemT, _STOP), timeout=timeout)
self._record_queue_depth(stage_id, queue.qsize())
except Full:
if self.run_mode is GraphRunMode.LOSSLESS_REPLAY:
self._cancel_event.set()
try:
stranded = queue.get_nowait()
except Empty as exc:
raise GraphExecutionError("lossless terminal queue timed out") from exc
try:
if not isinstance(stranded, _StopSignal):
self._terminal(
_packet_from_item(stranded),
TerminalOutcomeType.FAILED,
stage_id,
"terminal-queue-timeout",
)
finally:
queue.task_done()
queue.put_nowait(cast(_QueueItemT, _STOP))
self._record_queue_depth(stage_id, queue.qsize())
return
self._put_latest(queue, cast(_QueueItemT, _STOP), stage_id)
def _join_workers(self) -> None:
timeout_seconds = max(
1.0,
max(policy.terminal_timeout_ns for policy in self.config.queues)
/ 1_000_000_000
* 2,
)
for thread in self._threads:
thread.join(timeout_seconds)
if self.worker_threads_alive:
self._set_state(GraphState.FAILED)
raise GraphExecutionError("reference graph left an orphan worker thread")
def _close_accounting(self) -> None:
with self._result_lock:
missing = sorted(set(self._admitted) - set(self._outcomes))
envelopes = [self._admitted[sequence] for sequence in missing]
for envelope in envelopes:
self._terminal_envelope(
envelope,
TerminalOutcomeType.FAILED,
"graph",
"terminal-accounting-gap",
)
with self._result_lock:
if set(self._admitted) != set(self._outcomes):
raise GraphExecutionError("terminal accounting did not close")
def _result(self, state: GraphState) -> GraphRunResult | GraphRunResultV2:
with self._result_lock:
outcomes = tuple(self._outcomes[key] for key in sorted(self._outcomes))
deliveries = tuple(sorted(self._deliveries, key=lambda item: item.sequence))
admitted_count = len(self._admitted)
queue_high_watermarks = tuple(
(stage_id, self._queue_high_watermarks[stage_id])
for stage_id in sorted(self._queue_high_watermarks)
if any(policy.stage_id == stage_id for policy in self.config.queues)
)
if self.rolling is not None:
return build_graph_run_result_v2(
graph_id=self.config.graph_id,
source_profile_id=self.config.source_profile_id,
run_mode=self.run_mode,
state=state,
admitted_count=admitted_count,
outcomes=outcomes,
deliveries=deliveries,
queue_high_watermarks=queue_high_watermarks,
)
return build_graph_run_result(
graph_id=self.config.graph_id,
source_profile_id=self.config.source_profile_id,
state=state,
admitted_count=admitted_count,
outcomes=outcomes,
deliveries=deliveries,
)
def _stage(
self,
packet: SourcePacket,
stage_id: str,
input_count: int,
) -> AbstractContextManager[PipelineStageOutcome | _NullStage]:
emitter = self._frame_emitter(packet)
if emitter is None:
return _NullStage()
return emitter.stage(stage_id, input_count=input_count)
def _run_emitter(self) -> PipelineTelemetryEmitter | None:
if self.telemetry_identity is None or self.telemetry_sink is None:
return None
from k1link.compute.pipeline_telemetry import PipelineTelemetryEmitter
return PipelineTelemetryEmitter(
identity=self.telemetry_identity,
sink=self.telemetry_sink,
clock_ns=self._clock_ns,
)
def _frame_emitter(self, packet: SourcePacket) -> PipelineTelemetryEmitter | None:
if self.telemetry_identity is None or self.telemetry_sink is None:
return None
from k1link.compute.pipeline_telemetry import PipelineTelemetryEmitter
identity = replace(
self.telemetry_identity,
request_id=packet.envelope.frame_id,
frame_index=packet.envelope.sequence,
)
return PipelineTelemetryEmitter(
identity=identity,
sink=self.telemetry_sink,
clock_ns=self._clock_ns,
)
def _validate_provider_pins(self) -> None:
actual = {
ProviderRole.SOURCE: self.source.provider_id,
ProviderRole.DETECTOR: self.detector.provider_id,
ProviderRole.GEOMETRY: self.geometry.provider_id,
ProviderRole.TEMPORAL: self.temporal.provider_id,
ProviderRole.MOTION: self.motion.provider_id,
ProviderRole.THREAT: self.threat.provider_id,
}
if self.rolling is not None:
actual[ProviderRole.ROLLING] = self.rolling.provider_id
pinned = {pin.role: pin.provider_id for pin in self.config.providers}
if actual != pinned:
raise GraphExecutionError("configured provider identities do not match runtime")
def _capacity(self, stage_id: str) -> int:
return next(policy.capacity for policy in self.config.queues if policy.stage_id == stage_id)
def _deadline(self, stage_id: str) -> int:
return next(
policy.deadline_ns
for policy in self.config.queues
if policy.stage_id == stage_id
)
def _terminal_timeout(self, stage_id: str) -> int:
return next(
policy.terminal_timeout_ns
for policy in self.config.queues
if policy.stage_id == stage_id
)
def _now(self) -> int:
return int(self._clock_ns())
def _set_state(self, state: GraphState) -> None:
with self._state_lock:
self._state = state
class ReferencePerceptionGraphV2(ReferencePerceptionGraphV1):
"""Final M4 graph with explicit bounded rolling occupancy ownership."""
def __init__(
self,
*,
config: ReferencePerceptionGraphConfigV2,
source: SourceProvider,
detector: DetectorProvider,
geometry: GeometryAssociationProvider,
temporal: TemporalStateProvider,
motion: MotionProvider,
rolling: RollingMapProvider,
threat: ThreatProvider,
run_mode: GraphRunMode,
telemetry_identity: PipelineTelemetryIdentity | None = None,
telemetry_sink: PipelineTelemetrySink | None = None,
delivery_observer: Callable[[DeliveredFrame, int], None] | None = None,
delivery_evidence_observer: DeliveryEvidenceObserver | None = None,
clock_ns: Callable[[], int] = time.monotonic_ns,
) -> None:
if config.graph_id != REFERENCE_GRAPH_ID_V2:
raise GraphExecutionError("reference graph v2 id is incompatible")
if config.source_profile_id != BASELINE_PROFILE_ID:
raise GraphExecutionError("reference graph v2 source profile is incompatible")
self._has_run = False
self._run_once_lock = Lock()
self._initialize(
config=config,
source=source,
detector=detector,
geometry=geometry,
temporal=temporal,
motion=motion,
rolling=rolling,
threat=threat,
run_mode=run_mode,
telemetry_identity=telemetry_identity,
telemetry_sink=telemetry_sink,
delivery_observer=delivery_observer,
delivery_evidence_observer=delivery_evidence_observer,
clock_ns=clock_ns,
)
def _begin_run(self) -> None:
with self._run_once_lock:
if self._has_run:
raise GraphExecutionError(
"reference graph v2 restart requires freshly instantiated providers"
)
self._has_run = True
super()._begin_run()
class _NullStage:
output_count: int | None = None
def __enter__(self) -> _NullStage:
return self
def __exit__(self, *args: object) -> None:
return None
def _packet_from_item(item: object) -> SourcePacket:
if isinstance(item, SourcePacket):
return item
if isinstance(item, (_Detected, _Associated, _Temporal, _Rolled)):
return item.packet
raise GraphExecutionError("queue contained an incompatible item")
__all__ = [
"GRAPH_RESULT_SCHEMA",
"GRAPH_RESULT_SCHEMA_V2",
"REFERENCE_GRAPH_ID",
"REFERENCE_GRAPH_ID_V2",
"TERMINAL_OUTCOME_SCHEMA",
"DeliveredFrame",
"DeliveryEvidenceObserver",
"GraphExecutionError",
"GraphRunResult",
"GraphRunResultV2",
"GraphRunMode",
"GraphState",
"ReferencePerceptionGraphV1",
"ReferencePerceptionGraphV2",
"TerminalOutcome",
"TerminalOutcomeType",
]