feat(perception): enforce six-layer freshness through receipt
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Pilot adapter for the shared six-layer ABI; source arrival clock, local receipt.
|
||||
|
||||
Not a live clock synchronizer or lease authority. Domain history/unknown motion
|
||||
stays in existing payloads. An unavailable layer may retain diagnostic fields in
|
||||
the scene, but makes no valid payload/terrain-permission claim in the ABI.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from k1link.perception.realtime_contract import LayerEvidence
|
||||
from k1link.perception.realtime_scene import DEPENDENCIES, SceneFreshness, derive_layer
|
||||
|
||||
CLOCK_DOMAIN = "pilot-original-host-arrival"
|
||||
|
||||
|
||||
def payload_digest(scene, layer):
|
||||
if layer == "segmentation":
|
||||
return scene["segmentation_sha256"]
|
||||
fields = {
|
||||
"objects": ("proposals",),
|
||||
"geometry": ("observations", "surface_state", "range_estimator"),
|
||||
"motion": ("tracks", "threats"),
|
||||
"costmap": ("costmap_states", "costmap_material"),
|
||||
"policy": ("policy_actions", "policy_counts"),
|
||||
}[layer]
|
||||
value = {key: scene[key] for key in fields}
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, allow_nan=False, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_freshness(scene, bundle, ddr_layer, epoch_id):
|
||||
stamp, sequence = bundle["time_ns"], bundle["sequence"]
|
||||
segmentation = LayerEvidence(
|
||||
"segmentation",
|
||||
epoch_id,
|
||||
ddr_layer["source_sequence"],
|
||||
ddr_layer["source_host_monotonic_ns"],
|
||||
"current" if ddr_layer["state"] == "current" else "held",
|
||||
payload_digest(scene, "segmentation"),
|
||||
ddr_layer["source_host_monotonic_ns"],
|
||||
)
|
||||
objects = LayerEvidence(
|
||||
"objects", epoch_id, sequence, stamp, "current", payload_digest(scene, "objects"), stamp
|
||||
)
|
||||
layers = {"segmentation": segmentation, "objects": objects}
|
||||
for name in ("geometry", "motion", "costmap", "policy"):
|
||||
oldest = stamp
|
||||
if name == "geometry":
|
||||
if not bundle["available"] or scene["surface_state"] != "valid":
|
||||
layers[name] = LayerEvidence(name, epoch_id, None, None, "unavailable", None)
|
||||
continue
|
||||
lineage = bundle["lineage"]
|
||||
oldest = min(
|
||||
lineage["pose_host_monotonic_ns"],
|
||||
*(item["host_monotonic_ns"] for item in lineage["point_increments"]),
|
||||
)
|
||||
if name == "costmap" and bundle["available"]:
|
||||
# A rolling window can retain old occupied/unknown cells to prohibit
|
||||
# terrain. Permissive cells must keep their own observation age.
|
||||
oldest = scene["tgs_counts"]["oldest_permissive_cell_source_ns"]
|
||||
layers[name] = derive_layer(
|
||||
name,
|
||||
epoch_id=epoch_id,
|
||||
source_sequence=sequence,
|
||||
source_time_ns=stamp,
|
||||
payload_sha256=payload_digest(scene, name),
|
||||
inputs=tuple(layers[key] for key in DEPENDENCIES[name]),
|
||||
oldest_required_input_time_ns=oldest,
|
||||
)
|
||||
return SceneFreshness(epoch_id, CLOCK_DOMAIN, sequence, stamp, tuple(layers.values()))
|
||||
|
||||
|
||||
def assess(freshness, *, bundle, now_ns):
|
||||
# Both sides of this pilot use the SAME monotonic clock. Map it back to
|
||||
# original source arrival time; zero mapping uncertainty is local-only and
|
||||
# is not a claim of hardware camera/LiDAR synchronization or network quality.
|
||||
return freshness.assess(
|
||||
epoch_id=freshness.epoch_id,
|
||||
clock_domain_id=CLOCK_DOMAIN,
|
||||
observed_source_time_ns=bundle["time_ns"] + now_ns - bundle["due_ns"],
|
||||
clock_uncertainty_ms=0,
|
||||
maximum_clock_uncertainty_ms=5,
|
||||
maximum_layer_age_ms=250,
|
||||
)
|
||||
|
||||
|
||||
def suppress_policy(scene):
|
||||
scene["policy_actions"] = [2] * len(scene["policy_actions"])
|
||||
scene["policy_counts"] = {
|
||||
"ALLOW_candidate": 0,
|
||||
"HIGH_COST": 0,
|
||||
"NO_GO": len(scene["policy_actions"]),
|
||||
}
|
||||
|
||||
|
||||
def prepare_publication(scene, bundle, ddr_layer, *, epoch_id, now_ns):
|
||||
freshness = build_freshness(scene, bundle, ddr_layer, epoch_id)
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
if not checked.fresh_complete:
|
||||
suppress_policy(scene)
|
||||
# The envelope hashes the actual guarded policy, not the discarded one.
|
||||
freshness = build_freshness(scene, bundle, ddr_layer, epoch_id)
|
||||
checked = assess(freshness, bundle=bundle, now_ns=now_ns)
|
||||
scene["freshness"] = freshness.to_dict()
|
||||
scene["freshness_at_publication"] = checked.to_dict()
|
||||
scene["stale_at_publication"] = any(item.state == "stale" for item in checked.layers)
|
||||
scene["oldest_required_input_age_ms"] = max(
|
||||
(age for age in checked.age_upper_bound_ms if age is not None), default=None
|
||||
)
|
||||
|
||||
|
||||
def validate_receipt(scene, bundle, *, epoch_id):
|
||||
freshness = SceneFreshness.from_dict(scene["freshness"])
|
||||
if (
|
||||
freshness.epoch_id,
|
||||
freshness.clock_domain_id,
|
||||
freshness.source_sequence,
|
||||
freshness.source_time_ns,
|
||||
) != (epoch_id, CLOCK_DOMAIN, bundle["sequence"], bundle["time_ns"]):
|
||||
raise ValueError("collector freshness identity mismatch")
|
||||
if scene["commands_enabled"] is not False or scene["actuation_allowed"] is not False:
|
||||
raise ValueError("pilot result attempted control authority")
|
||||
for item in freshness.layers:
|
||||
if (
|
||||
item.payload_sha256 is not None
|
||||
and payload_digest(scene, item.layer) != item.payload_sha256
|
||||
):
|
||||
raise ValueError("collector layer payload digest mismatch")
|
||||
return freshness
|
||||
|
||||
|
||||
def receipt_view(scene, checked):
|
||||
"""A consumer view; never mutate the hashed published evidence."""
|
||||
view = dict(scene)
|
||||
if not checked.fresh_complete:
|
||||
suppress_policy(view)
|
||||
return view
|
||||
@@ -444,7 +444,7 @@ class JointGraph:
|
||||
for index in diagnostic_cells:
|
||||
matching = np.flatnonzero(good & (cell_ids == index))
|
||||
diagnostics[str(index)] = {
|
||||
"votes": dict(zip(self.material_names, votes[index].tolist())),
|
||||
"votes": dict(zip(self.material_names, votes[index].tolist(), strict=True)),
|
||||
"projected_ground_points": len(matching),
|
||||
"points_truncated": len(matching) > 128,
|
||||
"points": [
|
||||
@@ -467,6 +467,12 @@ class JointGraph:
|
||||
if index is not None:
|
||||
cells[index] = 2
|
||||
actions = self.action_lut[material, cells]
|
||||
permissive_stamps = last_seen[actions != 2]
|
||||
if np.any(permissive_stamps < 0):
|
||||
raise ValueError("permissive TGS cell lacks source observation")
|
||||
oldest_permissive = (
|
||||
int(permissive_stamps.min()) if len(permissive_stamps) else bundle["time_ns"]
|
||||
)
|
||||
return (
|
||||
cells,
|
||||
actions,
|
||||
@@ -478,6 +484,7 @@ class JointGraph:
|
||||
"occupied": int(np.count_nonzero(states == 2)),
|
||||
"rejected": int(np.count_nonzero(states == 3)),
|
||||
"stale_cells": int(np.count_nonzero(stale)),
|
||||
"oldest_permissive_cell_source_ns": oldest_permissive,
|
||||
"state_counts": dict(Counter(int(x) for x in cells)),
|
||||
**({"diagnostic_cells": diagnostics} if diagnostics else {}),
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from pilot_freshness import assess, prepare_publication, receipt_view, validate_receipt
|
||||
from pilot_ipc import receive, send
|
||||
from pilot_queue import Mailbox
|
||||
from pilot_scheduler import GpuStage
|
||||
@@ -482,44 +483,11 @@ def run(args):
|
||||
proposals=proposals,
|
||||
detector_ms=(gpu_done - ddr_done) / 1e6,
|
||||
)
|
||||
# Late evidence remains inspectable but cannot authorize terrain.
|
||||
policy_age_ms = (time.monotonic_ns() - bundle["due_ns"]) / 1e6
|
||||
sensor_source_age_ms = max(
|
||||
bundle["lineage"]["pose_age_ms"] or 0,
|
||||
bundle["lineage"]["oldest_point_age_ms"] or 0,
|
||||
freshness_started = time.monotonic_ns()
|
||||
prepare_publication(
|
||||
scene, bundle, ddr_layer, epoch_id=args.run_id,
|
||||
now_ns=freshness_started,
|
||||
)
|
||||
scene["layer_freshness"] = {
|
||||
"segmentation": {
|
||||
**ddr_layer,
|
||||
"age_at_policy_ms": policy_age_ms + ddr_layer["source_age_ms"],
|
||||
},
|
||||
"detection": {
|
||||
"state": "current",
|
||||
"source_sequence": bundle["sequence"],
|
||||
"source_host_monotonic_ns": bundle["time_ns"],
|
||||
"source_age_ms": 0.0,
|
||||
"age_at_policy_ms": policy_age_ms,
|
||||
},
|
||||
"geometry": {
|
||||
"state": "current" if bundle["available"] else "unavailable",
|
||||
"source_sequence": bundle["sequence"],
|
||||
"source_host_monotonic_ns": bundle["time_ns"],
|
||||
"source_age_ms": sensor_source_age_ms,
|
||||
"age_at_policy_ms": policy_age_ms + sensor_source_age_ms,
|
||||
},
|
||||
}
|
||||
scene["oldest_required_input_age_ms"] = policy_age_ms + max(
|
||||
sensor_source_age_ms,
|
||||
ddr_layer["source_age_ms"],
|
||||
)
|
||||
scene["stale_at_publication"] = scene["oldest_required_input_age_ms"] > 250
|
||||
if scene["stale_at_publication"]:
|
||||
scene["policy_actions"] = [2] * len(scene["policy_actions"])
|
||||
scene["policy_counts"] = {
|
||||
"ALLOW_candidate": 0,
|
||||
"HIGH_COST": 0,
|
||||
"NO_GO": len(scene["policy_actions"]),
|
||||
}
|
||||
scene.update(
|
||||
sequence=bundle["sequence"],
|
||||
lineage=bundle["lineage"],
|
||||
@@ -534,6 +502,10 @@ def run(args):
|
||||
received = json.loads(encoded)
|
||||
if received["sequence"] != bundle["sequence"]:
|
||||
raise ValueError("collector identity mismatch")
|
||||
freshness = validate_receipt(received, bundle, epoch_id=args.run_id)
|
||||
checked_at = time.monotonic_ns()
|
||||
checked = assess(freshness, bundle=bundle, now_ns=checked_at)
|
||||
view = receipt_view(received, checked)
|
||||
finished = time.monotonic_ns()
|
||||
timing = {
|
||||
**scene["timing_ms"],
|
||||
@@ -549,6 +521,7 @@ def run(args):
|
||||
"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"],
|
||||
@@ -565,7 +538,9 @@ def run(args):
|
||||
x["metric_geometry"] is not None for x in scene["observations"]
|
||||
),
|
||||
"tgs_counts": scene["tgs_counts"],
|
||||
"policy_counts": scene["policy_counts"],
|
||||
"policy_counts": view["policy_counts"],
|
||||
"freshness_at_receipt": checked.to_dict(),
|
||||
"policy_counts_at_publication": scene["policy_counts"],
|
||||
"scene_bytes": len(encoded),
|
||||
"scene_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
}
|
||||
@@ -711,8 +686,8 @@ def run(args):
|
||||
and max(s.get("cgroup_memory_mib", 99999) for s in samples) <= 8192,
|
||||
"all_modalities_fresh": len(results) == args.frames
|
||||
and all(r["available"] for r in results),
|
||||
"layer_source_age_250ms": bool(results)
|
||||
and all(r["ddrnet_source_age_ms"] <= 250 for r in results),
|
||||
"all_six_layers_fresh_at_receipt": bool(results)
|
||||
and all(r["freshness_at_receipt"]["fresh_complete"] for r in results),
|
||||
"network_end_to_end_qualified": False,
|
||||
}
|
||||
report["profile_realtime_qualified"] = False
|
||||
|
||||
Reference in New Issue
Block a user