feat(perception): define stream-first profile and qualification contracts

This commit is contained in:
DCCONSTRUCTIONS
2026-09-02 00:59:47 +03:00
parent 5a78c997ac
commit 4e04d06191
4 changed files with 1043 additions and 0 deletions
@@ -0,0 +1,175 @@
{
"schema_version": "missioncore.perception-profile-candidate/v1",
"profile_id": "k1-perception-ddrnet39-rfdetr-tgs-prototype/v1",
"display_name": "K1 Perception — DDRNet-39 + RF-DETR + TGS",
"status": "stage-1-candidate-not-installed-not-qualified",
"experiment_policy": {
"bounded_lab_overload_allowed": true,
"retain_profiles_that_fail_current_hardware_realtime": true,
"realtime_failure_blocks_experimental_packaging": false,
"qualification_scope": ["profile-version", "effective-config", "hardware", "source", "transport"],
"preserve_original_source_clock": true,
"report_drops_and_staleness": true,
"overload_is_realtime_pass": false,
"future_onboard_placement_requires_new_measurement": true
},
"packaging": {
"image": "ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1",
"image_sha256": null,
"single_container": true,
"external_model_server_required": false,
"host_model_cache_required": false,
"runtime_model_download_allowed": false,
"model_environment_isolation": "supervised-processes-one-gpu-scheduler",
"common_interpreter_required": false
},
"hardware_baseline": {
"gpu": "NVIDIA GeForce RTX 4090",
"active_profiles_per_worker": 1,
"sources_per_worker": 1,
"gpu_inference_policy": "serialized",
"other_gpu_workloads_allowed": false
},
"cpu_execution_policy": {
"OPENBLAS_NUM_THREADS": 1,
"OMP_NUM_THREADS": 1,
"MKL_NUM_THREADS": 1,
"reason": "bounded-single-thread-numeric-libraries-before-whole-graph-qualification"
},
"input_contract": {
"schema_version": "missioncore.perception-stream-start/v1",
"channels": ["camera", "point-cloud", "pose"],
"camera": {
"width": 800,
"height": 600,
"projection": "KB4",
"decoded_pixel_format": "bgr8",
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
"valid_fov_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
},
"point_cloud": {
"representation": "vendor-registered-current-increment",
"coordinate_frame": "map",
"units": "metres",
"scalar_type": "float32-little-endian",
"raw_fields_retained": true
},
"pose": {"parent_frame": "map", "child_frame": "sensor"},
"clock_mapping_required": true,
"source_end_or_duration_required": false,
"full_source_transfer_required": false,
"full_source_hash_pass_required": false,
"full_decode_or_png_cache_required": false,
"previous_perception_artifacts_allowed": false
},
"stream_policy": {
"replay_speed": 1.0,
"release_clock": "original-source-timestamps",
"inference_stride": 1,
"sampling_status": "strict-every-frame-initial-baseline-no-silent-multirate",
"first_result_requires_eof": false,
"lookahead_allowed": false,
"reconnect": "new-epoch-drop-old-backlog-reacquire-codec-keyframe",
"overflow": "bounded-drop-with-ledger-degraded-and-failed-runtime-gate",
"archive_export_on_critical_path": false,
"transport_candidate": "grpc-bidirectional-protobuf-binary-payloads",
"transport_qualified": false
},
"components": {
"segmentation": {
"model": "ddrnet_39-goose-fine-64",
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"precision": "existing-fp32-baseline",
"preprocessing": "center-600-square-to-512-rgb-tensor-0-1",
"outside_crop": "undefined-not-free",
"class_count": 64,
"runtime": "python-3.9-pytorch-1.13.1-cu117-super-gradients-3.2.0"
},
"objects": {
"model": "rf_detr_large_native_kb4",
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
"runtime": "tensorrt-11-fp16-native-uint8",
"minimum_score": 0.25,
"minimum_box_area_pixels": 64,
"maximum_box_area_fraction": 0.5,
"minimum_valid_fov_fraction": 0.5,
"require_center_inside_valid_fov": true,
"required_existing_classes": ["person", "cat", "dog"],
"fine_static_object_classifier_required": false,
"filter_quality_status": "existing-unchanged-near-large-and-small-object-risk-unqualified"
},
"geometry": {
"online_surface": "K1LocalSurfaceShadowEstimator",
"prepared_local_surface_allowed": false,
"generic_static_class": "static.unknown",
"detected_object_range": "median-camera-z-of-owned-current-points/v1",
"geometry_only_range": "nearest-euclidean-sensor-distance/v1",
"vehicle_clearance_claim": false,
"missing_support": "unavailable-not-zero-not-free"
},
"motion": {
"reuse": "BoundedSpatialTemporalProvider+ClassIndependentMotionEstimator",
"semantic_class_implies_motion": false
},
"costmap": {
"algorithm": "TRAVEL-TGS",
"revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4",
"execution": "cpu",
"rolling_history_seconds": 1.0,
"states": ["UNOBSERVED", "GROUND_SUPPORT", "NONGROUND_OCCUPIED", "UNKNOWN_REJECTED"],
"policy_input": true
},
"policy": {
"scope": "rural-prototype-advisory-only",
"hard_surface": "ALLOW-candidate-subject-to-geometry-and-freshness",
"fine_road_sidewalk_bikeway_distinction_required": false,
"effective_material_rules_required": true,
"unknown_or_occupied_overrides_material_allowance": true,
"sensor_to_vehicle_mount": "simulation-only-until-measured"
}
},
"reference_files": [
{"path": "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json", "sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6"},
{"path": "config/perception/lab-v1-goose-vegetation-benchmark-v1.json", "sha256": "96a427a8baae387b827ec9c0bf7ca42e3fb9114b8fa9a8671bbc9d10877670b9"},
{"path": "config/perception/lab-v1-vegetation-provider-label-map-v1.json", "sha256": "f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"},
{"path": "config/perception/lab-v1-vegetation-mission-policy-v1.json", "sha256": "b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},
{"path": "config/perception/m49-tgs-full-shadow-v1.json", "sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"},
{"path": "config/perception/m4-geometry-association-v1.json", "sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"}
],
"reference_usage": "reuse-model-parameters-and-algorithms-only-not-recording-ids-or-precomputed-results",
"realtime_contract": {
"schema_version": "missioncore.perception-realtime-contract/v1",
"budget_status": "engineering-targets-worker-pilot-measured-not-whole-path-qualified-not-safety-approved",
"budgets": {
"maximum_start_metadata_bytes": 65536,
"maximum_chunk_bytes": 1048576,
"maximum_inflight_bytes": 16777216,
"maximum_pending_camera_frames": 2,
"maximum_codec_preroll_ms": 500.0,
"maximum_pose_age_ms": 100.0,
"maximum_clock_uncertainty_ms": 5.0,
"maximum_release_lag_ms": 25.0,
"maximum_output_age_p95_ms": 125.0,
"maximum_output_age_p99_ms": 125.0,
"maximum_layer_age_ms": 250.0,
"maximum_first_result_ms": 1000.0,
"maximum_warmup_seconds": 120.0,
"maximum_stop_seconds": 5.0,
"maximum_vram_mib": 22000,
"maximum_rss_mib": 8192,
"maximum_backlog_growth_ms": 25.0
},
"budget_origin": "125ms-historical-envelope-retained-as-target-now-applied-to-whole-path; other-limits-explicit-initial-engineering-guards",
"capacity_drop_count_max": 0,
"unaccounted_input_count_max": 0,
"full_input_transfer_before_first_result_allowed": false,
"independent_quality_and_physical_live_status": "pending"
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
@@ -0,0 +1,236 @@
# ADR 0049 — Stream-first full perception profiles
Date: 2026-09-01. Status: stage-1 contract candidate; not a runtime cutover.
Execution order remains the four stages in
[the ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
## Decision and owner intent
Observatory LABs test the real-time behavior of a complete perception profile,
not just the quality of an offline export. A recording replaces the physical
source, preserving original release timing at 1×. It must not enable a different
batch implementation. The first profile contains DDRNet-39 GOOSE, RF-DETR native,
online LiDAR geometry/ranges, temporal/motion, CPU TRAVEL TGS/costmap and advisory
policy. One dedicated RTX 4090 owns one active full profile.
The first rural prototype accepts existing coarse `hard_surface`, including
asphalt, sidewalk, bikeway and cobble. Existing `static_obstacle`/`static.unknown`
is sufficient. No new bollard classifier, extra segmenter, road/sidewalk taxonomy
or annotation campaign precedes this prototype. Other material rules remain an
explicit effective mission configuration; rural context alone is not permission
for all vegetation or soil. Geometry/unknown/freshness overrides material allowance.
EoMT is preserved as a separate future profile. It is not loaded with DDRNet.
RF-DETR and DDRNet are two required components of the same first profile: one
supervisor and serialized GPU scheduling, not competing profile jobs.
## CURRENT → TARGET / ownership
| Boundary | Current evidence | Decision / stage |
| --- | --- | --- |
| Portable definitions | `portable_run_definitions.py` owns pins, resources and admission | KEEP identity/sealing; EXTEND stream capabilities and qualification in stage 3 |
| Source requirements | Recorded-only requirements include seekability, one media epoch and init digest | REPLACE as live admission criteria with channel/format/calibration/clock requirements; keep init digest as per-stream integrity |
| Job queue | `recorded_jobs.py` has durable claims but global single-active-job scope | KEEP durable queue; EXTEND worker-scoped fencing, not unbounded GPU concurrency |
| Worker agent | Blocking `execute(job)` and final publication | EXTEND incremental lifecycle and cancellation; no per-LAB agent |
| Runtime | `portable_worker_runtime.py:735` materializes before execution | NEW stream runtime in stage 2; never call this materializer in the real-time path |
| Source transport | Whole camera archive download/hash/extraction before use | REPLACE startup barrier with bounded metadata, chunks and incremental integrity |
| Installed LAB V1 | prepare → complete EoMT → complete DDRNet → assemble | Legacy evidence only; not the new profile and not real-time qualified |
| Camera/source replay | `recorded_source.py` has useful pacing and bounded prefetch, but source-specific timeline and paths | REUSE mechanisms; generic unknown-duration source adapter, no route-sized prefetch |
| Existing live ingress | `LivePerceptionIngress`, `LiveIngressEvent.wire_bytes`, `LiveSensorSynchronizer` already provide raw-first modality queues, hashes, epochs and bounded binding | REUSE, not a second acquisition system; extend worker/profile fencing and aggregate byte limits; map existing envelopes to the selected transport |
| Local surface | `RecordedGeometryStore` reads prepared NPZ | REUSE `K1LocalSurfaceShadowEstimator.process` and `lidar_local_surface_geometry`; feed current points/pose |
| Geometry/ranges | Current point association, exclusive ownership and geometry-only clusters exist | REUSE algorithms; remove recorded-store dependency and expose estimator/frame identity |
| Temporal/motion | `BoundedSpatialTemporalProvider`, `ClassIndependentMotionEstimator` | REUSE with current-run inputs and reset on epoch/lease change |
| TGS | Core algorithm reusable; old runners read a full schedule, some require exactly 4489 frames | REUSE TGS parameters/core; REPLACE schedule/file CLI with incremental input |
| Fusion | M49 TGS can be a neighboring shadow with `tgs_modifies_reference_graph_state=false` | TGS and semantics must influence the current advisory scene, not only viewer composition |
| Publication | Verified immutable result/publication/recovery exist | KEEP asynchronous final sealing; live results do not await it |
| Frontend | Setup/job/result surfaces exist, historical M49 viewer links other results | EXTEND common renderer capabilities with current-run layer references and freshness in stage 3 |
| Recording archive | Source identities and raw evidence must be retained | KEEP; create metadata/index/integrity during acquisition or incrementally, not via preflight full scan |
No changes to the active registry, old queue, source endpoint, UI or canonical
local server are made by this ADR or the stage-1 validators.
## Versioned handshake and observations
`missioncore.perception-stream-start/v1` is represented by `StreamStart` in
`src/k1link/perception/realtime_contract.py`. It contains run/source/worker/epoch,
lease generation, profile/image/effective-config/calibration identities, clock
domain, input mode and channel declarations. The first profile requires camera,
point-cloud and pose. Temporary absence of a sample is an explicit modality
outcome, not removal of a required capability.
It deliberately has no total source bytes, frame count, total duration, whole
archive, command, environment, complete member inventory or required EOF. The
initial metadata budget is 64 KiB. Large constant model assets and FOV masks
belong inside the image; calibration references resolve to bounded metadata.
The stage-2 wire mapping must carry:
- Run/epoch/lease binding on every message; source/channel sequence, capture
timestamp and clock domain; independently verifiable payload length/hash.
- Camera codec initialization and then incremental encoded access units, or
bounded fragments thereof. Native decoded representation is BGR8 800×600 KB4.
Do not send an entire fMP4 epoch as one observation or turn all frames into PNGs.
- Current vendor-registered map point increments with point IDs, coordinate
frame, units, scalar layout and retained raw attributes; map→sensor pose and
calibration identity. The adapter owns vendor decoding; the profile is not a
new K1 protocol implementation and cannot command the scanner.
- Explicit unavailable/gap/end/cancel messages. End is a termination event,
never a prerequisite for first inference. Final recording inventory/hash can
be sealed after consumption; per-chunk integrity is verified before use.
- Integer nanoseconds use protobuf integer fields; any JSON projection uses
decimal strings for 64-bit times rather than lossy JavaScript numbers.
Maximum payload fragment: 1 MiB; total application inflight bytes: 16 MiB;
pending decoded camera frames: 2. Fragment reassembly is included in that budget,
with a deadline and incomplete-chunk accounting. Decoded image/tensor, cloud,
costmap and archive sinks need their own measured bounds inside the RSS budget.
Transport flow control alone is not a memory bound or a freshness policy.
gRPC bidirectional streaming is the first transport candidate, not an already
measured implementation. HTTP control-plane operations remain. The browser does
not connect directly to model RPCs. One authenticated data-plane endpoint feeds
the supervised profile; model processes have no arbitrary external network.
Protocol/codec selection must be measured on actual payload sizes in stage 2;
changing an archive POST to gRPC without changing execution is not acceptance.
Existing `missioncore.live-perception-wire/v1` and result wire v2 are migration
inputs: reuse their semantics and raw-first producer hooks. Their modality count
caps alone permit more than the proposed 16 MiB aggregate inflight budget; an
explicit byte bound is required. No duplicate new scanner feed is introduced.
## Time and lifecycle
Open → validate bounded metadata/claim → warm models → Ready → start the replay
clock → observations and incremental scenes → stop/drain bounded state → close
and asynchronously seal the immutable receipt. A live source already producing
data does not queue its entire warmup history: readiness begins at a current
decodable keyframe, with the skipped interval recorded.
Replay release uses original source intervals, not a convenient fixed 12 Hz and
not the processing speed. A slow consumer must not stretch source time to make
inference look real-time. Overload may drop bounded work to preserve liveness,
but the dropped observations remain in the ledger and fail the strict initial
performance target. Initial baseline is every camera frame, stride 1. Choosing
multirate later changes the explicit profile contract; a retained mask is not a
new inference and cannot receive a fresh capture timestamp.
UTC identifies the run; monotonic clocks measure local durations. Cross-host
age requires a clock mapping and its uncertainty. No direct subtraction of
unrelated host monotonic clocks. The common source timeline is separate from
worker wall-clock time. Causality is bounded by the released-observation
watermark at decision time, not by whatever later samples exist in the file.
Each layer retains its own channel/sequence/time; camera anchors and pose/cloud
association times remain separately visible. Future file look-ahead and offline
nearest-pose lookup across unreleased data are forbidden.
Lease expiry, cancellation and epoch changes fence the previous producer. No
new profile may take the GPU until old processes and work have stopped. A
reconnect creates a new epoch, discards stale backlog, resets temporal/rolling
state and reacquires codec initialization/keyframe. It never resends a stale
motor action; this profile has no motor actions at all.
## Outputs and interpretation
Every scene accounts for segmentation, objects, geometry, motion, costmap and
policy exactly once, with current/held/stale/unavailable state and current-run
payload identity. Existing source/object/obstacle/temporal/map/threat contracts
remain the domain vocabulary. `LayerEvidence` adds freshness/completeness checks,
not a second semantic ontology. In stage 2, layer manifests also carry their full
input reference sets; a single anchor does not replace camera/cloud/pose lineage.
DDRNet currently crops the center 600×600 to 512×512; outside that ROI is undefined,
not hard_surface/free. RF-DETR accepts the full 800×600 native raster with its
pinned valid-FOV handling. Their coordinates must be related explicitly.
RF-DETR's existing filter emits person/cat/dog, but minimum box area 64 px,
maximum box fraction 0.5, FOV fraction 0.5 and required valid center create known
small-animal/close-large-object edge cases. They are retained and documented,
not silently retuned or presented as proven field quality.
Detected-object range is the median camera-Z of owned current support points;
geometry-only range is nearest Euclidean distance from the sensor. Neither is
automatically clearance from the physical vehicle body. Missing/ambiguous
support produces unavailable range, not zero/infinity. Generic static obstacles
survive absence of a detector class name.
TGS keeps ground/occupied/rejected/unobserved separate. Ground support alone is
not a traversability or actuation decision. Policy can produce an advisory
allowed-candidate/high-cost/blocked/unknown outcome with explanation. No planner,
physical motor command, autonomous driving acceptance or mission configurator
implementation is introduced.
## Package/dependency decision
The candidate manifest is
`config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json`.
It is not installed into the active portable registry and has no invented image
digest. Model/checkpoint/config identities are pinned; source session IDs and old
derived output paths in reference experiments are not inherited requirements.
Select process/environment isolation inside one future image: Python 3.12
supervisor and geometry, RF-DETR TensorRT 11 runtime, existing Python 3.9 / Torch
1.13.1 cu117 / super-gradients 3.2.0 environment, and CPU C++ TGS. Exchange bounded
shared-memory/IPC payload references; one supervisor serializes GPU work. This
avoids a forced dependency upgrade or checkpoint conversion in stage 1. Image
assembly of the final standalone package and the full schedule pilot remain
explicit implementation evidence. A temporary common-base image passed separate
DDRNet, TensorRT, Python 3.12 geometry and C++ TGS execution probes; DDRNet masks
matched on 64 frames. This proves bounded ABI/execution compatibility in that
image, not jointly resident models, the supervisor/IPC or a complete profile.
The probes still mount pinned assets explicitly; they are not standalone proof.
The temporary Triton base lacks Python grpc/protobuf, cv2 and TensorRT bindings;
native trtexec/server are present. The actual supervisor/transport environment
must explicitly include and validate its dependencies. Numeric-library thread
limits are pinned to one after the bounded synthetic local-surface comparison
(mean 198.0 ms default versus 63.2 ms with limits); algorithm thresholds were not
changed. Real-cloud timing, output parity and whole-graph scheduling remain gates.
The initial DDRNet image-only probe failed before inference: the installed image
does not contain the checkpoint at its logical asset path. The bounded baseline
therefore uses an explicit read-only pinned checkpoint and runner mount. This is
valid component measurement, but fails the target's standalone packaging claim.
Super-gradients also needs a writable log directory; a bounded temporary log
mount was required. The new image must declare scratch areas explicitly without
requiring a writable host home, code checkout or implicit weight cache.
## Preregistered engineering acceptance
Owner clarification, 2026-09-02 MSK: bounded laboratory overload is an admissible
experimental outcome. Keep useful profiles that fail the current 4090 target;
do not block reusable runtime/packaging work solely on that performance result.
Functional execution, experimental availability, quality and real-time
qualification are separate dimensions. Qualification belongs to a measured
profile/config/hardware/source/transport combination, not permanently to a
model name. More powerful Worker/onboard placement is a future retest target,
not a claim that existing latency or internal IPC disappears automatically.
The original budgets and failed measurements below remain unchanged. No slowed
source clock, hidden drops, stale-as-current evidence or actuation is authorized.
The manifest freezes an initial engineering candidate: p95/p99 full output age
≤125 ms, required-layer age ≤250 ms, release lag ≤25 ms, first incremental result
≤1 s after source admission, warmup ≤120 s, stop ≤5 s, VRAM ≤22000 MiB, RSS ≤8192
MiB. First/last-window backlog growth ≤25 ms and no capacity drops, failures,
expired selected frames or unaccounted observations. These are prototype targets,
not physical braking/safety limits or claims of achieved performance.
`ReplayMeasurements`/`realtime_failures` reject full-source preload, EOF-only
results, slowed replay, incomplete accounting, hidden missing layers and budget
violations. Declared source gaps must be verified from the input ledger; they
produce explicit degraded scenes and are not counted as fully fresh perception.
At least one fully evaluable scene is required. A component FPS figure cannot
populate this whole-path receipt. A quality comparison or successful export is
not a substitute for the real-time gate.
Every observation receives a terminal ledger outcome. Track ingress/release,
selection, decode, inference, completion, emission, drop/expiry/failure and source
gaps separately. Store startup separately from steady state; record payload bytes,
queue high-water marks, clock error, RSS/VRAM, image/weights/config/source/equipment
identities. Export and UI encoding cannot block inference through an unlimited
queue. A receiver-side render/receipt timestamp, not just Worker completion, ends
the end-to-end interval.
## Evidence and remaining stage-1 work
See `experiments/perception/PERCEPTION_STREAM_STAGE1_2026-09-01.md` for measured
component results, bounded validation, Worker maintenance and remaining gates.
Neither this ADR nor passing synthetic contract tests marks the new runtime ready.
+354
View File
@@ -0,0 +1,354 @@
"""Stage-1, transport-neutral contract for stream-first perception.
These executable invariants do not start models, replace the legacy queue, or
claim that a stream transport exists. Replay and live must implement the same
contract in stage 2. Existing perception objects remain the domain vocabulary.
"""
from __future__ import annotations
import math
import re
from dataclasses import asdict, dataclass
from typing import Final, cast
STREAM_START_SCHEMA: Final = "missioncore.perception-stream-start/v1"
REALTIME_CONTRACT_SCHEMA: Final = "missioncore.perception-realtime-contract/v1"
REQUIRED_CHANNELS: Final = ("camera", "point-cloud", "pose")
REQUIRED_LAYERS: Final = ("segmentation", "objects", "geometry", "motion", "costmap", "policy")
_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
_SHA: Final = re.compile(r"^[a-f0-9]{64}$")
class RealtimeContractError(ValueError):
"""The caller attempted an ambiguous, unbounded or non-causal contract."""
def _integer(value: object, name: str, *, minimum: int = 0) -> int:
if type(value) is not int or value < minimum:
raise RealtimeContractError(f"{name} must be an integer >= {minimum}")
return value
def _number(value: object, name: str, *, minimum: float = 0.0) -> float:
if type(value) not in (int, float):
raise RealtimeContractError(f"{name} must be a finite number")
number = float(cast(int | float, value))
if not math.isfinite(number) or number < minimum:
raise RealtimeContractError(f"{name} must be finite and >= {minimum}")
return number
def _identifier(value: object, name: str) -> None:
if not isinstance(value, str) or _ID.fullmatch(value) is None:
raise RealtimeContractError(f"{name} is invalid")
def _digest(value: object, name: str) -> None:
if not isinstance(value, str) or _SHA.fullmatch(value) is None:
raise RealtimeContractError(f"{name} must be a SHA-256 identity")
@dataclass(frozen=True, slots=True)
class StreamStart:
"""Bounded handshake; deliberately no source duration, EOF or file inventory.
The epoch is reset on reconnect. Source hashes belong to chunk admission
and the asynchronous final receipt, not a full-file startup verification.
Calibration/config references resolve to bounded, authenticated metadata.
"""
run_id: str
source_id: str
worker_id: str
epoch_id: str
lease_generation: int
profile_sha256: str
image_sha256: str
effective_config_sha256: str
calibration_sha256: str
clock_domain_id: str
input_mode: str
channels: tuple[str, ...] = REQUIRED_CHANNELS
def __post_init__(self) -> None:
for name in ("run_id", "source_id", "worker_id", "epoch_id", "clock_domain_id"):
_identifier(getattr(self, name), name)
_integer(self.lease_generation, "lease_generation", minimum=1)
for name in (
"profile_sha256",
"image_sha256",
"effective_config_sha256",
"calibration_sha256",
):
_digest(getattr(self, name), name)
if self.input_mode not in ("recorded-source-paced", "live"):
raise RealtimeContractError("only source-paced replay and live are admitted")
if self.channels != REQUIRED_CHANNELS:
raise RealtimeContractError("the full profile requires camera, point-cloud and pose")
def to_dict(self) -> dict[str, object]:
value = asdict(self)
value["schema_version"] = STREAM_START_SCHEMA
value["channels"] = list(self.channels)
return value
@classmethod
def from_dict(cls, value: object) -> StreamStart:
if not isinstance(value, dict):
raise RealtimeContractError("stream start must be an object")
fields = set(cls.__dataclass_fields__)
if set(value) != fields | {"schema_version"}:
raise RealtimeContractError("stream start fields changed; full-source fields forbidden")
if value["schema_version"] != STREAM_START_SCHEMA:
raise RealtimeContractError("stream start schema changed")
if not isinstance(value["channels"], list):
raise RealtimeContractError("channels must be an array")
arguments = {name: value[name] for name in fields}
arguments["channels"] = tuple(value["channels"])
return cls(**arguments)
@dataclass(frozen=True, slots=True)
class RealtimeBudgets:
"""Preregistered engineering limits, never vehicle-safety thresholds."""
maximum_start_metadata_bytes: int
maximum_chunk_bytes: int
maximum_inflight_bytes: int
maximum_pending_camera_frames: int
maximum_codec_preroll_ms: float
maximum_pose_age_ms: float
maximum_clock_uncertainty_ms: float
maximum_release_lag_ms: float
maximum_output_age_p95_ms: float
maximum_output_age_p99_ms: float
maximum_layer_age_ms: float
maximum_first_result_ms: float
maximum_warmup_seconds: float
maximum_stop_seconds: float
maximum_vram_mib: int
maximum_rss_mib: int
maximum_backlog_growth_ms: float
def __post_init__(self) -> None:
for name in (
"maximum_start_metadata_bytes",
"maximum_chunk_bytes",
"maximum_inflight_bytes",
"maximum_pending_camera_frames",
"maximum_vram_mib",
"maximum_rss_mib",
):
_integer(getattr(self, name), name, minimum=1)
for name in set(self.__dataclass_fields__) - {
"maximum_start_metadata_bytes",
"maximum_chunk_bytes",
"maximum_inflight_bytes",
"maximum_pending_camera_frames",
"maximum_vram_mib",
"maximum_rss_mib",
}:
_number(getattr(self, name), name, minimum=0.001)
if self.maximum_chunk_bytes > self.maximum_inflight_bytes:
raise RealtimeContractError("one chunk exceeds the inflight byte budget")
if not (
self.maximum_output_age_p95_ms
<= self.maximum_output_age_p99_ms
<= self.maximum_layer_age_ms
):
raise RealtimeContractError("output percentile and freshness budgets disagree")
@classmethod
def from_dict(cls, value: object) -> RealtimeBudgets:
if not isinstance(value, dict) or set(value) != set(cls.__dataclass_fields__):
raise RealtimeContractError("realtime budget fields changed")
return cls(**value)
@dataclass(frozen=True, slots=True)
class LayerEvidence:
"""Freshness metadata wrapping existing domain payloads, not a new ontology.
source_time_ns is mapped into the run's common source timeline. It is not
a worker clock. A retained layer must keep its original timestamp/sequence.
Network end-to-end age is measured separately, with clock uncertainty.
"""
layer: str
epoch_id: str
source_sequence: int | None
source_time_ns: int | None
state: str
payload_sha256: str | None
def __post_init__(self) -> None:
if self.layer not in REQUIRED_LAYERS:
raise RealtimeContractError("unknown full-profile layer")
_identifier(self.epoch_id, "epoch_id")
if self.state not in ("current", "held", "stale", "unavailable"):
raise RealtimeContractError("unknown evidence currentness")
if self.state == "unavailable":
if any(
x is not None
for x in (self.source_sequence, self.source_time_ns, self.payload_sha256)
):
raise RealtimeContractError("unavailable evidence cannot claim a payload")
else:
_integer(self.source_sequence, "source_sequence")
_integer(self.source_time_ns, "source_time_ns")
_digest(self.payload_sha256, "payload_sha256")
def validate_scene_layers(
layers: tuple[LayerEvidence, ...],
*,
epoch_id: str,
source_time_ns: int,
maximum_layer_age_ms: float,
) -> None:
"""Reject missing layers, cross-epoch reuse, look-ahead and relabelled stale data.
Explicit unavailable/stale output remains valid evidence, not permission to
move. Deciding hard_surface/occupied precedence belongs to the existing
policy evaluator, whose results cannot grant actuation.
"""
_identifier(epoch_id, "epoch_id")
_integer(source_time_ns, "source_time_ns")
_number(maximum_layer_age_ms, "maximum_layer_age_ms", minimum=0.001)
if len(layers) != len(REQUIRED_LAYERS) or {x.layer for x in layers} != set(REQUIRED_LAYERS):
raise RealtimeContractError("a scene must account for every required layer exactly once")
for layer in layers:
if layer.epoch_id != epoch_id:
raise RealtimeContractError("layer belongs to another stream epoch")
if layer.source_time_ns is None:
continue
age_ns = source_time_ns - layer.source_time_ns
if age_ns < 0:
raise RealtimeContractError("future observations are not causal input")
if age_ns > maximum_layer_age_ms * 1_000_000 and layer.state in ("current", "held"):
raise RealtimeContractError("expired evidence cannot be current or held")
@dataclass(frozen=True, slots=True)
class ReplayMeasurements:
"""Measured whole-path receipt; component timings alone cannot populate it.
All age figures include source release, transfer, decode, graph processing
and delivery to the application. The slowest required layer determines
full-scene age. Qualification is bound to identities by the caller.
"""
source_frames: int
declared_source_gap_frames: int
terminal_frames: int
fresh_complete_scenes: int
capacity_drops: int
expired_frames: int
failed_frames: int
unexpected_unavailable_layers: int
full_source_preloaded: bool
results_before_end_of_source: bool
replay_speed: float
maximum_release_lag_ms: float
output_age_p95_ms: float
output_age_p99_ms: float
maximum_layer_age_ms: float
first_result_ms: float
warmup_seconds: float
stop_seconds: float
peak_inflight_bytes: int
peak_pending_camera_frames: int
peak_vram_mib: int
peak_rss_mib: int
clock_uncertainty_ms: float
backlog_growth_ms: float
computed_layers: tuple[str, ...]
def __post_init__(self) -> None:
integer_fields = {
"source_frames",
"declared_source_gap_frames",
"terminal_frames",
"fresh_complete_scenes",
"capacity_drops",
"expired_frames",
"failed_frames",
"unexpected_unavailable_layers",
"peak_inflight_bytes",
"peak_pending_camera_frames",
"peak_vram_mib",
"peak_rss_mib",
}
boolean_fields = {"full_source_preloaded", "results_before_end_of_source"}
for name in integer_fields:
_integer(getattr(self, name), name)
for name in boolean_fields:
if type(getattr(self, name)) is not bool:
raise RealtimeContractError(f"{name} must be boolean")
for name in (
set(self.__dataclass_fields__) - integer_fields - boolean_fields - {"computed_layers"}
):
_number(getattr(self, name), name)
if (
self.source_frames == 0
or self.declared_source_gap_frames > self.source_frames
or self.fresh_complete_scenes > self.terminal_frames
):
raise RealtimeContractError("measurement frame counts are invalid")
if self.output_age_p95_ms > self.output_age_p99_ms:
raise RealtimeContractError("measurement percentiles are invalid")
if not isinstance(self.computed_layers, tuple) or any(
not isinstance(layer, str) for layer in self.computed_layers
):
raise RealtimeContractError("computed layers must be an immutable tuple of names")
if len(set(self.computed_layers)) != len(self.computed_layers):
raise RealtimeContractError("computed layers are duplicated")
def realtime_failures(value: ReplayMeasurements, limits: RealtimeBudgets) -> tuple[str, ...]:
"""Return every failed strict replay gate, not a product-ready flag.
First baseline is every source camera frame, no silent stride. A later
multirate profile needs its own explicit contract and measured qualification.
Source gaps are bound to the source ledger, never inferred from model
failures. They must still produce explicit degraded scenes and terminal
accounting; no complete fresh-scene claim is allowed for those frames.
"""
failures: list[str] = []
for failed, reason in (
(value.full_source_preloaded, "full-source-preload"),
(not value.results_before_end_of_source, "no-incremental-result"),
(value.replay_speed != 1.0, "source-clock-not-1x"),
(value.terminal_frames != value.source_frames, "incomplete-input-accounting"),
(
value.fresh_complete_scenes != value.source_frames - value.declared_source_gap_frames,
"incomplete-fresh-scenes",
),
(value.fresh_complete_scenes == 0, "no-evaluable-scene"),
(value.capacity_drops != 0, "capacity-drops"),
(value.expired_frames != 0, "expired-frames"),
(value.failed_frames != 0, "failed-frames"),
(value.unexpected_unavailable_layers != 0, "required-layer-unavailable"),
(set(value.computed_layers) != set(REQUIRED_LAYERS), "full-profile-not-computed"),
):
if failed:
failures.append(reason)
for measurement, budget in (
("maximum_release_lag_ms", "maximum_release_lag_ms"),
("output_age_p95_ms", "maximum_output_age_p95_ms"),
("output_age_p99_ms", "maximum_output_age_p99_ms"),
("maximum_layer_age_ms", "maximum_layer_age_ms"),
("first_result_ms", "maximum_first_result_ms"),
("warmup_seconds", "maximum_warmup_seconds"),
("stop_seconds", "maximum_stop_seconds"),
("peak_inflight_bytes", "maximum_inflight_bytes"),
("peak_pending_camera_frames", "maximum_pending_camera_frames"),
("peak_vram_mib", "maximum_vram_mib"),
("peak_rss_mib", "maximum_rss_mib"),
("clock_uncertainty_ms", "maximum_clock_uncertainty_ms"),
("backlog_growth_ms", "maximum_backlog_growth_ms"),
):
if getattr(value, measurement) > getattr(limits, budget):
failures.append(measurement)
return tuple(failures)
+278
View File
@@ -0,0 +1,278 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import asdict, replace
from pathlib import Path
import pytest
from k1link.compute.live_perception import LivePerceptionIngress
from k1link.perception.realtime_contract import (
REQUIRED_LAYERS,
LayerEvidence,
RealtimeBudgets,
RealtimeContractError,
ReplayMeasurements,
StreamStart,
realtime_failures,
validate_scene_layers,
)
ROOT = Path(__file__).resolve().parents[1]
PROFILE = ROOT / "config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json"
def _profile() -> dict:
return json.loads(PROFILE.read_text())
def _budgets() -> RealtimeBudgets:
return RealtimeBudgets.from_dict(_profile()["realtime_contract"]["budgets"])
def _start() -> StreamStart:
return StreamStart(
run_id="run-1",
source_id="source-a",
worker_id="worker-006",
epoch_id="epoch-1",
lease_generation=1,
profile_sha256="a" * 64,
image_sha256="b" * 64,
effective_config_sha256="c" * 64,
calibration_sha256="d" * 64,
clock_domain_id="source-monotonic",
input_mode="recorded-source-paced",
)
def _measurements() -> ReplayMeasurements:
return ReplayMeasurements(
source_frames=100,
declared_source_gap_frames=0,
terminal_frames=100,
fresh_complete_scenes=100,
capacity_drops=0,
expired_frames=0,
failed_frames=0,
unexpected_unavailable_layers=0,
full_source_preloaded=False,
results_before_end_of_source=True,
replay_speed=1.0,
maximum_release_lag_ms=10.0,
output_age_p95_ms=80.0,
output_age_p99_ms=100.0,
maximum_layer_age_ms=110.0,
first_result_ms=500.0,
warmup_seconds=20.0,
stop_seconds=1.0,
peak_inflight_bytes=2_000_000,
peak_pending_camera_frames=2,
peak_vram_mib=12000,
peak_rss_mib=4000,
clock_uncertainty_ms=1.0,
backlog_growth_ms=0.0,
computed_layers=REQUIRED_LAYERS,
)
def _layers() -> tuple[LayerEvidence, ...]:
return tuple(
LayerEvidence(name, "epoch-1", 0, 0, "current", "f" * 64) for name in REQUIRED_LAYERS
)
def test_handshake_needs_no_source_size_duration_or_end() -> None:
value = _start()
assert StreamStart.from_dict(value.to_dict()) == value
assert len(json.dumps(value.to_dict()).encode()) < _budgets().maximum_start_metadata_bytes
assert replace(value, input_mode="live").channels == value.channels
assert replace(value, worker_id="worker-007").worker_id == "worker-007"
def test_existing_raw_first_ingress_delivers_without_session_end() -> None:
ingress = LivePerceptionIngress()
try:
ingress.begin_session("synthetic-source")
ingress.open_consumer("synthetic-worker")
assert ingress.take_next("synthetic-worker", timeout=0).modality == "control"
assert ingress.publish(
modality="camera-frame",
source_id="camera",
source_sequence=0,
captured_at_epoch_ns=1,
received_monotonic_ns=1,
payload=b"synthetic-frame",
)
event = ingress.take_next("synthetic-worker", timeout=0)
assert event is not None and event.payload == b"synthetic-frame"
assert ingress.snapshot()["active"] is True
finally:
ingress.close()
@pytest.mark.parametrize(
"field",
[
"total_bytes",
"frame_count",
"source_duration_ns",
"source_bundle_sha256",
"source_archive",
"source_members",
"wait_for_eof",
"command",
],
)
def test_full_recording_and_execution_fields_are_not_start_requirements(field: str) -> None:
value = _start().to_dict()
value[field] = "not-an-input-to-the-stream-handshake"
with pytest.raises(RealtimeContractError, match="fields"):
StreamStart.from_dict(value)
@pytest.mark.parametrize(
"change",
[
{"lease_generation": 0},
{"lease_generation": True},
{"epoch_id": ""},
{"image_sha256": "latest"},
{"input_mode": "offline-batch"},
{"channels": ("camera",)},
{"calibration_sha256": None},
],
)
def test_invalid_handshakes_fail_closed(change: dict) -> None:
with pytest.raises(RealtimeContractError):
replace(_start(), **change)
def test_scene_reports_all_layers_before_any_end_marker_exists() -> None:
validate_scene_layers(_layers(), epoch_id="epoch-1", source_time_ns=0, maximum_layer_age_ms=250)
@pytest.mark.parametrize("layers", [_layers()[:-1], _layers()[:-1] + (_layers()[0],)])
def test_missing_or_duplicate_layer_is_not_a_complete_profile(layers: tuple) -> None:
with pytest.raises(RealtimeContractError, match="exactly once"):
validate_scene_layers(
layers, epoch_id="epoch-1", source_time_ns=0, maximum_layer_age_ms=250
)
@pytest.mark.parametrize(
"change,reason",
[
({"epoch_id": "epoch-old"}, "another stream epoch"),
({"source_time_ns": 2_000_000_000}, "future"),
({"source_time_ns": 0, "state": "held"}, "expired"),
],
)
def test_cross_epoch_future_and_expired_evidence_rejected(change: dict, reason: str) -> None:
layers = tuple(replace(x, source_time_ns=1_000_000_000) for x in _layers())
layers = (replace(layers[0], **change),) + layers[1:]
with pytest.raises(RealtimeContractError, match=reason):
validate_scene_layers(
layers, epoch_id="epoch-1", source_time_ns=1_000_000_000, maximum_layer_age_ms=250
)
def test_explicit_missing_lidar_does_not_invent_geometry_or_distance() -> None:
missing = LayerEvidence("geometry", "epoch-1", None, None, "unavailable", None)
layers = tuple(missing if x.layer == "geometry" else x for x in _layers())
validate_scene_layers(layers, epoch_id="epoch-1", source_time_ns=0, maximum_layer_age_ms=250)
with pytest.raises(RealtimeContractError, match="cannot claim"):
replace(missing, payload_sha256="e" * 64)
def test_synthetic_receipt_passes_only_whole_path_limits() -> None:
assert realtime_failures(_measurements(), _budgets()) == ()
@pytest.mark.parametrize(
"change,reason",
[
({"full_source_preloaded": True}, "full-source-preload"),
({"results_before_end_of_source": False}, "no-incremental-result"),
({"replay_speed": 0.5}, "source-clock-not-1x"),
({"terminal_frames": 101}, "incomplete-input-accounting"),
({"fresh_complete_scenes": 50}, "incomplete-fresh-scenes"),
({"capacity_drops": 1}, "capacity-drops"),
({"expired_frames": 1}, "expired-frames"),
({"failed_frames": 1}, "failed-frames"),
({"unexpected_unavailable_layers": 1}, "required-layer-unavailable"),
({"computed_layers": ("segmentation",)}, "full-profile-not-computed"),
({"peak_pending_camera_frames": 3}, "peak_pending_camera_frames"),
({"peak_inflight_bytes": 20_000_000}, "peak_inflight_bytes"),
({"clock_uncertainty_ms": 6}, "clock_uncertainty_ms"),
({"backlog_growth_ms": 30}, "backlog_growth_ms"),
({"output_age_p99_ms": 130}, "output_age_p99_ms"),
({"maximum_layer_age_ms": 300}, "maximum_layer_age_ms"),
({"warmup_seconds": 121}, "warmup_seconds"),
({"stop_seconds": 6}, "stop_seconds"),
],
)
def test_fast_model_or_complete_archive_does_not_pass_realtime(change: dict, reason: str) -> None:
assert reason in realtime_failures(replace(_measurements(), **change), _budgets())
def test_source_gaps_stay_visible_and_are_not_model_capacity_drops() -> None:
value = replace(_measurements(), declared_source_gap_frames=10, fresh_complete_scenes=90)
assert realtime_failures(value, _budgets()) == ()
assert "incomplete-fresh-scenes" in realtime_failures(
replace(value, fresh_complete_scenes=100), _budgets()
)
assert "no-evaluable-scene" in realtime_failures(
replace(value, declared_source_gap_frames=100, fresh_complete_scenes=0), _budgets()
)
@pytest.mark.parametrize(
"change",
[
{"output_age_p99_ms": float("nan")},
{"clock_uncertainty_ms": float("inf")},
{"peak_vram_mib": -1},
{"source_frames": True},
{"full_source_preloaded": 0},
],
)
def test_non_finite_or_ambiguous_measurements_rejected(change: dict) -> None:
with pytest.raises(RealtimeContractError):
replace(_measurements(), **change)
def test_budget_schema_rejects_silent_fields_and_inconsistent_limits() -> None:
with pytest.raises(RealtimeContractError):
RealtimeBudgets.from_dict({**asdict(_budgets()), "allow_full_download": True})
with pytest.raises(RealtimeContractError):
replace(_budgets(), maximum_chunk_bytes=100_000_000)
def test_profile_pins_reused_assets_without_installing_a_fake_ready_executor() -> None:
profile = _profile()
assert profile["packaging"]["image_sha256"] is None
assert set(profile["components"]) == set(REQUIRED_LAYERS)
assert profile["stream_policy"]["inference_stride"] == 1
assert profile["input_contract"]["full_source_transfer_required"] is False
assert profile["authority"]["actuation_allowed"] is False
assert profile["components"]["geometry"]["generic_static_class"] == "static.unknown"
assert (
profile["components"]["policy"]["fine_road_sidewalk_bikeway_distinction_required"] is False
)
for ref in profile["reference_files"]:
assert hashlib.sha256((ROOT / ref["path"]).read_bytes()).hexdigest() == ref["sha256"]
registry = (ROOT / "config/observatory-portable-run-definitions.json").read_text()
assert profile["profile_id"] not in registry
def test_experimental_overload_does_not_become_realtime_qualification() -> None:
policy = _profile()["experiment_policy"]
assert policy["bounded_lab_overload_allowed"] is True
assert policy["retain_profiles_that_fail_current_hardware_realtime"] is True
assert policy["realtime_failure_blocks_experimental_packaging"] is False
assert policy["overload_is_realtime_pass"] is False
measurements = replace(_measurements(), output_age_p95_ms=150.0, output_age_p99_ms=200.0)
failures = realtime_failures(measurements, _budgets())
assert "output_age_p95_ms" in failures and "output_age_p99_ms" in failures