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

99 lines
3.9 KiB
Python

"""Recoverable input epochs; resident model ownership is a separate lifetime.
Used only by the explicitly enabled resumable adapter. The controller supplies
the source-clock mapping and the profile adapter supplies verified decoder and
sensor evidence. No socket, GPU, vehicle command or unbounded history lives here.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from hashlib import sha256
from .realtime_contract import StreamStart, _integer
class StreamSuspended(RuntimeError):
"""Discard obsolete work, keep the resident model processes and lease."""
@dataclass(frozen=True, slots=True)
class ResumeEvidence:
camera_ns: int
pose_ns: int
oldest_points_ns: int
newest_points_ns: int
decoded_keyframe: bool
def validate(self, *, cutoff_ns: int, source_now_ns: int) -> None:
for name in ("camera_ns", "pose_ns", "oldest_points_ns", "newest_points_ns"):
_integer(getattr(self, name), name)
_integer(source_now_ns, "source clock")
if (
self.decoded_keyframe is not True
or not cutoff_ns <= self.pose_ns <= self.camera_ns <= source_now_ns
or not cutoff_ns <= self.oldest_points_ns <= self.newest_points_ns <= self.camera_ns
or source_now_ns - self.camera_ns > 250_000_000
or self.camera_ns - self.pose_ns > 100_000_000
or self.camera_ns - self.newest_points_ns > 100_000_000
or self.camera_ns - self.oldest_points_ns > 250_000_000
or max(
abs(self.pose_ns - self.oldest_points_ns), abs(self.pose_ns - self.newest_points_ns)
)
> 100_000_000
):
raise StreamSuspended("fresh keyframe and causal camera/pose/points required")
class InputContinuity:
"""Access is serialized by StreamingLifecycle's lock."""
def __init__(self, activation: StreamStart) -> None:
self.activation = self.epoch = activation
self.phase = "active"
self.generation = self.pauses = 0
self.reason: str | None = None
self.cutoff_ns = 0
def check(self, epoch: StreamStart, *, synchronizing: bool = False) -> None:
if epoch != self.epoch:
raise StreamSuspended("obsolete input epoch")
if self.phase != "active" and not (synchronizing and self.phase == "synchronizing"):
raise StreamSuspended("input is waiting for resynchronization")
def pause(self, reason: str) -> None:
if reason not in (
"input-disconnected",
"input-timeout",
"source-gap",
"worker-telemetry",
"source-clock",
):
raise ValueError("unknown recoverable pause reason")
if self.phase != "waiting":
self.pauses += 1
self.phase, self.reason = "waiting", reason
def begin(self, source_now_ns: int) -> StreamStart:
_integer(source_now_ns, "source clock")
if self.phase != "waiting" or source_now_ns < self.cutoff_ns:
raise StreamSuspended("new input needs a pause and nonregressing source clock")
self.generation += 1
identity = sha256(f"{self.activation.epoch_id}:{self.generation}".encode()).hexdigest()
# Wire v2 already binds every packet to the complete StreamStart. Change
# the input epoch, NOT the resident lease generation/model activation.
self.epoch = replace(self.activation, epoch_id="input-" + identity)
self.cutoff_ns, self.phase = source_now_ns, "synchronizing"
return self.epoch
def snapshot(self) -> dict[str, object]:
return {
"phase": self.phase,
"reason": self.reason,
"input_generation": self.generation,
"pauses": self.pauses,
"input_start": self.epoch.to_dict(),
"minimum_source_ns": str(self.cutoff_ns),
"actuation_allowed": False,
}