feat(perception): define stream-first profile and qualification contracts
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user