962 lines
35 KiB
Python
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",
|
|
]
|