feat(perception): resume full graph on fresh input without restarting models
This commit is contained in:
@@ -8,6 +8,8 @@ from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.media_fragments import ParseBudget, video_fragment_timing, video_timing
|
||||
from k1link.perception.streaming_continuity import ResumeEvidence, StreamSuspended
|
||||
from k1link.perception.streaming_ingress import StreamingIngress
|
||||
from k1link.perception.streaming_sensors import (
|
||||
CausalSensorWindow,
|
||||
@@ -18,7 +20,7 @@ from k1link.perception.streaming_sensors import (
|
||||
|
||||
|
||||
class BinaryGraphBridge:
|
||||
def __init__(self, runtime, decoder, source, report):
|
||||
def __init__(self, runtime, decoder, source, report, *, reset_temporal=None):
|
||||
self.runtime, self.decoder, self.source, self.report = runtime, decoder, source, report
|
||||
self.source_zero, self.wall_zero = source.source_zero, source.wall_zero
|
||||
self.window = CausalSensorWindow(runtime.mailbox)
|
||||
@@ -26,6 +28,11 @@ class BinaryGraphBridge:
|
||||
self.binding_reasons = Counter()
|
||||
self.preroll = 0
|
||||
self.bgr_hashes = []
|
||||
self.epoch = runtime.start
|
||||
self.reset_temporal = reset_temporal
|
||||
self.decode_sequence_start = 0
|
||||
self.init_timing = None
|
||||
self.epoch_reports, self.resumes, self.sync_skipped = [], [], []
|
||||
self.left = right = None
|
||||
try:
|
||||
self.left, right = socket.socketpair()
|
||||
@@ -33,7 +40,11 @@ class BinaryGraphBridge:
|
||||
right, runtime, "recorded-acquisition", 1, self.consume, self.notice
|
||||
)
|
||||
self.producer = threading.Thread(
|
||||
target=source.run, args=(self.left,), daemon=True, name="binary-recorded-source"
|
||||
target=source.run,
|
||||
args=(self.left,),
|
||||
kwargs={"bridge": self} if reset_temporal is not None else {},
|
||||
daemon=True,
|
||||
name="binary-recorded-source",
|
||||
)
|
||||
runtime.track_thread(self.producer)
|
||||
except BaseException:
|
||||
@@ -54,14 +65,52 @@ class BinaryGraphBridge:
|
||||
# and fresh decoder state; never continue predictive decoding across it.
|
||||
raise ValueError("binary full profile requires restart after an explicit source gap")
|
||||
|
||||
def disconnect(self):
|
||||
self.left.close() # Real socket EOF; independent resident lease is kept.
|
||||
self.runtime.pause_input(self.epoch, "input-disconnected")
|
||||
|
||||
def reconnect(self):
|
||||
"""Nonblocking source-side attempt; source clock keeps moving on failure."""
|
||||
try:
|
||||
epoch = self.runtime.begin_input(self.runtime.start)
|
||||
except StreamSuspended:
|
||||
return None
|
||||
self.epoch_reports.append(self.receiver.snapshot())
|
||||
if len(self.epoch_reports) > 256:
|
||||
raise ValueError("bounded pilot reconnect count exceeded")
|
||||
self.epoch = epoch
|
||||
self.decode_sequence_start = None
|
||||
self.window.reset()
|
||||
self.left, right = socket.socketpair()
|
||||
try:
|
||||
self.receiver = StreamingIngress(
|
||||
right,
|
||||
self.runtime,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
self.consume,
|
||||
self.notice,
|
||||
input_epoch=epoch,
|
||||
)
|
||||
self.receiver.start()
|
||||
except BaseException:
|
||||
self.left.close()
|
||||
right.close()
|
||||
raise
|
||||
return self.left
|
||||
|
||||
def consume(self, event):
|
||||
self.runtime.check_current(self.runtime.start)
|
||||
self.runtime.check_input(self.epoch, synchronizing=True)
|
||||
stamp = event.received_monotonic_ns
|
||||
self.window.advance(stamp)
|
||||
if event.modality == "camera-init":
|
||||
if event.source_id != "sensor.camera.right":
|
||||
raise ValueError("camera source mapping changed")
|
||||
self.decoder.configure(event.payload)
|
||||
if self.decode_sequence_start is None:
|
||||
self.decoder.reset(self.epoch, event.payload)
|
||||
else:
|
||||
self.decoder.configure(event.payload)
|
||||
self.init_timing = video_timing(event.payload, ParseBudget(128, 1))
|
||||
return
|
||||
if event.modality in ("lidar", "pose"):
|
||||
expected = {
|
||||
@@ -84,6 +133,16 @@ class BinaryGraphBridge:
|
||||
fresh = binding.increments
|
||||
count = sum(len(e.value[0]) for e in fresh)
|
||||
rolling_count = sum(len(e.value[0]) for e in rolling)
|
||||
resuming = self.decode_sequence_start is None
|
||||
if resuming and (
|
||||
not binding.available
|
||||
or not video_fragment_timing(
|
||||
event.payload, self.init_timing, ParseBudget(128, 1)
|
||||
).random_access
|
||||
):
|
||||
self.sync_skipped.append(event.source_sequence)
|
||||
self.window.finish_camera(stamp)
|
||||
return # No decode of dependent pictures; wait for a current keyframe/pair.
|
||||
size = 1440000 + count * 24 + rolling_count * 32 + 4096
|
||||
reservation = self.runtime.mailbox.reserve_ingress(size)
|
||||
raw = image = points = rolling_points = rolling_times = bundle = None
|
||||
@@ -94,7 +153,25 @@ class BinaryGraphBridge:
|
||||
decoded = self.decoder.decode(event.payload, raw)
|
||||
image = np.frombuffer(raw, "u1").reshape(600, 800, 3)
|
||||
image.setflags(write=False)
|
||||
if decoded["frame_index"] != event.source_sequence:
|
||||
if resuming:
|
||||
evidence = ResumeEvidence(
|
||||
stamp,
|
||||
pose.time_ns,
|
||||
fresh[0].time_ns,
|
||||
fresh[-1].time_ns,
|
||||
decoded["frame_index"] == 0,
|
||||
)
|
||||
self.runtime.resume_input(self.epoch, evidence, self.reset_temporal)
|
||||
self.decode_sequence_start = event.source_sequence
|
||||
self.resumes.append(
|
||||
{
|
||||
"sequence": event.source_sequence,
|
||||
"input_start": self.epoch.to_dict(),
|
||||
"source_ns": stamp,
|
||||
"completed_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
)
|
||||
if decoded["frame_index"] != event.source_sequence - self.decode_sequence_start:
|
||||
raise ValueError("camera and decoder frame identity diverged")
|
||||
points = np.concatenate([e.value[0] for e in fresh]) if fresh else np.empty((0, 3))
|
||||
rolling_points = (
|
||||
@@ -108,6 +185,7 @@ class BinaryGraphBridge:
|
||||
offset += length
|
||||
due = self.wall_zero + stamp - self.source_zero
|
||||
bundle = {
|
||||
"input_start": self.epoch,
|
||||
"sequence": event.source_sequence,
|
||||
"time_ns": stamp,
|
||||
"source_ns": stamp - self.source_zero,
|
||||
@@ -139,7 +217,7 @@ class BinaryGraphBridge:
|
||||
}
|
||||
self.bgr_hashes.append(hashlib.sha256(image).hexdigest())
|
||||
bundle["enqueued_ns"] = time.monotonic_ns()
|
||||
transferred = self.runtime.admit_reserved(self.runtime.start, bundle, reservation)
|
||||
transferred = self.runtime.admit_reserved(self.epoch, bundle, reservation)
|
||||
self.accepted.append(event.source_sequence)
|
||||
self.binding_reasons.update(binding.reasons)
|
||||
self.preroll += sum(len(e.value[0]) for e in binding.history_only)
|
||||
@@ -160,6 +238,9 @@ class BinaryGraphBridge:
|
||||
joined = self.receiver.join(timeout=2) if self.receiver.thread.ident is not None else True
|
||||
self.report.update(
|
||||
binary_ingress=self.receiver.snapshot(),
|
||||
previous_input_epochs=self.epoch_reports,
|
||||
resumed_inputs=self.resumes,
|
||||
synchronization_skipped_camera_sequences=self.sync_skipped,
|
||||
decoder_bgr_sha256=self.bgr_hashes,
|
||||
failed_camera_sequences=sorted(set(self.source.released) - set(self.accepted)),
|
||||
sensor_binding_reasons=dict(self.binding_reasons),
|
||||
|
||||
+60
-4
@@ -15,6 +15,7 @@ from pilot_binary_bridge import BinaryGraphBridge
|
||||
from pilot_binary_source import RecordingSource
|
||||
|
||||
from k1link.perception.realtime_contract import StreamStart
|
||||
from k1link.perception.streaming_continuity import StreamSuspended
|
||||
from k1link.perception.streaming_decoder_client import StreamingDecoderClient
|
||||
from k1link.perception.streaming_lifecycle import StreamingLifecycle
|
||||
from k1link.perception.streaming_queue import StreamMailbox
|
||||
@@ -60,7 +61,16 @@ def run(args):
|
||||
lease_generation=1,
|
||||
profile_sha256=hashlib.sha256(Path("/out/candidate-profile.json").read_bytes()).hexdigest(),
|
||||
image_sha256="664824aa25de1db178f177d67a81b01541a938b479812b9383ddbf03f6b59dbe",
|
||||
effective_config_sha256=hashlib.sha256(b"cpu-bundle-parity-v1").hexdigest(),
|
||||
effective_config_sha256=hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"probe": "cpu-bundle-parity-v1",
|
||||
"recover_input": args.recover_input,
|
||||
"input_gap": args.input_gap,
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
).hexdigest(),
|
||||
calibration_sha256=hashlib.sha256(
|
||||
b"no-calibration-or-model-scene-in-this-probe"
|
||||
).hexdigest(),
|
||||
@@ -68,7 +78,12 @@ def run(args):
|
||||
input_mode="recorded-source-paced",
|
||||
)
|
||||
runtime = StreamingLifecycle(
|
||||
identity, Path("/tmp/cpu-bridge-lease"), StreamMailbox(), threading.Event()
|
||||
identity,
|
||||
Path("/tmp/cpu-bridge-lease"),
|
||||
StreamMailbox(),
|
||||
threading.Event(),
|
||||
recover_input=args.recover_input,
|
||||
source_clock_ns=lambda: source.source_zero + time.monotonic_ns() - source.wall_zero,
|
||||
)
|
||||
heartbeat_stop = threading.Event()
|
||||
|
||||
@@ -113,9 +128,20 @@ def run(args):
|
||||
client = StreamingDecoderClient(runtime, child.stdout.fileno(), child.stdin.fileno())
|
||||
runtime.ready()
|
||||
source = RecordingSource(args, runtime, source_report)
|
||||
bridge = BinaryGraphBridge(runtime, client, source, source_report)
|
||||
bridge = BinaryGraphBridge(
|
||||
runtime,
|
||||
client,
|
||||
source,
|
||||
source_report,
|
||||
reset_temporal=(lambda: None) if args.recover_input else None,
|
||||
)
|
||||
bridge.start()
|
||||
while (bundle := runtime.mailbox.take()) is not None:
|
||||
try:
|
||||
runtime.check_input(bundle["input_start"])
|
||||
except StreamSuspended:
|
||||
runtime.mailbox.release(bundle, discard_reason="input-gap")
|
||||
continue
|
||||
timings.append(
|
||||
{
|
||||
"sequence": bundle["sequence"],
|
||||
@@ -131,7 +157,7 @@ def run(args):
|
||||
finally:
|
||||
heartbeat_stop.set()
|
||||
renewer.join(timeout=1)
|
||||
runtime.request_stop("completed" if len(decoded) == args.frames else "failed")
|
||||
runtime.request_stop("completed" if not runtime.mailbox.error and decoded else "failed")
|
||||
runtime.stop_children()
|
||||
report["bridge_stopped"] = bridge.close() if bridge else True
|
||||
if client:
|
||||
@@ -142,7 +168,35 @@ def run(args):
|
||||
report["peak_input_bytes"] = runtime.mailbox.peak_bytes
|
||||
report["residual_input_bytes"] = runtime.mailbox.bytes
|
||||
report["timings"] = timings
|
||||
report["model_runs"] = 0
|
||||
report["queue_dropped"] = runtime.mailbox.dropped
|
||||
report["bundle_count"] = len(decoded)
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
if args.recover_input:
|
||||
report["continuity_passed"] = (
|
||||
len(source_report["resumed_inputs"]) == len(args.input_gap)
|
||||
and len(decoded)
|
||||
+ runtime.mailbox.dropped_count
|
||||
+ len(source_report["failed_camera_sequences"])
|
||||
== args.frames
|
||||
and report["released"]
|
||||
and not runtime.mailbox.bytes
|
||||
and not runtime.mailbox.error
|
||||
)
|
||||
(root / "decoded-bundles.json").write_text(json.dumps(decoded, indent=2) + "\n")
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
assert report["continuity_passed"], report
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"continuity_passed": True,
|
||||
"resumes": len(source_report["resumed_inputs"]),
|
||||
"decoded": len(decoded),
|
||||
"model_runs": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
if args.fault_decoder:
|
||||
assert not decoded and report["released"] and not runtime.mailbox.bytes
|
||||
assert "decoder RPC deadline" in source_report["binary_ingress"]["error"]
|
||||
@@ -223,6 +277,8 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--frames", type=int, default=32)
|
||||
parser.add_argument("--fault-decoder", action="store_true")
|
||||
parser.add_argument("--recover-input", action="store_true")
|
||||
parser.add_argument("--input-gap", action="append", default=[])
|
||||
parser.add_argument("--camera-root", default="/camera")
|
||||
parser.add_argument("--camera-index", default="/camera/index.jsonl")
|
||||
parser.add_argument("--sensor-archive", default="/sensor-source.npz")
|
||||
|
||||
@@ -14,7 +14,24 @@ from pathlib import Path
|
||||
from pilot_source import SensorArchive, camera_events, merged_events
|
||||
|
||||
from k1link.compute.live_perception import LiveIngressEvent
|
||||
from k1link.perception.streaming_continuity import StreamSuspended
|
||||
from k1link.perception.streaming_sender import StreamingSender
|
||||
from k1link.perception.streaming_wire import StreamWireError
|
||||
|
||||
|
||||
def input_gaps(values):
|
||||
"""Bounded diagnostic fault plan, not a source or model configuration."""
|
||||
result = []
|
||||
for value in values:
|
||||
sequence, milliseconds = (int(v) for v in value.split(":"))
|
||||
if not 0 < sequence < 256 or not 1 <= milliseconds <= 5000:
|
||||
raise ValueError("input gap outside pilot bounds")
|
||||
if result and sequence <= result[-1][0]:
|
||||
raise ValueError("input gaps must increase")
|
||||
result.append((sequence, milliseconds))
|
||||
if len(result) > 4:
|
||||
raise ValueError("at most four input gaps")
|
||||
return result
|
||||
|
||||
|
||||
def read_member(path, root, maximum):
|
||||
@@ -44,36 +61,44 @@ class RecordingSource:
|
||||
full_source_prepass=False,
|
||||
)
|
||||
|
||||
def run(self, connection):
|
||||
def run(self, connection, *, bridge=None):
|
||||
archive = None
|
||||
sender = None
|
||||
arrivals, skipped = Counter(), Counter()
|
||||
lags = []
|
||||
clock = self.runtime.stop_event
|
||||
gaps = input_gaps(getattr(self.args, "input_gap", []))
|
||||
gap_index, outage_until = 0, 0
|
||||
faults, skipped_cameras = [], []
|
||||
try:
|
||||
archive = SensorArchive(Path(self.args.sensor_archive))
|
||||
root = Path(self.args.camera_root)
|
||||
sender = StreamingSender(
|
||||
connection,
|
||||
self.runtime.start,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
lambda: self.runtime.check_current(self.runtime.start),
|
||||
)
|
||||
seq = 1
|
||||
sender.send(
|
||||
LiveIngressEvent(
|
||||
seq,
|
||||
|
||||
def connect(sock, epoch, stamp):
|
||||
stream = StreamingSender(
|
||||
sock,
|
||||
epoch,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
"camera-init",
|
||||
"sensor.camera.right",
|
||||
0,
|
||||
0,
|
||||
self.source_zero,
|
||||
read_member(root / "init.mp4", root, 65536),
|
||||
lambda: self.runtime.check_current(self.runtime.start),
|
||||
)
|
||||
)
|
||||
stream.send(
|
||||
LiveIngressEvent(
|
||||
1,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
"camera-init",
|
||||
"sensor.camera.right",
|
||||
0,
|
||||
0,
|
||||
stamp,
|
||||
read_member(root / "init.mp4", root, 65536),
|
||||
)
|
||||
)
|
||||
return stream
|
||||
|
||||
sender = connect(connection, self.runtime.start, self.source_zero)
|
||||
seq = 1
|
||||
for event in merged_events(archive, self.args.camera_index, self.args.frames):
|
||||
if event.time_ns < self.source_zero:
|
||||
skipped[event.channel] += 1
|
||||
@@ -86,6 +111,46 @@ class RecordingSource:
|
||||
utc = 0 # These normalized sensor rows have no UTC evidence.
|
||||
if event.channel == "camera":
|
||||
self.released.append(event.sequence)
|
||||
if bridge is not None:
|
||||
if (
|
||||
event.channel == "camera"
|
||||
and gap_index < len(gaps)
|
||||
and event.sequence >= gaps[gap_index][0]
|
||||
):
|
||||
milliseconds = gaps[gap_index][1]
|
||||
bridge.disconnect()
|
||||
sender = None
|
||||
outage_until = time.monotonic_ns() + milliseconds * 1_000_000
|
||||
faults.append(
|
||||
{
|
||||
"sequence": event.sequence,
|
||||
"source_ns": event.time_ns,
|
||||
"injected_monotonic_ns": time.monotonic_ns(),
|
||||
"duration_ms": milliseconds,
|
||||
"runtime": self.runtime.snapshot(),
|
||||
}
|
||||
)
|
||||
gap_index += 1
|
||||
if self.runtime.continuity.phase == "waiting":
|
||||
if sender is not None:
|
||||
sender.close()
|
||||
sender = None
|
||||
if time.monotonic_ns() >= outage_until:
|
||||
connection = bridge.reconnect()
|
||||
if connection is not None:
|
||||
sender = connect(
|
||||
connection, bridge.epoch, self.runtime.continuity.cutoff_ns
|
||||
)
|
||||
seq = 1
|
||||
# The current event predates the reconnect cutoff. Do
|
||||
# not retimestamp or replay it; wait for the next event.
|
||||
skipped[event.channel + "-input-gap"] += 1
|
||||
if event.channel == "camera":
|
||||
skipped_cameras.append(event.sequence)
|
||||
if event.sequence == self.args.frames - 1:
|
||||
break
|
||||
continue
|
||||
if event.channel == "camera":
|
||||
row = event.value
|
||||
raw = read_member(root / row["path"], root, 1024 * 1024)
|
||||
if (
|
||||
@@ -107,25 +172,40 @@ class RecordingSource:
|
||||
raw = position.tobytes() + quaternion.tobytes()
|
||||
modality, source_id = "pose", "normalized-map-from-lidar-pose"
|
||||
seq += 1
|
||||
sender.send(
|
||||
LiveIngressEvent(
|
||||
seq,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
modality,
|
||||
source_id,
|
||||
event.sequence,
|
||||
utc,
|
||||
event.time_ns,
|
||||
raw,
|
||||
try:
|
||||
sender.send(
|
||||
LiveIngressEvent(
|
||||
seq,
|
||||
"recorded-acquisition",
|
||||
1,
|
||||
modality,
|
||||
source_id,
|
||||
event.sequence,
|
||||
utc,
|
||||
event.time_ns,
|
||||
raw,
|
||||
)
|
||||
)
|
||||
)
|
||||
except (StreamWireError, OSError, StreamSuspended):
|
||||
if bridge is None:
|
||||
raise
|
||||
# Bounded IPC write failure: pause, never retry old bytes.
|
||||
bridge.disconnect()
|
||||
sender = None
|
||||
skipped[event.channel + "-send-interrupted"] += 1
|
||||
if event.channel == "camera":
|
||||
skipped_cameras.append(event.sequence)
|
||||
del raw
|
||||
if event.channel == "camera" and event.sequence == self.args.frames - 1:
|
||||
break # Sensor streams may continue beyond this bounded camera probe.
|
||||
if not clock.is_set():
|
||||
if not clock.is_set() and sender is not None:
|
||||
self.report["end_sent_monotonic_ns"] = time.monotonic_ns()
|
||||
sender.end()
|
||||
elif not clock.is_set():
|
||||
# The bounded diagnostic source ended during an outage. There
|
||||
# are no further observations to recover from; do not hang.
|
||||
self.report["source_ended_while_waiting"] = True
|
||||
self.runtime.mailbox.finish()
|
||||
except Exception:
|
||||
self.report["source_error"] = traceback.format_exc()
|
||||
self.runtime.mailbox.finish(self.report["source_error"])
|
||||
@@ -139,7 +219,10 @@ class RecordingSource:
|
||||
release_lag_ms=distribution(lags),
|
||||
incremental_reads=archive.counters() if archive else {},
|
||||
window_end_monotonic_ns=time.monotonic_ns(),
|
||||
input_faults=faults,
|
||||
input_gap_skipped_camera_sequences=skipped_cameras,
|
||||
)
|
||||
if archive:
|
||||
archive.close()
|
||||
connection.close()
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
|
||||
@@ -200,6 +200,7 @@ class JointGraph:
|
||||
body_frame_resolver=self.store,
|
||||
profile=load_replay_threat_profile(config / "m4-replay-threat-v3.json"),
|
||||
)
|
||||
self.temporal_resets = 0
|
||||
self.backend = TritonNativeRfDetrHttpInferenceBackend(
|
||||
"http://127.0.0.1:8000", timeout_seconds=5
|
||||
)
|
||||
@@ -257,6 +258,39 @@ class JointGraph:
|
||||
if material != "hard_surface":
|
||||
self.action_lut[i, :] = 2
|
||||
|
||||
def reset_temporal(self):
|
||||
"""Quiescent input epoch boundary; keep model backend and TGS process.
|
||||
|
||||
TGS C++ creates a new estimator per request; costmap arrays are local.
|
||||
Cross-frame ground, associations, motion and rolling evidence live here.
|
||||
Configuration objects are retained; no model reload or host I/O.
|
||||
"""
|
||||
previous = self.temporal_state_counts()
|
||||
surface = K1LocalSurfaceShadowEstimator(self.surface.profile)
|
||||
store = CurrentStore(self.store.profile)
|
||||
geometry = Ravnoves00GeometryAssociationProvider(store=store)
|
||||
temporal = BoundedSpatialTemporalProvider(
|
||||
point_resolver=store, profile=self.temporal.profile
|
||||
)
|
||||
motion = ClassIndependentMotionEstimator(profile=self.motion.profile)
|
||||
rolling = RollingLocalObstacleMapProvider(pose_resolver=store, profile=self.rolling.profile)
|
||||
threat = DualEvidenceReplayThreatProvider(
|
||||
body_frame_resolver=store, profile=self.threat.profile
|
||||
)
|
||||
self.surface, self.store, self.geometry = surface, store, geometry
|
||||
self.temporal, self.motion, self.rolling, self.threat = temporal, motion, rolling, threat
|
||||
self.temporal_resets += 1
|
||||
return {"previous": previous, "current": self.temporal_state_counts()}
|
||||
|
||||
def temporal_state_counts(self):
|
||||
"""Bounded diagnostics, never admission evidence from a remote client."""
|
||||
return {
|
||||
"surface_cache": len(self.surface._cache),
|
||||
"body_history": len(self.store.body_history),
|
||||
"components": len(self.temporal._components),
|
||||
"rolling_cells": len(self.rolling._cells),
|
||||
}
|
||||
|
||||
def packet(self, bundle):
|
||||
available = bundle["available"]
|
||||
status = ModalityStatus(
|
||||
|
||||
@@ -9,6 +9,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -94,8 +95,15 @@ class PilotController:
|
||||
input_mode="recorded-source-paced",
|
||||
)
|
||||
self.runtime = StreamingLifecycle(
|
||||
self.start, Path(args.worker_lease_root), mailbox, stop, ttl_seconds=2.0
|
||||
self.start,
|
||||
Path(args.worker_lease_root),
|
||||
mailbox,
|
||||
stop,
|
||||
ttl_seconds=2.0,
|
||||
recover_input=getattr(args, "recover_input", False),
|
||||
source_clock_ns=self.source_now,
|
||||
)
|
||||
self.source_clock = None
|
||||
self.heartbeat_stop = threading.Event()
|
||||
self.heartbeat_error = None
|
||||
self.thread = threading.Thread(
|
||||
@@ -108,6 +116,11 @@ class PilotController:
|
||||
report["lease_authority_scope"] = "cooperating-runtimes-sharing-fixed-worker-volume"
|
||||
report["heartbeat_scope"] = "same-process-pilot-controller-not-network-client"
|
||||
|
||||
def source_now(self):
|
||||
if self.source_clock is None:
|
||||
raise WorkerLeaseError("source clock mapping not configured")
|
||||
return self.source_clock.source_zero + time.monotonic_ns() - self.source_clock.wall_zero
|
||||
|
||||
def _renew(self):
|
||||
while not self.heartbeat_stop.wait(0.25):
|
||||
try:
|
||||
|
||||
@@ -33,6 +33,8 @@ from pilot_sensor_binding import bind_sensors, increment_identity, milliseconds
|
||||
from pilot_source import SensorArchive, camera_events, merged_events
|
||||
from pilot_telemetry import NvmlSampler
|
||||
|
||||
from k1link.perception.streaming_continuity import StreamSuspended
|
||||
|
||||
|
||||
def distribution(values):
|
||||
# Missing diagnostics are not zero; binary source lag is measured separately.
|
||||
@@ -274,6 +276,8 @@ def run(args):
|
||||
"costmap_freshness_mode": args.costmap_freshness,
|
||||
"ddrnet_min_source_interval_ms": args.ddrnet_min_source_interval_ms,
|
||||
"input_transport": args.input_transport,
|
||||
"recover_input": getattr(args, "recover_input", False),
|
||||
"input_gap_plan": getattr(args, "input_gap", []),
|
||||
}
|
||||
children, logs, results, samples = [], [], [], []
|
||||
stop = threading.Event()
|
||||
@@ -430,6 +434,26 @@ def run(args):
|
||||
stats_before = read_stats(triton_models)
|
||||
|
||||
ddr_cache = None
|
||||
report["resident_pids"] = [p.pid for p in children]
|
||||
report["temporal_reset_events"] = []
|
||||
|
||||
def reset_temporal():
|
||||
nonlocal ddr_cache
|
||||
state = graph.reset_temporal()
|
||||
ddr_cache = None
|
||||
report["temporal_reset_events"].append(
|
||||
{
|
||||
"state": state,
|
||||
"ddr_cache_cleared": True,
|
||||
"input_epoch_id": controller.runtime.continuity.epoch.epoch_id,
|
||||
"resident_pids": [p.pid for p in children],
|
||||
"all_children_alive": all(p.poll() is None for p in children),
|
||||
"monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
)
|
||||
|
||||
def input_start(bundle):
|
||||
return bundle.get("input_start", controller.start) if controller else None
|
||||
|
||||
def compute_gpu_impl(bundle):
|
||||
nonlocal ddr_cache
|
||||
@@ -480,7 +504,7 @@ def run(args):
|
||||
mask = ddr_cache["mask"]
|
||||
ddr_done = time.monotonic_ns()
|
||||
if controller:
|
||||
controller.runtime.check_current(controller.start)
|
||||
controller.runtime.check_input(input_start(bundle))
|
||||
proposals = graph.detector.detect(graph.packet(bundle))
|
||||
gpu_done = time.monotonic_ns()
|
||||
ddr_layer = {
|
||||
@@ -493,7 +517,7 @@ def run(args):
|
||||
|
||||
def compute_gpu(bundle):
|
||||
context = (
|
||||
controller.runtime.work(controller.start, "gpu") if controller else nullcontext()
|
||||
controller.runtime.work(input_start(bundle), "gpu") if controller else nullcontext()
|
||||
)
|
||||
with context:
|
||||
return compute_gpu_impl(bundle)
|
||||
@@ -516,8 +540,13 @@ def run(args):
|
||||
from pilot_binary_source import RecordingSource
|
||||
|
||||
source = RecordingSource(args, controller.runtime, source_report)
|
||||
controller.source_clock = source
|
||||
binary_bridge = BinaryGraphBridge(
|
||||
controller.runtime, decoder_client, source, source_report
|
||||
controller.runtime,
|
||||
decoder_client,
|
||||
source,
|
||||
source_report,
|
||||
reset_temporal=reset_temporal if report["recover_input"] else None,
|
||||
)
|
||||
binary_bridge.start()
|
||||
else:
|
||||
@@ -538,109 +567,126 @@ def run(args):
|
||||
if item is None:
|
||||
break
|
||||
bundle, computed = item
|
||||
item = None # Do not retain the old input while waiting for the next epoch.
|
||||
cpu_bundle = bundle
|
||||
else:
|
||||
bundle = mailbox.take()
|
||||
if bundle is None:
|
||||
break
|
||||
cpu_bundle = bundle
|
||||
computed = compute_gpu(bundle)
|
||||
begin, ddr_done, gpu_done, ddr_result, mask, proposals, ddr_layer = computed
|
||||
cpu_started = time.monotonic_ns()
|
||||
context = (
|
||||
controller.runtime.work(controller.start, "cpu")
|
||||
if controller
|
||||
else nullcontext()
|
||||
)
|
||||
with context:
|
||||
scene = graph.process(
|
||||
bundle,
|
||||
mask,
|
||||
proposals=proposals,
|
||||
detector_ms=(gpu_done - ddr_done) / 1e6,
|
||||
computed = None
|
||||
try:
|
||||
if computed is None:
|
||||
computed = compute_gpu(bundle)
|
||||
begin, ddr_done, gpu_done, ddr_result, mask, proposals, ddr_layer = computed
|
||||
cpu_started = time.monotonic_ns()
|
||||
context = (
|
||||
controller.runtime.work(input_start(bundle), "cpu")
|
||||
if controller
|
||||
else nullcontext()
|
||||
)
|
||||
raw_costmap_sha256 = hashlib.sha256(
|
||||
json.dumps(scene["costmap_states"], separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
freshness_started = time.monotonic_ns()
|
||||
prepare_publication(
|
||||
scene,
|
||||
bundle,
|
||||
ddr_layer,
|
||||
epoch_id=epoch_id,
|
||||
now_ns=freshness_started,
|
||||
mode=args.costmap_freshness,
|
||||
)
|
||||
scene.update(
|
||||
sequence=bundle["sequence"],
|
||||
lineage=bundle["lineage"],
|
||||
sensor_binding=bundle["sensor_binding"],
|
||||
available=bundle["available"],
|
||||
original_source_ns=bundle["time_ns"],
|
||||
)
|
||||
if controller:
|
||||
controller.runtime.check_current(controller.start)
|
||||
scene["runtime_binding"] = controller.start.to_dict()
|
||||
encoded = json.dumps(scene, allow_nan=False, separators=(",", ":")).encode()
|
||||
if len(encoded) > 1024 * 1024:
|
||||
raise ValueError("scene exceeds bounded collector message budget")
|
||||
# Actual bounded local receiver parse. Durable export excluded below.
|
||||
received = json.loads(encoded)
|
||||
if received["sequence"] != bundle["sequence"]:
|
||||
raise ValueError("collector identity mismatch")
|
||||
if controller:
|
||||
controller.runtime.validate_result_binding(received["runtime_binding"])
|
||||
freshness = validate_receipt(received, bundle, epoch_id=epoch_id)
|
||||
checked_at = time.monotonic_ns()
|
||||
view, checked = assess_receipt(
|
||||
received, freshness, bundle=bundle, now_ns=checked_at
|
||||
)
|
||||
if controller:
|
||||
controller.runtime.validate_result_binding(view["runtime_binding"])
|
||||
finished = time.monotonic_ns()
|
||||
timing = {
|
||||
**scene["timing_ms"],
|
||||
**{"ddrnet_" + key: value for key, value in ddr_result["stages_ms"].items()},
|
||||
"ddrnet_rpc_ms": (ddr_done - begin) / 1e6,
|
||||
"ddrnet_component_ms": ddr_result["component_ms"],
|
||||
"ddrnet_forward_ms": ddr_result["forward_ms"],
|
||||
"decode_ms": bundle["decode_ms"],
|
||||
"decode_rpc_ms": bundle["decode_rpc_ms"],
|
||||
"source_release_lag_ms": bundle["source_release_lag_ms"],
|
||||
"queue_wait_ms": (begin - bundle["enqueued_ns"]) / 1e6,
|
||||
"gpu_to_cpu_queue_wait_ms": (cpu_started - gpu_done) / 1e6,
|
||||
"cpu_tail_to_receiver_ms": (finished - cpu_started) / 1e6,
|
||||
"compute_to_receiver_ms": (finished - begin) / 1e6,
|
||||
"source_due_to_receiver_ms": (finished - bundle["due_ns"]) / 1e6,
|
||||
"freshness_encode_receive_ms": (finished - freshness_started) / 1e6,
|
||||
}
|
||||
result = {
|
||||
"sequence": bundle["sequence"],
|
||||
"finished_monotonic_ns": finished,
|
||||
"timing_ms": timing,
|
||||
"available": bundle["available"],
|
||||
"ddrnet_state": ddr_layer["state"],
|
||||
"ddrnet_source_age_ms": ddr_layer["source_age_ms"],
|
||||
"surface_state": scene["surface_state"],
|
||||
"proposal_count": len(scene["proposals"]),
|
||||
"observation_count": len(scene["observations"]),
|
||||
"track_count": len(scene["tracks"]),
|
||||
"metric_count": sum(
|
||||
x["metric_geometry"] is not None for x in scene["observations"]
|
||||
),
|
||||
"tgs_counts": scene["tgs_counts"],
|
||||
"policy_counts": view["policy_counts"],
|
||||
"freshness_at_receipt": checked.to_dict(),
|
||||
"policy_counts_at_publication": scene["policy_counts"],
|
||||
"cell_assessment_at_receipt": view.get("cell_assessment"),
|
||||
"raw_costmap_states_sha256": raw_costmap_sha256,
|
||||
"effective_costmap_states_sha256": hashlib.sha256(
|
||||
json.dumps(view["costmap_states"], separators=(",", ":")).encode()
|
||||
).hexdigest(),
|
||||
"scene_bytes": len(encoded),
|
||||
"scene_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
results.append(result)
|
||||
with context:
|
||||
scene = graph.process(
|
||||
bundle,
|
||||
mask,
|
||||
proposals=proposals,
|
||||
detector_ms=(gpu_done - ddr_done) / 1e6,
|
||||
)
|
||||
raw_costmap_sha256 = hashlib.sha256(
|
||||
json.dumps(scene["costmap_states"], separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
freshness_started = time.monotonic_ns()
|
||||
prepare_publication(
|
||||
scene,
|
||||
bundle,
|
||||
ddr_layer,
|
||||
epoch_id=input_start(bundle).epoch_id if controller else epoch_id,
|
||||
now_ns=freshness_started,
|
||||
mode=args.costmap_freshness,
|
||||
)
|
||||
scene.update(
|
||||
sequence=bundle["sequence"],
|
||||
lineage=bundle["lineage"],
|
||||
sensor_binding=bundle["sensor_binding"],
|
||||
available=bundle["available"],
|
||||
original_source_ns=bundle["time_ns"],
|
||||
)
|
||||
if controller:
|
||||
controller.runtime.check_input(input_start(bundle))
|
||||
scene["runtime_binding"] = input_start(bundle).to_dict()
|
||||
encoded = json.dumps(scene, allow_nan=False, separators=(",", ":")).encode()
|
||||
if len(encoded) > 1024 * 1024:
|
||||
raise ValueError("scene exceeds bounded collector message budget")
|
||||
# Actual bounded local receiver parse. Durable export excluded below.
|
||||
received = json.loads(encoded)
|
||||
if received["sequence"] != bundle["sequence"]:
|
||||
raise ValueError("collector identity mismatch")
|
||||
if controller:
|
||||
controller.runtime.validate_result_binding(received["runtime_binding"])
|
||||
freshness = validate_receipt(
|
||||
received,
|
||||
bundle,
|
||||
epoch_id=input_start(bundle).epoch_id if controller else epoch_id,
|
||||
)
|
||||
checked_at = time.monotonic_ns()
|
||||
view, checked = assess_receipt(
|
||||
received, freshness, bundle=bundle, now_ns=checked_at
|
||||
)
|
||||
if controller:
|
||||
controller.runtime.validate_result_binding(view["runtime_binding"])
|
||||
finished = time.monotonic_ns()
|
||||
timing = {
|
||||
**scene["timing_ms"],
|
||||
**{
|
||||
"ddrnet_" + key: value for key, value in ddr_result["stages_ms"].items()
|
||||
},
|
||||
"ddrnet_rpc_ms": (ddr_done - begin) / 1e6,
|
||||
"ddrnet_component_ms": ddr_result["component_ms"],
|
||||
"ddrnet_forward_ms": ddr_result["forward_ms"],
|
||||
"decode_ms": bundle["decode_ms"],
|
||||
"decode_rpc_ms": bundle["decode_rpc_ms"],
|
||||
"source_release_lag_ms": bundle["source_release_lag_ms"],
|
||||
"queue_wait_ms": (begin - bundle["enqueued_ns"]) / 1e6,
|
||||
"gpu_to_cpu_queue_wait_ms": (cpu_started - gpu_done) / 1e6,
|
||||
"cpu_tail_to_receiver_ms": (finished - cpu_started) / 1e6,
|
||||
"compute_to_receiver_ms": (finished - begin) / 1e6,
|
||||
"source_due_to_receiver_ms": (finished - bundle["due_ns"]) / 1e6,
|
||||
"freshness_encode_receive_ms": (finished - freshness_started) / 1e6,
|
||||
}
|
||||
result = {
|
||||
"input_epoch_id": input_start(bundle).epoch_id if controller else epoch_id,
|
||||
"sequence": bundle["sequence"],
|
||||
"finished_monotonic_ns": finished,
|
||||
"timing_ms": timing,
|
||||
"available": bundle["available"],
|
||||
"ddrnet_state": ddr_layer["state"],
|
||||
"ddrnet_source_age_ms": ddr_layer["source_age_ms"],
|
||||
"surface_state": scene["surface_state"],
|
||||
"proposal_count": len(scene["proposals"]),
|
||||
"observation_count": len(scene["observations"]),
|
||||
"track_count": len(scene["tracks"]),
|
||||
"metric_count": sum(
|
||||
x["metric_geometry"] is not None for x in scene["observations"]
|
||||
),
|
||||
"tgs_counts": scene["tgs_counts"],
|
||||
"policy_counts": view["policy_counts"],
|
||||
"freshness_at_receipt": checked.to_dict(),
|
||||
"policy_counts_at_publication": scene["policy_counts"],
|
||||
"cell_assessment_at_receipt": view.get("cell_assessment"),
|
||||
"raw_costmap_states_sha256": raw_costmap_sha256,
|
||||
"effective_costmap_states_sha256": hashlib.sha256(
|
||||
json.dumps(view["costmap_states"], separators=(",", ":")).encode()
|
||||
).hexdigest(),
|
||||
"scene_bytes": len(encoded),
|
||||
"scene_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
results.append(result)
|
||||
except StreamSuspended:
|
||||
mailbox.release(bundle, discard_reason="input-gap")
|
||||
cpu_bundle = bundle = computed = mask = proposals = scene = received = view = (
|
||||
None
|
||||
)
|
||||
continue
|
||||
mailbox.release(bundle)
|
||||
cpu_bundle = None
|
||||
sink.write(encoded + b"\n")
|
||||
@@ -664,6 +710,7 @@ def run(args):
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
bundle = computed = mask = proposals = scene = received = view = None
|
||||
if mailbox.error:
|
||||
raise RuntimeError(mailbox.error)
|
||||
if controller:
|
||||
@@ -873,6 +920,8 @@ if __name__ == "__main__":
|
||||
parser.add_argument("--lease-generation", type=int, default=1)
|
||||
parser.add_argument("--controller-image-sha256")
|
||||
parser.add_argument("--stop-renew-after-sequence", type=int, default=-1)
|
||||
parser.add_argument("--recover-input", action="store_true")
|
||||
parser.add_argument("--input-gap", action="append", default=[], metavar="SEQUENCE:MILLISECONDS")
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.frames <= 256:
|
||||
parser.error("pilot window must be 1..256 frames")
|
||||
@@ -886,4 +935,14 @@ if __name__ == "__main__":
|
||||
parser.error("lease-expiry injection requires a controller")
|
||||
if args.input_transport == "binary-ipc" and not args.worker_lease_root:
|
||||
parser.error("binary ingress requires the common lifecycle")
|
||||
from pilot_binary_source import input_gaps
|
||||
|
||||
try:
|
||||
gaps = input_gaps(args.input_gap)
|
||||
except (ValueError, TypeError):
|
||||
parser.error("invalid bounded input gap plan")
|
||||
if args.recover_input and args.input_transport != "binary-ipc":
|
||||
parser.error("resumable full profile requires binary input")
|
||||
if gaps and (not args.recover_input or any(seq >= args.frames for seq, _ in gaps)):
|
||||
parser.error("input gaps require recovery and must be inside the bounded source")
|
||||
raise SystemExit(run(args))
|
||||
|
||||
@@ -227,8 +227,10 @@ class StreamingLifecycle:
|
||||
self._check(start)
|
||||
if self.continuity is None or self._source_clock_ns is None:
|
||||
raise ValueError("resumable input was not enabled")
|
||||
if any(self._active.values()) or (
|
||||
self._ingress_thread is not None and self._ingress_thread.is_alive()
|
||||
if (
|
||||
not self.mailbox.epoch_drained
|
||||
or any(self._active.values())
|
||||
or (self._ingress_thread is not None and self._ingress_thread.is_alive())
|
||||
):
|
||||
raise StreamSuspended("old epoch callbacks or connection still active")
|
||||
if self.continuity.phase != "waiting":
|
||||
|
||||
@@ -228,3 +228,9 @@ class StreamMailbox:
|
||||
if self.done or self._owned or self.external_pending:
|
||||
raise ValueError("old epoch still owns work or mailbox is closed")
|
||||
self._last_sequence = -1
|
||||
|
||||
@property
|
||||
def epoch_drained(self) -> bool:
|
||||
"""Resident scratch may remain; no old input/result may still be owned."""
|
||||
with self.condition:
|
||||
return not self.done and not self._owned and not self.external_pending
|
||||
|
||||
@@ -21,6 +21,67 @@ def pilot(monkeypatch):
|
||||
return lambda name: importlib.import_module(name)
|
||||
|
||||
|
||||
def test_full_graph_reset_replaces_temporal_state_not_models(pilot):
|
||||
module = pilot("pilot_graph")
|
||||
root = Path(__file__).resolve().parents[1] / "config/perception"
|
||||
graph = module.JointGraph.__new__(module.JointGraph)
|
||||
graph.surface = module.K1LocalSurfaceShadowEstimator()
|
||||
graph.store = module.CurrentStore(
|
||||
module.load_geometry_profile(root / "m4-geometry-association-v1.json")
|
||||
)
|
||||
profile = module.load_temporal_motion_profile(root / "m4-temporal-motion-v1.json")
|
||||
graph.temporal = module.BoundedSpatialTemporalProvider(
|
||||
point_resolver=graph.store, profile=profile
|
||||
)
|
||||
graph.motion = module.ClassIndependentMotionEstimator(profile=profile)
|
||||
graph.rolling = module.RollingLocalObstacleMapProvider(
|
||||
pose_resolver=graph.store,
|
||||
profile=module.load_rolling_map_profile(root / "m4-rolling-local-map-v1.json"),
|
||||
)
|
||||
graph.threat = module.DualEvidenceReplayThreatProvider(
|
||||
body_frame_resolver=graph.store,
|
||||
profile=module.load_replay_threat_profile(root / "m4-replay-threat-v3.json"),
|
||||
)
|
||||
graph.temporal_resets = 0
|
||||
graph.backend = graph.detector = graph.tgs = model = object()
|
||||
for cache in (
|
||||
graph.surface._cache,
|
||||
graph.store.body_history,
|
||||
graph.temporal._components,
|
||||
graph.rolling._cells,
|
||||
):
|
||||
cache["old"] = object()
|
||||
graph.surface._previous_surface = (1, 2, 3, 4)
|
||||
graph.temporal._previous_sequence = graph.rolling._previous_sequence = 32
|
||||
old_store = graph.store
|
||||
state = graph.reset_temporal()
|
||||
assert set(state["previous"].values()) == {1}
|
||||
assert set(state["current"].values()) == {0}
|
||||
assert graph.temporal._previous_sequence is graph.rolling._previous_sequence is None
|
||||
assert graph.surface._previous_surface is None and graph.temporal_resets == 1
|
||||
assert graph.store is not old_store
|
||||
assert (
|
||||
graph.temporal.point_resolver
|
||||
is graph.rolling.pose_resolver
|
||||
is graph.threat.body_frame_resolver
|
||||
is graph.store
|
||||
)
|
||||
assert graph.backend is graph.detector is graph.tgs is model
|
||||
assert graph.temporal.profile is graph.motion.profile is profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values", [["0:1"], ["10:0"], ["10:5001"], ["a:1"], ["10:1", "9:1"], ["1:1"] * 5]
|
||||
)
|
||||
def test_source_gap_plan_is_bounded(pilot, values):
|
||||
with pytest.raises(ValueError):
|
||||
pilot("pilot_binary_source").input_gaps(values)
|
||||
|
||||
|
||||
def test_source_gap_plan_preserves_explicit_sequence_and_duration(pilot):
|
||||
assert pilot("pilot_binary_source").input_gaps(["16:150", "72:2200"]) == [(16, 150), (72, 2200)]
|
||||
|
||||
|
||||
def test_pending_overflow_is_explicit_and_does_not_evict_active(pilot):
|
||||
queue = pilot("pilot_queue").Mailbox(capacity=2, byte_limit=100)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def test_active_old_callback_cannot_publish_or_be_freed_early(harness):
|
||||
run.check_current(run.start) # In-flight IPC can drain without poisoning the child.
|
||||
with pytest.raises(StreamSuspended):
|
||||
run.begin_input(run.start)
|
||||
with pytest.raises(ValueError, match="still owns"):
|
||||
with pytest.raises(StreamSuspended, match="still active"):
|
||||
run.begin_input(run.start)
|
||||
run.mailbox.release(packet, discard_reason="input-gap")
|
||||
epoch = run.begin_input(run.start)
|
||||
|
||||
Reference in New Issue
Block a user