feat(perception): connect binary ingress to the supervised full graph

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 13:53:25 +03:00
parent a85aed2e03
commit 221e429c0a
13 changed files with 1352 additions and 141 deletions
@@ -0,0 +1,64 @@
"""Fenced decoder RPC; native execution lives in a separately supervised child."""
from __future__ import annotations
import math
from typing import Any
from .graph_contracts import GraphState
from .streaming_lifecycle import StreamingLifecycle
from .streaming_pipe_rpc import HEADER_SCRATCH, MAX_OUTPUT, BoundedPipeRpc, PipeRpcError
class StreamingDecoderClient:
def __init__(self, runtime: StreamingLifecycle, read_fd: int, write_fd: int) -> None:
if runtime.state != GraphState.STARTING:
raise PipeRpcError("decoder must be created during warmup")
self.runtime = runtime
self.reservation = runtime.mailbox.reserve_ingress(HEADER_SCRATCH)
self.closed = False
self.frames = 0
try:
self.rpc = BoundedPipeRpc(
read_fd, write_fd, lambda: runtime.check_current(runtime.start, starting=True)
)
self.ready = self.rpc.exchange(None, b"", bytearray(), timeout=10)
if self.ready != {"ready": "fragment-h264", "pyav": "18.0.0", "as_limit_mib": 1024}:
raise PipeRpcError("decoder startup identity changed")
self.rpc.check = lambda: runtime.check_current(runtime.start)
except BaseException:
runtime.request_stop("failed")
self.close()
raise
def configure(self, raw: bytes) -> None:
if self.closed or not 0 < len(raw) <= 65536:
raise PipeRpcError("decoder init outside bound")
if self.rpc.exchange({"op": "init"}, raw, bytearray()) != {"initialized": True}:
raise PipeRpcError("decoder init response changed")
def decode(self, raw: bytes, target: bytearray) -> dict[str, Any]:
# The caller has already reserved this exact BGR buffer in a decoded
# bundle ticket. RPC fills it directly, without an unaccounted copy.
if self.closed or len(target) != MAX_OUTPUT:
raise PipeRpcError("decoder output allocation changed")
result = self.rpc.exchange({"op": "decode"}, raw, target)
duration = result.get("decode_ms")
if (
set(result) != {"decode_ms", "frame_index"}
or type(result["frame_index"]) is not int
or result["frame_index"] != self.frames
or not isinstance(duration, (int, float))
or isinstance(duration, bool)
or not math.isfinite(duration)
or duration < 0
):
raise PipeRpcError("decoder frame identity or measurement changed")
self.frames += 1
return result
def close(self) -> None:
# Caller joins its callback and stops the owned child before closing.
if not self.closed:
self.closed = True
self.reservation.release()
+11 -3
View File
@@ -21,7 +21,7 @@ from typing import Any, Literal
from .graph_contracts import GraphState
from .realtime_contract import StreamStart
from .realtime_scene import _wire_integer
from .streaming_queue import StreamBundle, StreamMailbox
from .streaming_queue import IngressReservation, StreamBundle, StreamMailbox
from .worker_lease import WorkerLease, WorkerLeaseError
@@ -116,6 +116,13 @@ class StreamingLifecycle:
self._check(start)
return self.mailbox.put(bundle)
def admit_reserved(
self, start: StreamStart, bundle: StreamBundle, reservation: IngressReservation
) -> bool:
with self._lock:
self._check(start)
return self.mailbox.put_reserved(bundle, reservation)
@contextmanager
def work(self, start: StreamStart, lane: Literal["gpu", "cpu"]) -> Iterator[None]:
with self._lock:
@@ -133,9 +140,10 @@ class StreamingLifecycle:
with self._lock:
self._active[lane] -= 1
def check_current(self, start: StreamStart) -> None:
def check_current(self, start: StreamStart, *, starting: bool = False) -> None:
"""Only child startup handshakes may opt into the warmup state."""
with self._lock:
self._check(start)
self._check(start, starting=starting)
def validate_result_binding(self, value: object) -> None:
"""Recheck at receipt/use; accepting a hash alone cannot renew authority."""
+130
View File
@@ -0,0 +1,130 @@
"""Bounded supervised-child RPC with caller-owned output buffers, POSIX only.
Same small header/binary framing as the existing pilot RPC. No process creation,
paths, models, queues or implicit allocation of image payloads. Caller reserves
the target buffer and HEADER_SCRATCH before using this transport. Native child
memory is separately constrained; timeout poisons this connection, never retries.
"""
from __future__ import annotations
import json
import os
import select
import struct
import threading
import time
from collections.abc import Callable, Mapping
from typing import Any
HEADER_SCRATCH = 65536
MAX_HEADER = 4096
MAX_INPUT = 1024 * 1024
MAX_OUTPUT = 1_440_000
class PipeRpcError(ValueError):
pass
def _unique(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise PipeRpcError("duplicate RPC field")
result[key] = value
return result
class BoundedPipeRpc:
def __init__(self, read_fd: int, write_fd: int, check: Callable[[], None]) -> None:
self.read_fd, self.write_fd, self.check = read_fd, write_fd, check
os.set_blocking(read_fd, False)
os.set_blocking(write_fd, False)
self._lock = threading.Lock()
self.failed = False
def _check(self, deadline: float) -> None:
self.check()
if time.monotonic() >= deadline:
raise PipeRpcError("decoder RPC deadline exceeded")
def _read(self, target: memoryview, deadline: float) -> None:
offset = 0
while offset < len(target):
self._check(deadline)
if not select.select([self.read_fd], [], [], 0.01)[0]:
continue
try:
count = os.readv(self.read_fd, [target[offset:]])
except BlockingIOError:
continue
if not count:
raise PipeRpcError("truncated decoder RPC")
offset += count
self._check(deadline)
def _write(self, data: bytes | memoryview, deadline: float) -> None:
view = memoryview(data)
while view:
self._check(deadline)
if not select.select([], [self.write_fd], [], 0.01)[1]:
continue
try:
count = os.write(self.write_fd, view)
except BlockingIOError:
continue
if not count:
raise PipeRpcError("decoder RPC write made no progress")
view = view[count:]
self._check(deadline)
def exchange(
self,
header: Mapping[str, Any] | None,
payload: bytes,
target: bytearray,
*,
timeout: float = 0.25,
) -> dict[str, Any]:
"""None header reads a one-time startup message; no work is submitted."""
if not 0 < timeout <= (10 if header is None else 0.25):
raise PipeRpcError("RPC timeout outside bound")
if len(payload) > MAX_INPUT or len(target) > MAX_OUTPUT:
raise PipeRpcError("RPC payload outside bound")
if not self._lock.acquire(blocking=False):
raise PipeRpcError("concurrent decoder RPC is forbidden")
try:
if self.failed:
raise PipeRpcError("decoder RPC is poisoned")
deadline = time.monotonic() + timeout
self._check(deadline)
if header is not None:
raw = json.dumps(
{**header, "payload_bytes": len(payload)}, allow_nan=False
).encode()
if not 0 < len(raw) <= MAX_HEADER:
raise PipeRpcError("RPC header outside bound")
self._write(struct.pack("<I", len(raw)), deadline)
self._write(raw, deadline)
self._write(payload, deadline)
prefix = bytearray(4)
self._read(memoryview(prefix), deadline)
size = struct.unpack("<I", prefix)[0]
if not 0 < size <= MAX_HEADER:
raise PipeRpcError("RPC header outside bound")
response = bytearray(size)
self._read(memoryview(response), deadline)
value = json.loads(response, object_pairs_hook=_unique)
if not isinstance(value, dict):
raise PipeRpcError("RPC header must be an object")
length = value.pop("payload_bytes", None)
if type(length) is not int or length != len(target):
raise PipeRpcError("decoder response byte count changed")
self._read(memoryview(target), deadline)
return value
except BaseException:
self.failed = True
raise
finally:
self._lock.release()
+24
View File
@@ -96,6 +96,30 @@ class StreamMailbox:
self.drop_counts[reason] += 1
self._recent_drops.append({"sequence": bundle["sequence"], "reason": reason})
def put_reserved(self, bundle: StreamBundle, reservation: IngressReservation) -> bool:
"""Atomically transfer preallocated decoded bytes into queue ownership.
Success consumes the reservation. On rejection/error the caller still
owns it and must drop its buffers BEFORE releasing it. No unaccounted
interval or transient double charge; active inputs cannot be evicted.
"""
with self.condition:
size = self._ingress.get(reservation)
if size is None or type(bundle["payload_bytes"]) is not int:
raise ValueError("decoded handoff ownership mismatch")
if bundle["payload_bytes"] != size:
raise ValueError("decoded handoff size mismatch")
del self._ingress[reservation]
self.bytes -= size
accepted = False
try:
accepted = self.put(bundle)
return accepted
finally:
if not accepted:
self._ingress[reservation] = size
self.bytes += size
def _discard_pending(self, reason: str) -> None:
old = self.pending.popleft()
_, size = self._owned.pop(id(old))
+239
View File
@@ -0,0 +1,239 @@
"""Causal input binding with explicit preroll history and per-modality age.
No new inference or interpolation. The caller retains all preroll points in its
bounded rolling window; only the initial current-pair selection is narrowed.
After the first camera, the original increment admission rules are unchanged.
"""
from collections import deque
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
import numpy as np
from .streaming_queue import StreamMailbox
@dataclass(frozen=True)
class SensorEvent:
time_ns: int
channel: str
sequence: int
value: Any
POSE_AGE_NS = 100_000_000
NEWEST_POINT_AGE_NS = 100_000_000
OLDEST_POINT_AGE_NS = 250_000_000
POINT_POSE_SKEW_NS = 100_000_000
def increment_identity(event: SensorEvent) -> dict[str, int]:
return {
"sequence": event.sequence,
"host_monotonic_ns": event.time_ns,
"points": len(event.value[0]),
}
def milliseconds(value: int | None) -> float | None:
return None if value is None else value / 1e6
@dataclass(frozen=True)
class SensorBinding:
increments: tuple[SensorEvent, ...]
history_only: tuple[SensorEvent, ...]
pose_age_ns: int | None
newest_point_age_ns: int | None
oldest_point_age_ns: int | None
binding_age_ns: int | None
pose_state: str
points_state: str
reasons: tuple[str, ...]
@property
def available(self) -> bool:
return not self.reasons
def document(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.pilot-sensor-binding/v1",
"pose_state": self.pose_state,
"points_state": self.points_state,
"current_pair_available": self.available,
"reason_codes": list(self.reasons),
"pose_age_ms": milliseconds(self.pose_age_ns),
"newest_point_age_ms": milliseconds(self.newest_point_age_ns),
"oldest_point_age_ms": milliseconds(self.oldest_point_age_ns),
"point_pose_skew_ms": milliseconds(self.binding_age_ns),
"preroll_history_only": [increment_identity(e) for e in self.history_only],
"preroll_history_disposition": "retained-in-bounded-rolling-window",
}
def bind_sensors(
camera_time_ns: int,
pose: SensorEvent | None,
increments: Sequence[SensorEvent],
*,
previous_camera_time_ns: int | None = None,
) -> SensorBinding:
increments = tuple(increments)
if previous_camera_time_ns is not None and previous_camera_time_ns >= camera_time_ns:
raise ValueError("camera binding clock must increase")
if pose is not None and pose.time_ns > camera_time_ns:
raise ValueError("future pose cannot bind to camera")
if any(e.time_ns > camera_time_ns for e in increments):
raise ValueError("future points cannot bind to camera")
if any(a.time_ns > b.time_ns for a, b in zip(increments, increments[1:], strict=False)):
raise ValueError("point binding clock moved backwards")
history_only: tuple[SensorEvent, ...] = ()
if previous_camera_time_ns is None:
# History used to warm rolling geometry is not one current increment.
# Preserve its identity separately; do not retimestamp or silently drop it.
selected: list[SensorEvent] = []
history: list[SensorEvent] = []
for event in increments:
is_current = (
pose is not None
and camera_time_ns - event.time_ns <= OLDEST_POINT_AGE_NS
and abs(event.time_ns - pose.time_ns) <= POINT_POSE_SKEW_NS
)
(selected if is_current else history).append(event)
increments, history_only = tuple(selected), tuple(history)
pose_age = None if pose is None else camera_time_ns - pose.time_ns
newest_age = None if not increments else camera_time_ns - increments[-1].time_ns
oldest_age = None if not increments else camera_time_ns - increments[0].time_ns
skew = (
max(abs(e.time_ns - pose.time_ns) for e in increments)
if increments and pose is not None
else None
)
reasons = []
if pose is None:
pose_state = "unavailable"
reasons.append("pose-unavailable")
elif pose_age is not None and pose_age > POSE_AGE_NS:
pose_state = "stale"
reasons.append("pose-too-old")
else:
pose_state = (
"held"
if previous_camera_time_ns is not None and pose.time_ns <= previous_camera_time_ns
else "current"
)
if not increments or not any(len(e.value[0]) for e in increments):
points_state = "unavailable"
reasons.append("point-increment-unavailable")
else:
points_state = "current"
if newest_age is not None and newest_age > NEWEST_POINT_AGE_NS:
points_state = "stale"
reasons.append("newest-points-too-old")
if oldest_age is not None and oldest_age > OLDEST_POINT_AGE_NS:
points_state = "stale"
reasons.append("oldest-points-too-old")
if skew is not None and skew > POINT_POSE_SKEW_NS:
reasons.append("point-pose-skew")
return SensorBinding(
increments,
history_only,
pose_age,
newest_age,
oldest_age,
skew,
pose_state,
points_state,
tuple(reasons),
)
def normalized_sensor(modality: str, sequence: int, time_ns: int, raw: bytes) -> SensorEvent:
"""Explicit existing normalized-map wire layout, not a vendor packet parser."""
if modality == "lidar":
count = int.from_bytes(raw[:4], "little")
if len(raw) < 4 or not 0 <= count <= 50000 or len(raw) != 4 + count * 25:
raise ValueError("normalized point layout outside bound")
xyz = np.frombuffer(raw, "<f8", count=count * 3, offset=4).reshape(count, 3)
intensity = np.frombuffer(raw, "u1", count=count, offset=4 + count * 24)
# Bound validation temporaries inside the window's metadata/scratch
# allowance, even for a maximum-size incoming point increment.
flat = xyz.reshape(-1)
for offset in range(0, flat.size, 4096):
if not np.isfinite(flat[offset : offset + 4096]).all():
raise ValueError("nonfinite source cloud")
return SensorEvent(time_ns, "points", sequence, (xyz, intensity))
if modality != "pose" or len(raw) != 56:
raise ValueError("normalized pose layout outside bound")
values = np.frombuffer(raw, "<f8")
if not np.isfinite(values).all() or abs(float(np.linalg.norm(values[3:])) - 1) > 0.01:
raise ValueError("invalid source pose")
return SensorEvent(time_ns, "pose", sequence, (values[:3], values[3:]))
class CausalSensorWindow:
"""Single-consumer rolling/fresh caches; no async nearest/future lookup.
Reserve the maximum union of two 64000-point sets before retaining raw
views. Metadata and pose allowance is separate; Python objects/native RSS
still need the enclosing process limit. Sensor selection rules above are
exactly the existing pilot rules, including the initial history-only cut.
"""
CACHE_BYTES = 2 * 64000 * 25 + 16384
def __init__(self, mailbox: StreamMailbox) -> None:
self._reservation = mailbox.reserve_ingress(self.CACHE_BYTES)
self.rolling: deque[SensorEvent] = deque()
self.fresh: list[SensorEvent] = []
self.pose: SensorEvent | None = None
self.previous_camera_time: int | None = None
self._last_time = -1
self.closed = False
def advance(self, time_ns: int) -> None:
if self.closed or time_ns < self._last_time:
raise ValueError("sensor stream is closed or moved backwards")
self._last_time = time_ns
while self.rolling and time_ns - self.rolling[0].time_ns > 1_000_000_000:
self.rolling.popleft()
def append(self, event: SensorEvent) -> None:
self.advance(event.time_ns)
if event.channel == "pose":
self.pose = event
return
if event.channel != "points":
raise ValueError("unsupported normalized sensor channel")
for items in (self.rolling, self.fresh):
if (
len(items) >= 64
or sum(len(e.value[0]) for e in items) + len(event.value[0]) > 64000
):
raise ValueError("causal sensor cache exceeds bound")
self.rolling.append(event)
self.fresh.append(event)
def bind(self, time_ns: int) -> SensorBinding:
self.advance(time_ns)
return bind_sensors(
time_ns, self.pose, self.fresh, previous_camera_time_ns=self.previous_camera_time
)
def finish_camera(self, time_ns: int) -> None:
if self.closed or time_ns != self._last_time:
raise ValueError("sensor window changed during camera binding")
self.previous_camera_time = time_ns
self.fresh.clear()
def close(self) -> None:
if not self.closed:
self.rolling.clear()
self.fresh.clear()
self.pose = None
self.closed = True
self._reservation.release()