fix(lab): seal RAV004 spatial replay transport

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 10:42:41 +03:00
parent f1cbe0061a
commit 757d368c86
11 changed files with 492 additions and 91 deletions
@@ -28,6 +28,7 @@ export function RecordedEvidenceVideoScene({
imageWidth, imageWidth,
imageHeight, imageHeight,
boxes, boxes,
overlaySeconds,
semanticOverlay, semanticOverlay,
pointCloudOverlay, pointCloudOverlay,
ariaLabel, ariaLabel,
@@ -46,6 +47,7 @@ export function RecordedEvidenceVideoScene({
imageWidth: number; imageWidth: number;
imageHeight: number; imageHeight: number;
boxes: readonly RecordedEvidenceBox[]; boxes: readonly RecordedEvidenceBox[];
overlaySeconds?: number;
semanticOverlay?: RecordedEvidenceSemanticOverlay; semanticOverlay?: RecordedEvidenceSemanticOverlay;
pointCloudOverlay?: RecordedEvidencePointCloudOverlayData; pointCloudOverlay?: RecordedEvidencePointCloudOverlayData;
ariaLabel: string; ariaLabel: string;
@@ -77,7 +79,8 @@ export function RecordedEvidenceVideoScene({
const sourceReady = admissionPhase === "ready"; const sourceReady = admissionPhase === "ready";
const overlaysPresented = sourceReady const overlaysPresented = sourceReady
&& presentedSeconds !== null && presentedSeconds !== null
&& Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25; && Math.abs(presentedSeconds - playback.currentSeconds) <= 0.25
&& (overlaySeconds === undefined || Math.abs(presentedSeconds - overlaySeconds) <= 0.25);
const handlePlaybackChange = (next: RecordedObservationPlayback) => { const handlePlaybackChange = (next: RecordedObservationPlayback) => {
setPresentedSeconds(next.currentSeconds); setPresentedSeconds(next.currentSeconds);
// During a paused operator seek the existing media element can emit its old // During a paused operator seek the existing media element can emit its old
@@ -238,7 +238,7 @@ export interface M4ThreatPlaybackProgress {
export interface M4ThreatPlaybackPointPack { export interface M4ThreatPlaybackPointPack {
resultId: string; resultId: string;
frameCount: 4489; frameCount: number;
pointCount: number; pointCount: number;
pointOffsets: Uint32Array; pointOffsets: Uint32Array;
pointsMapXyzM: Float32Array; pointsMapXyzM: Float32Array;
@@ -261,7 +261,7 @@ export interface M4ThreatPlaybackChunkDescriptor {
export interface M4ThreatPlaybackManifest { export interface M4ThreatPlaybackManifest {
resultId: string; resultId: string;
frameCount: 4489; frameCount: number;
pointCount: number; pointCount: number;
pointOffsets: Uint32Array; pointOffsets: Uint32Array;
chunkFrameCount: 24; chunkFrameCount: 24;
@@ -341,7 +341,7 @@ const motion = (value: unknown): M4ThreatMotion => {
}; };
const resultId = (value: unknown): string => { const resultId = (value: unknown): string => {
const parsed = text(value, "M4.6 result id"); const parsed = text(value, "M4.6 result id");
if (!/^m4-threat-replay-[a-f0-9]{64}$/.test(parsed)) { if (!/^[a-z0-9][a-z0-9-]{0,127}-[a-f0-9]{64}$/.test(parsed)) {
throw new M4ThreatContractError("M4.6 result id: нарушена идентичность."); throw new M4ThreatContractError("M4.6 result id: нарушена идентичность.");
} }
return parsed; return parsed;
@@ -1001,7 +1001,10 @@ export async function fetchM4ThreatPlaybackManifest(
exact(manifest.result_id, result, "M4.6 playback result"); exact(manifest.result_id, result, "M4.6 playback result");
exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame"); exact(manifest.coordinate_frame, "map", "M4.6 playback coordinate frame");
exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access"); exact(manifest.access, "read-only-sealed-binary-playback", "M4.6 playback access");
const frameCount = exact(integer(manifest.frame_count, "M4.6 playback frames"), 4489, "M4.6 playback frames"); const frameCount = integer(manifest.frame_count, "M4.6 playback frames");
if (frameCount < 1) {
throw new M4ThreatContractError("M4.6 playback frames: пустой playback недопустим.");
}
const pointCount = integer(manifest.point_count, "M4.6 playback points"); const pointCount = integer(manifest.point_count, "M4.6 playback points");
const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets"); const offsetsRaw = array(manifest.point_offsets, "M4.6 playback offsets");
if (offsetsRaw.length !== frameCount + 1) { if (offsetsRaw.length !== frameCount + 1) {
@@ -180,6 +180,7 @@ export function M4ReplayThreatVisual({
showReferenceMediaLayers = true, showReferenceMediaLayers = true,
showSpatialOverlaySummary = true, showSpatialOverlaySummary = true,
playbackTransport = "epoch-stream", playbackTransport = "epoch-stream",
spatialPlaybackTransport = "auto",
recoverTimestampStalls = false, recoverTimestampStalls = false,
onActiveSequenceChange, onActiveSequenceChange,
}: { }: {
@@ -198,6 +199,7 @@ export function M4ReplayThreatVisual({
showReferenceMediaLayers?: boolean; showReferenceMediaLayers?: boolean;
showSpatialOverlaySummary?: boolean; showSpatialOverlaySummary?: boolean;
playbackTransport?: "segmented" | "epoch-stream"; playbackTransport?: "segmented" | "epoch-stream";
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
recoverTimestampStalls?: boolean; recoverTimestampStalls?: boolean;
onActiveSequenceChange?: (sequence: number | null) => void; onActiveSequenceChange?: (sequence: number | null) => void;
}) { }) {
@@ -293,6 +295,7 @@ export function M4ReplayThreatVisual({
currentSeconds: playbackController.playback.currentSeconds, currentSeconds: playbackController.playback.currentSeconds,
includeSpatialPoints: evidenceDemand.sourceSpatialPoints, includeSpatialPoints: evidenceDemand.sourceSpatialPoints,
endpointRoot: timelineEndpointRoot, endpointRoot: timelineEndpointRoot,
spatialPlaybackTransport,
}); });
const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null); const [videoSource, setVideoSource] = useState<ObservationSourceDescriptor | null>(null);
const [videoLoading, setVideoLoading] = useState(false); const [videoLoading, setVideoLoading] = useState(false);
@@ -1143,6 +1146,7 @@ export function M4ReplayThreatVisual({
imageWidth={timeline.imageWidth} imageWidth={timeline.imageWidth}
imageHeight={timeline.imageHeight} imageHeight={timeline.imageHeight}
boxes={activeBoxes} boxes={activeBoxes}
overlaySeconds={frame?.sessionSeconds}
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined} pointCloudOverlay={mediaMode === "video" ? pointCloudOverlay : undefined}
ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} ariaLabel={`${evidenceLabel} recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
@@ -80,6 +80,7 @@ function FullRouteReviewEvidence({
classifiedSpatialLayer={sealedSpatialGap} classifiedSpatialLayer={sealedSpatialGap}
evidenceLabel="RAVNOVES004TREE" evidenceLabel="RAVNOVES004TREE"
playbackTransport="segmented" playbackTransport="segmented"
spatialPlaybackTransport="sealed-binary"
recoverTimestampStalls recoverTimestampStalls
showReferenceMediaLayers showReferenceMediaLayers
showSpatialOverlaySummary showSpatialOverlaySummary
@@ -34,14 +34,14 @@ export function m4ThreatChunkWindowStarts(
if (chunkSize < 1 || frameCount < 1) return []; if (chunkSize < 1 || frameCount < 1) return [];
return [ return [
activeChunkStart, activeChunkStart,
...Array.from(
{ length: RETAINED_CHUNKS_BEHIND },
(_, index) => activeChunkStart - (index + 1) * chunkSize,
),
...Array.from( ...Array.from(
{ length: PREFETCH_CHUNKS_AHEAD }, { length: PREFETCH_CHUNKS_AHEAD },
(_, index) => activeChunkStart + (index + 1) * chunkSize, (_, index) => activeChunkStart + (index + 1) * chunkSize,
), ),
...Array.from(
{ length: RETAINED_CHUNKS_BEHIND },
(_, index) => activeChunkStart - (index + 1) * chunkSize,
),
].filter((start) => start >= 0 && start < frameCount); ].filter((start) => start >= 0 && start < frameCount);
} }
@@ -86,12 +86,14 @@ export function useM4ThreatTimelineFrame({
currentSeconds, currentSeconds,
includeSpatialPoints = true, includeSpatialPoints = true,
endpointRoot, endpointRoot,
spatialPlaybackTransport = "auto",
}: { }: {
resultId: string; resultId: string;
timeline: M4ThreatTimeline | null; timeline: M4ThreatTimeline | null;
currentSeconds: number; currentSeconds: number;
includeSpatialPoints?: boolean; includeSpatialPoints?: boolean;
endpointRoot?: string; endpointRoot?: string;
spatialPlaybackTransport?: "auto" | "sealed-binary" | "json";
}) { }) {
const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>( const [chunks, setChunks] = useState<ReadonlyMap<number, M4ThreatTimelineChunk>>(
() => new Map(), () => new Map(),
@@ -104,8 +106,10 @@ export function useM4ThreatTimelineFrame({
totalBytes: 0, totalBytes: 0,
}); });
const [playbackError, setPlaybackError] = useState<string | null>(null); const [playbackError, setPlaybackError] = useState<string | null>(null);
const binaryPlayback = endpointRoot === undefined const binaryPlayback = spatialPlaybackTransport === "sealed-binary"
|| endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT; || (spatialPlaybackTransport === "auto" && (
endpointRoot === undefined || endpointRoot === M4_THREAT_TIMELINE_ENDPOINT_ROOT
));
const inFlight = useRef(new Map<number, AbortController>()); const inFlight = useRef(new Map<number, AbortController>());
const chunksRef = useRef(chunks); const chunksRef = useRef(chunks);
const activeChunkStartRef = useRef<number | null>(null); const activeChunkStartRef = useRef<number | null>(null);
@@ -379,14 +379,16 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point
}); });
test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => { test("M4.6 source cloud opens from one verified bounded chunk instead of the 105 MiB track", async () => {
const pointOffsets = [0, ...Array(4489).fill(2)]; const playbackResultId = `lab-v1-vegetation-shadow-${"b".repeat(64)}`;
const frameCount = 48;
const pointOffsets = [0, ...Array(frameCount).fill(2)];
const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]); const points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]);
const pointBytes = points.buffer; const pointBytes = points.buffer;
const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex"); const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex");
const endpointRoot = "/api/v1/laboratory/m4-threat/results"; const endpointRoot = "/api/v1/laboratory/m4-threat/results";
const chunks = Array.from({ length: 188 }, (_, index) => { const chunks = Array.from({ length: Math.ceil(frameCount / 24) }, (_, index) => {
const start = index * 24; const start = index * 24;
const count = Math.min(24, 4489 - start); const count = Math.min(24, frameCount - start);
const pointStart = pointOffsets[start]; const pointStart = pointOffsets[start];
const pointStop = pointOffsets[start + count]; const pointStop = pointOffsets[start + count];
const pointCount = pointStop - pointStart; const pointCount = pointStop - pointStart;
@@ -396,7 +398,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
count, count,
point_start: pointStart, point_start: pointStart,
point_count: pointCount, point_count: pointCount,
url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`, url: `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/${index}`,
media_type: "application/octet-stream", media_type: "application/octet-stream",
dtype: "<f4", dtype: "<f4",
shape: [pointCount, 3], shape: [pointCount, 3],
@@ -406,8 +408,8 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
}); });
const manifestPayload = { const manifestPayload = {
schema_version: "missioncore.recorded-spatial-playback/v1", schema_version: "missioncore.recorded-spatial-playback/v1",
result_id: resultId, result_id: playbackResultId,
frame_count: 4489, frame_count: frameCount,
point_count: 2, point_count: 2,
point_offsets: pointOffsets, point_offsets: pointOffsets,
chunk_frame_count: 24, chunk_frame_count: 24,
@@ -416,7 +418,7 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
chunks, chunks,
track: { track: {
id: "points-map-f32", id: "points-map-f32",
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`, url: `${endpointRoot}/${playbackResultId}/timeline/playback/tracks/points-map-f32`,
media_type: "application/octet-stream", media_type: "application/octet-stream",
dtype: "<f4", dtype: "<f4",
shape: [2, 3], shape: [2, 3],
@@ -434,12 +436,12 @@ test("M4.6 source cloud opens from one verified bounded chunk instead of the 105
if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0)); if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0));
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
}; };
const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher }); const manifest = await fetchM4ThreatPlaybackManifest(playbackResultId, { fetcher });
const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher }); const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher });
assert.deepEqual(requested, [ assert.deepEqual(requested, [
`${endpointRoot}/${resultId}/timeline/playback`, `${endpointRoot}/${playbackResultId}/timeline/playback`,
`${endpointRoot}/${resultId}/timeline/playback/chunks/0`, `${endpointRoot}/${playbackResultId}/timeline/playback/chunks/0`,
]); ]);
assert.equal(chunk.pointCount, 2); assert.equal(chunk.pointCount, 2);
assert.equal(chunk.pointStart, 0); assert.equal(chunk.pointStart, 0);
@@ -769,7 +771,7 @@ test("M4.6 local SLAM surface reprojects registered increments into the active b
}); });
test("M4.6 spatial buffering keeps the active and one future chunk", () => { test("M4.6 spatial buffering keeps the active and one future chunk", () => {
assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 24, 72]); assert.deepEqual(m4ThreatChunkWindowStarts(48, 24, 4489), [48, 72, 24]);
assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]); assert.deepEqual(m4ThreatChunkWindowStarts(0, 24, 4489), [0, 24]);
}); });
@@ -787,7 +789,7 @@ test("M4.6 spatial buffering drops stale in-flight windows across rapid jumps",
} }
} }
assert.deepEqual([...inFlight.keys()], [4488, 4464]); assert.deepEqual([...inFlight.keys()], [4488, 4464]);
assert.deepEqual(aborted, [0, 24, 1488, 1464, 1512]); assert.deepEqual(aborted, [0, 24, 1488, 1512, 1464]);
}); });
test("recorded evidence clock advances by selected rate and stops at the sealed end", () => { test("recorded evidence clock advances by selected rate and stops at the sealed end", () => {
@@ -22,6 +22,29 @@ Window structure, switching, seek, buffering and spatial scene code are shared.
## Why the previous LAB failed ## Why the previous LAB failed
### RAV004 did not use the accepted replay data plane
The working M4/Hologravity LAB uses sealed binary numeric tracks, retained scene
state and bounded JSON metadata. RAV004 reused the visual component but silently
left its custom timeline endpoint on the JSON fallback. Each eight-frame spatial
window therefore transferred approximately 1.2-2.9 MB and took 0.87-1.37 s to
produce while representing only about 0.84 s of playback. The next request
aborted and replaced the previous one before spatial state could catch up.
The user screenshot captured the failure exactly: camera frame 366 was active
while the last delivered spatial evidence was frame 230, a 136-frame gap. The
backend also reopened and indexed the 6830-entry semantic ZIP for every requested
mask and proposal frame. This explains why the same canonical viewer was smooth
for Hologravity but stalled for RAV004: the window and interaction code were
shared, but the data-plane contract was not.
RAV004 now publishes the same `missioncore.recorded-spatial-playback/v1`
contract as the accepted LAB: an immutable Float32 map-point track, camera-frame
offsets and 24-frame binary chunks. JSON chunks contain bounded frame metadata
only; retained Local SLAM is reconstructed from exact source increments in the
shared client. The semantic ZIP handle and member index are cached per immutable
artifact instead of being reparsed per frame.
### Video and spatial state had different clocks ### Video and spatial state had different clocks
The removed RAV004 viewer advanced an animation/host clock even when the browser The removed RAV004 viewer advanced an animation/host clock even when the browser
@@ -77,8 +100,11 @@ dozens of seconds old. The buffer now loads the active chunk first, the precedin
chunk second and the next chunk as prefetch. The scene selects the latest proven chunk second and the next chunk as prefetch. The scene selects the latest proven
source increment whose sequence is not later than the active camera frame. source increment whose sequence is not later than the active camera frame.
At the final UI check, camera frame 189 causally held spatial frame 184. Before At the final UI check, playback restarted at frame 1, then ran continuously past
the fix the same point could hold frame 16. frame 462. At frame 188 the DDRNet mask was frame 188, proposals were frame 187
and spatial state was delivered without buffering; the one-frame proposal delay
is the recorded causal overlay, not stale UI state. Before the transport fix the
user's run had already fallen 136 frames behind by camera frame 366.
## Capability ledger ## Capability ledger
@@ -104,12 +130,17 @@ full-TGS result is also not reused because it has a different source identity an
Measured on the canonical local service and current immutable artifacts: Measured on the canonical local service and current immutable artifacts:
- replay launch POST: 3.55 s on first opening; - replay launch POST: 3.55 s on first opening;
- timeline metadata: 0.02 s warm; - first binary playback-manifest build after a service restart: 32.33 s while
- active spatial chunk, eight camera frames: 35.17 s first process-local RRD the process-local RRD point track is materialized; warm manifest: 0.18-0.20 s;
index build, 0.67 s warm, approximately 3.81 MB; - full retained point track: 3,893,445 Float32 map points, 46,721,340 bytes,
- UI replay: passed the previously deterministic 11.422 s decoder stop, then divided into 285 immutable 24-frame chunks;
continued to 59 s with media and timeline advancing together; - representative active binary chunks: 155-224 KB at 9-10 ms;
- operator reset seek: 16.4 s to 0 s, one mounted media worker, successful; - representative 24-frame metadata chunks: 24-32 KB at 0.78-1.0 s, covering
about 2.4 s of playback;
- cached semantic-mask reads: 5.6-7.8 ms instead of approximately 80 ms;
- UI replay: reset to frame 1 and ran continuously beyond frame 462 with camera,
semantic overlay and retained spatial state advancing together;
- operator reset seek: successful, one mounted media worker;
- browser console after the acceptance run: no warnings or errors. - browser console after the acceptance run: no warnings or errors.
The first RRD index is still process-local rather than a persistent disk cache. The first RRD index is still process-local rather than a persistent disk cache.
@@ -147,13 +178,14 @@ deploy it.
## Acceptance performed ## Acceptance performed
- 44 focused backend tests passed; - 12 focused backend spatial/API tests passed;
- 37 frontend replay, buffering and LAB contract tests passed; - 16 focused frontend replay transport/manifest tests passed;
- TypeScript project typecheck passed; - TypeScript project typecheck passed;
- production Vite build passed (only existing large-chunk warnings); - production Vite build passed (only existing large-chunk warnings);
- `git diff --check` passed; - `git diff --check` passed;
- live browser run verified the shared controls, disabled unsealed TGS/3D - live browser run verified reset seek, the shared controls, disabled unsealed
semantics, continuous media recovery, causal spatial hold and clean console. TGS/3D semantics, continuous playback through the former failing interval,
causal spatial hold and a clean console.
Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`. Visual QA: `docs/handoff/2026-08-30_RAV004_CANONICAL_LAB_QA.jpg`.
+94 -9
View File
@@ -75,7 +75,13 @@ def _session_times(batch: Any) -> Any | None:
return batch.column("session_time") return batch.column("session_time")
def _point_rows(chunks: list[Any], entity: str, component: str, *, nested: bool = False) -> _TimedPoints: def _point_rows(
chunks: list[Any],
entity: str,
component: str,
*,
nested: bool = False,
) -> _TimedPoints:
rows: list[tuple[int, np.ndarray]] = [] rows: list[tuple[int, np.ndarray]] = []
for chunk in chunks: for chunk in chunks:
if chunk.entity_path != entity: if chunk.entity_path != entity:
@@ -436,6 +442,8 @@ def _bounded_local_slam(
def _canonical_lab_spatial_frame_from_index( def _canonical_lab_spatial_frame_from_index(
index: _CanonicalSpatialIndex, index: _CanonicalSpatialIndex,
target_time_ns: int, target_time_ns: int,
*,
include_local_slam: bool = True,
) -> dict[str, object]: ) -> dict[str, object]:
point_index = _latest_index(index.points.times_ns, target_time_ns) point_index = _latest_index(index.points.times_ns, target_time_ns)
pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index]) pose_index = _latest_index(index.poses.times_ns, index.points.times_ns[point_index])
@@ -462,12 +470,17 @@ def _canonical_lab_spatial_frame_from_index(
ground_origin, ground_origin,
basis_map_from_body, basis_map_from_body,
) )
local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam( if include_local_slam:
index.points, local_slam, local_slam_source_frames, local_slam_source_points = _bounded_local_slam(
index.points.times_ns[point_index], index.points,
ground_origin, index.points.times_ns[point_index],
basis_map_from_body, ground_origin,
) basis_map_from_body,
)
else:
local_slam = np.empty((0, 3), dtype=np.float32)
local_slam_source_frames = 0
local_slam_source_points = 0
return { return {
"schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3", "schema_version": "missioncore.canonical-recorded-lab-spatial-frame/v3",
"target_time_ns": target_time_ns, "target_time_ns": target_time_ns,
@@ -531,6 +544,8 @@ def canonical_lab_spatial_timeline_samples(
frame_times_ns: tuple[int, ...], frame_times_ns: tuple[int, ...],
start_sequence: int, start_sequence: int,
frame_count: int, frame_count: int,
*,
include_local_slam: bool = True,
) -> tuple[dict[str, object] | None, ...]: ) -> tuple[dict[str, object] | None, ...]:
"""Project only new source increments onto a denser camera timeline. """Project only new source increments onto a denser camera timeline.
@@ -545,7 +560,10 @@ def canonical_lab_spatial_timeline_samples(
start_sequence < 0 start_sequence < 0
or frame_count < 1 or frame_count < 1
or start_sequence >= len(frame_times_ns) or start_sequence >= len(frame_times_ns)
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:])) or any(
current <= previous
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
)
): ):
raise ValueError("Recorded LAB timeline sample request is invalid") raise ValueError("Recorded LAB timeline sample request is invalid")
stat = recording_path.stat() stat = recording_path.stat()
@@ -566,8 +584,75 @@ def canonical_lab_spatial_timeline_samples(
else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1]) else _latest_index(index.points.times_ns, frame_times_ns[sequence - 1])
) )
samples.append( samples.append(
_canonical_lab_spatial_frame_from_index(index, target_time_ns) _canonical_lab_spatial_frame_from_index(
index,
target_time_ns,
include_local_slam=include_local_slam,
)
if point_index != previous_point_index if point_index != previous_point_index
else None else None
) )
return tuple(samples) return tuple(samples)
@lru_cache(maxsize=2)
def _canonical_lab_spatial_playback_points_cached(
recording_path_text: str,
recording_size: int,
recording_mtime_ns: int,
generation_sha256: str,
frame_times_ns: tuple[int, ...],
) -> tuple[np.ndarray, tuple[int, ...]]:
del recording_size, recording_mtime_ns
recording_path = Path(recording_path_text)
stat = recording_path.stat()
index = _load_index(
str(recording_path),
stat.st_size,
stat.st_mtime_ns,
generation_sha256,
)
increments: list[np.ndarray] = []
offsets = [0]
point_count = 0
previous_point_index = -1
for target_time_ns in frame_times_ns:
point_index = _latest_index(index.points.times_ns, target_time_ns)
if point_index != previous_point_index:
increment = np.ascontiguousarray(index.points.values[point_index], dtype="<f4")
increments.append(increment)
point_count += int(increment.shape[0])
offsets.append(point_count)
previous_point_index = point_index
points = (
np.ascontiguousarray(np.concatenate(increments, axis=0), dtype="<f4")
if increments
else np.empty((0, 3), dtype="<f4")
)
points.setflags(write=False)
return points, tuple(offsets)
def canonical_lab_spatial_playback_points(
recording_path: Path,
generation_sha256: str,
frame_times_ns: tuple[int, ...],
) -> tuple[np.ndarray, tuple[int, ...]]:
"""Return one retained map-coordinate point track for a camera timeline."""
if (
not frame_times_ns
or any(
current <= previous
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
)
):
raise ValueError("Recorded LAB playback timeline is invalid")
stat = recording_path.stat()
return _canonical_lab_spatial_playback_points_cached(
str(recording_path),
stat.st_size,
stat.st_mtime_ns,
generation_sha256,
frame_times_ns,
)
+248 -44
View File
@@ -26,13 +26,16 @@ from k1link.laboratory.evidence_report import (
) )
from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA from k1link.laboratory.vegetation_shadow_lab import LAB_SCHEMA
from k1link.sessions import RecordedCameraFrame, SessionIntegrityError from k1link.sessions import RecordedCameraFrame, SessionIntegrityError
from k1link.sessions.canonical_lab_spatial import canonical_lab_spatial_timeline_samples from k1link.sessions.canonical_lab_spatial import (
canonical_lab_spatial_playback_points,
canonical_lab_spatial_timeline_samples,
)
RootProvider = Callable[[], Path | None] RootProvider = Callable[[], Path | None]
CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None] CanonicalRecordingProvider = Callable[[str], tuple[Path, str] | None]
CameraFrameProvider = Callable[[str, int], RecordedCameraFrame] CameraFrameProvider = Callable[[str, int], RecordedCameraFrame]
_MAX_DOCUMENT_BYTES: Final = 1024 * 1024 _MAX_DOCUMENT_BYTES: Final = 1024 * 1024
_CANONICAL_ROUTE_CHUNK_FRAMES: Final = 8 _CANONICAL_ROUTE_CHUNK_FRAMES: Final = 24
_DEFINITION: Final = LaboratoryEvidenceDefinition( _DEFINITION: Final = LaboratoryEvidenceDefinition(
work_id="lab-v1-vegetation-shadow", work_id="lab-v1-vegetation-shadow",
runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"), runtime_relative_root=PurePosixPath("lab-v1-vegetation/results"),
@@ -167,19 +170,7 @@ def _build_vegetation_lab_router(
archive_path = candidate.joinpath(*relative.parts) archive_path = candidate.joinpath(*relative.parts)
member = f"masks/frame-{sequence + 1:06d}.png" member = f"masks/frame-{sequence + 1:06d}.png"
try: try:
before = archive_path.stat() payload = _read_cached_mask_member(archive_path, member)
with zipfile.ZipFile(archive_path) as frozen:
info = frozen.getinfo(member)
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
raise ValueError("Vegetation video mask member is invalid")
payload = frozen.read(info)
after = archive_path.stat()
if (
before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
or len(payload) != info.file_size
):
raise ValueError("Vegetation video mask archive changed during read")
except (KeyError, OSError, ValueError, zipfile.BadZipFile): except (KeyError, OSError, ValueError, zipfile.BadZipFile):
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -286,7 +277,7 @@ def _build_vegetation_lab_router(
route, frame_times_ns = _full_route_context(candidate, manifest) route, frame_times_ns = _full_route_context(candidate, manifest)
intervals = [ intervals = [
(current - previous) / 1_000_000_000 (current - previous) / 1_000_000_000
for previous, current in zip(frame_times_ns, frame_times_ns[1:]) for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
] ]
nominal_interval = statistics.median(intervals) nominal_interval = statistics.median(intervals)
if not math.isfinite(nominal_interval) or nominal_interval <= 0: if not math.isfinite(nominal_interval) or nominal_interval <= 0:
@@ -350,7 +341,10 @@ def _build_vegetation_lab_router(
if start >= len(frame_times_ns): if start >= len(frame_times_ns):
raise HTTPException(status_code=404, detail="Full-route timeline chunk not found") raise HTTPException(status_code=404, detail="Full-route timeline chunk not found")
if canonical_recording_provider is None: if canonical_recording_provider is None:
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable") raise HTTPException(
status_code=503,
detail="Canonical spatial recording is unavailable",
)
recording = canonical_recording_provider(str(route["session_id"])) recording = canonical_recording_provider(str(route["session_id"]))
if recording is None: if recording is None:
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready") raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
@@ -362,6 +356,7 @@ def _build_vegetation_lab_router(
frame_times_ns, frame_times_ns,
start, start,
count, count,
include_local_slam=False,
) )
except (OSError, ValueError): except (OSError, ValueError):
raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None raise HTTPException(status_code=503, detail="Canonical spatial chunk failed") from None
@@ -391,6 +386,98 @@ def _build_vegetation_lab_router(
"access": "read-only-bounded-recorded-replay", "access": "read-only-bounded-recorded-replay",
} }
@router.get("/{result_id}/timeline/playback")
def get_canonical_route_timeline_playback(result_id: str) -> dict[str, object]:
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
route, frame_times_ns = _full_route_context(candidate, manifest)
points, offsets = _canonical_route_playback(
canonical_recording_provider,
route,
frame_times_ns,
)
points_view = memoryview(points).cast("B")
return {
"schema_version": "missioncore.recorded-spatial-playback/v1",
"result_id": result_id,
"frame_count": len(frame_times_ns),
"point_count": int(points.shape[0]),
"point_offsets": list(offsets),
"chunk_frame_count": _CANONICAL_ROUTE_CHUNK_FRAMES,
"resident_chunk_count_max": 4,
"forward_prefetch_chunk_count": 1,
"chunks": _canonical_route_playback_chunk_catalog(
prefix,
result_id,
points_view,
offsets,
),
"track": {
"id": "points-map-f32",
"url": f"{prefix}/{result_id}/timeline/playback/tracks/points-map-f32",
"media_type": "application/octet-stream",
"dtype": "<f4",
"shape": [int(points.shape[0]), 3],
"bytes": int(points.nbytes),
"sha256": hashlib.sha256(points_view).hexdigest(),
},
"coordinate_frame": "map",
"ground_truth": False,
"authority": "replay-simulated",
"access": "read-only-sealed-binary-playback",
}
@router.get("/{result_id}/timeline/playback/chunks/{chunk_index}")
def get_canonical_route_timeline_playback_chunk(
result_id: str,
chunk_index: int,
) -> Response:
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
route, frame_times_ns = _full_route_context(candidate, manifest)
points, offsets = _canonical_route_playback(
canonical_recording_provider,
route,
frame_times_ns,
)
points_view = memoryview(points).cast("B")
descriptor = _canonical_route_playback_chunk_descriptor(
prefix,
result_id,
points_view,
offsets,
chunk_index,
)
if descriptor is None:
raise HTTPException(status_code=404, detail="Full-route playback chunk not found")
point_start = int(descriptor["point_start"])
byte_length = int(descriptor["bytes"])
byte_start = point_start * 3 * 4
payload = bytes(points_view[byte_start : byte_start + byte_length])
return Response(
content=payload,
media_type="application/octet-stream",
headers=_immutable_binary_headers(byte_length, str(descriptor["sha256"])),
)
@router.get("/{result_id}/timeline/playback/tracks/points-map-f32")
def get_canonical_route_timeline_playback_track(result_id: str) -> Response:
candidate = _resolve_candidate(root_provider, definition, result_id)
manifest = _read_verified(candidate, definition)
route, frame_times_ns = _full_route_context(candidate, manifest)
points, _ = _canonical_route_playback(
canonical_recording_provider,
route,
frame_times_ns,
)
payload = memoryview(points).cast("B")
digest = hashlib.sha256(payload).hexdigest()
return Response(
content=bytes(payload),
media_type="application/octet-stream",
headers=_immutable_binary_headers(payload.nbytes, digest),
)
@router.get("/{result_id}/timeline/frames/{sequence}/camera") @router.get("/{result_id}/timeline/frames/{sequence}/camera")
def get_canonical_route_camera(result_id: str, sequence: int) -> Response: def get_canonical_route_camera(result_id: str, sequence: int) -> Response:
if camera_frame_provider is None: if camera_frame_provider is None:
@@ -403,7 +490,10 @@ def _build_vegetation_lab_router(
try: try:
camera = camera_frame_provider(str(route["session_id"]), sequence) camera = camera_frame_provider(str(route["session_id"]), sequence)
except (OSError, SessionIntegrityError, ValueError): except (OSError, SessionIntegrityError, ValueError):
raise HTTPException(status_code=503, detail="Full-route camera frame unavailable") from None raise HTTPException(
status_code=503,
detail="Full-route camera frame unavailable",
) from None
if camera.width != route["width"] or camera.height != route["height"]: if camera.width != route["width"] or camera.height != route["height"]:
raise HTTPException(status_code=503, detail="Full-route camera dimensions changed") raise HTTPException(status_code=503, detail="Full-route camera dimensions changed")
return Response( return Response(
@@ -495,15 +585,111 @@ def _full_route_context(
values = np.frombuffer(payload, dtype="<u8") values = np.frombuffer(payload, dtype="<u8")
frame_times_ns = tuple(int(value) for value in values) frame_times_ns = tuple(int(value) for value in values)
except (OSError, ValueError): except (OSError, ValueError):
raise HTTPException(status_code=503, detail="Full-route timeline verification failed") from None raise HTTPException(
status_code=503,
detail="Full-route timeline verification failed",
) from None
if ( if (
len(frame_times_ns) != route["frame_count"] len(frame_times_ns) != route["frame_count"]
or any(current <= previous for previous, current in zip(frame_times_ns, frame_times_ns[1:])) or any(
current <= previous
for previous, current in zip(frame_times_ns, frame_times_ns[1:], strict=False)
)
): ):
raise HTTPException(status_code=503, detail="Full-route timeline order changed") raise HTTPException(status_code=503, detail="Full-route timeline order changed")
return route, frame_times_ns return route, frame_times_ns
def _canonical_route_playback(
provider: CanonicalRecordingProvider | None,
route: dict[str, Any],
frame_times_ns: tuple[int, ...],
) -> tuple[np.ndarray, tuple[int, ...]]:
if provider is None:
raise HTTPException(status_code=503, detail="Canonical spatial recording is unavailable")
recording = provider(str(route["session_id"]))
if recording is None:
raise HTTPException(status_code=409, detail="Canonical spatial recording is not ready")
recording_path, generation_sha256 = recording
try:
return canonical_lab_spatial_playback_points(
recording_path,
generation_sha256,
frame_times_ns,
)
except (OSError, ValueError):
raise HTTPException(status_code=503, detail="Canonical spatial playback failed") from None
def _canonical_route_playback_chunk_descriptor(
endpoint_prefix: str,
result_id: str,
points_view: memoryview,
offsets: tuple[int, ...],
chunk_index: int,
) -> dict[str, object] | None:
frame_count = len(offsets) - 1
start = chunk_index * _CANONICAL_ROUTE_CHUNK_FRAMES
if chunk_index < 0 or start >= frame_count:
return None
count = min(_CANONICAL_ROUTE_CHUNK_FRAMES, frame_count - start)
point_start = offsets[start]
point_stop = offsets[start + count]
byte_start = point_start * 3 * 4
byte_stop = point_stop * 3 * 4
payload = points_view[byte_start:byte_stop]
return {
"index": chunk_index,
"start": start,
"count": count,
"point_start": point_start,
"point_count": point_stop - point_start,
"url": f"{endpoint_prefix}/{result_id}/timeline/playback/chunks/{chunk_index}",
"media_type": "application/octet-stream",
"dtype": "<f4",
"shape": [point_stop - point_start, 3],
"bytes": payload.nbytes,
"sha256": hashlib.sha256(payload).hexdigest(),
}
def _canonical_route_playback_chunk_catalog(
endpoint_prefix: str,
result_id: str,
points_view: memoryview,
offsets: tuple[int, ...],
) -> list[dict[str, object]]:
frame_count = len(offsets) - 1
chunk_count = (
frame_count + _CANONICAL_ROUTE_CHUNK_FRAMES - 1
) // _CANONICAL_ROUTE_CHUNK_FRAMES
return [
descriptor
for chunk_index in range(chunk_count)
if (
descriptor := _canonical_route_playback_chunk_descriptor(
endpoint_prefix,
result_id,
points_view,
offsets,
chunk_index,
)
)
is not None
]
def _immutable_binary_headers(byte_length: int, sha256: str) -> dict[str, str]:
return {
"Cache-Control": "private, max-age=31536000, immutable",
"Content-Encoding": "identity",
"Content-Length": str(byte_length),
"ETag": f'"{sha256}"',
"X-Content-Type-Options": "nosniff",
"X-Uncompressed-Content-Length": str(byte_length),
}
def _canonical_timeline_frame( def _canonical_timeline_frame(
*, *,
result_id: str, result_id: str,
@@ -518,7 +704,6 @@ def _canonical_timeline_frame(
points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"] points = [] if spatial is None or not include_points else spatial["source_points_body_xyz_m"]
point_count = 0 if spatial is None else int(spatial["source_point_count"]) point_count = 0 if spatial is None else int(spatial["source_point_count"])
body_frame = None if spatial is None else spatial["body_frame"] body_frame = None if spatial is None else spatial["body_frame"]
local_slam = [] if spatial is None else spatial["local_slam_body_xyz_m"]
return { return {
"schema_version": "missioncore.recorded-spatial-evidence-frame/v1", "schema_version": "missioncore.recorded-spatial-evidence-frame/v1",
"sequence": sequence, "sequence": sequence,
@@ -534,11 +719,6 @@ def _canonical_timeline_frame(
"point_cloud_source_count": point_count, "point_cloud_source_count": point_count,
"point_cloud_sample_count": point_count if not include_points else len(points), "point_cloud_sample_count": point_count if not include_points else len(points),
"point_cloud_layer": "current-increment", "point_cloud_layer": "current-increment",
"local_slam_body_xyz_m": local_slam,
"local_slam_source_frame_count": 0
if spatial is None else spatial["local_slam_source_frame_count"],
"local_slam_source_point_count": 0
if spatial is None else spatial["local_slam_source_point_count"],
"rolling_map_component_count": 0, "rolling_map_component_count": 0,
"metric_obstacles": [], "metric_obstacles": [],
"camera_proposals": _semantic_component_proposals(candidate, route, sequence), "camera_proposals": _semantic_component_proposals(candidate, route, sequence),
@@ -585,12 +765,14 @@ def _semantic_component_proposals_cached(
archive_mtime_ns: int, archive_mtime_ns: int,
sequence: int, sequence: int,
) -> tuple[dict[str, object], ...]: ) -> tuple[dict[str, object], ...]:
del archive_size, archive_mtime_ns
archive_path = Path(archive_path_text)
member = f"masks/frame-{sequence + 1:06d}.png" member = f"masks/frame-{sequence + 1:06d}.png"
try: try:
with zipfile.ZipFile(archive_path) as frozen: frozen = _cached_zip_archive(
payload = frozen.read(member) archive_path_text,
archive_size,
archive_mtime_ns,
)
payload = frozen.read(member)
with Image.open(io.BytesIO(payload)) as image: with Image.open(io.BytesIO(payload)) as image:
mask = np.asarray(image.convert("L"), dtype=np.uint8) mask = np.asarray(image.convert("L"), dtype=np.uint8)
except (KeyError, OSError, ValueError, zipfile.BadZipFile): except (KeyError, OSError, ValueError, zipfile.BadZipFile):
@@ -733,7 +915,10 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj
selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32) selected_points = np.ascontiguousarray(points[start:end], dtype=np.float32)
selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8) selected_states = np.ascontiguousarray(states[slot], dtype=np.uint8)
selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32) selected_z_bounds = np.ascontiguousarray(z_bounds[slot], dtype=np.float32)
if not np.isfinite(selected_points).all() or not np.isin(selected_states, [0, 1, 2, 3]).all(): if (
not np.isfinite(selected_points).all()
or not np.isin(selected_states, [0, 1, 2, 3]).all()
):
raise ValueError("Route TGS payload changed") raise ValueError("Route TGS payload changed")
result = { result = {
"schema_version": "missioncore.lab-v1-route-tgs-anchor/v1", "schema_version": "missioncore.lab-v1-route-tgs-anchor/v1",
@@ -762,19 +947,7 @@ def _route_tgs_anchor_payload(path: Path, source_sequence: int) -> dict[str, obj
def _zip_mask_response(archive_path: Path, sequence: int) -> Response: def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
member = f"masks/frame-{sequence + 1:06d}.png" member = f"masks/frame-{sequence + 1:06d}.png"
try: try:
before = archive_path.stat() payload = _read_cached_mask_member(archive_path, member)
with zipfile.ZipFile(archive_path) as frozen:
info = frozen.getinfo(member)
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
raise ValueError("Semantic mask member is invalid")
payload = frozen.read(info)
after = archive_path.stat()
if (
before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
or len(payload) != info.file_size
):
raise ValueError("Semantic mask archive changed during read")
except (KeyError, OSError, ValueError, zipfile.BadZipFile): except (KeyError, OSError, ValueError, zipfile.BadZipFile):
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -792,6 +965,37 @@ def _zip_mask_response(archive_path: Path, sequence: int) -> Response:
) )
@lru_cache(maxsize=8)
def _cached_zip_archive(
archive_path_text: str,
archive_size: int,
archive_mtime_ns: int,
) -> zipfile.ZipFile:
del archive_size, archive_mtime_ns
return zipfile.ZipFile(archive_path_text)
def _read_cached_mask_member(archive_path: Path, member: str) -> bytes:
before = archive_path.stat()
frozen = _cached_zip_archive(
str(archive_path),
before.st_size,
before.st_mtime_ns,
)
info = frozen.getinfo(member)
if info.is_dir() or info.file_size < 8 or info.file_size > 1024 * 1024:
raise ValueError("Semantic mask member is invalid")
payload = frozen.read(info)
after = archive_path.stat()
if (
before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
or len(payload) != info.file_size
):
raise ValueError("Semantic mask archive changed during read")
return payload
def _configured_root(provider: RootProvider) -> Path | None: def _configured_root(provider: RootProvider) -> Path | None:
candidate = provider() candidate = provider()
if candidate is None: if candidate is None:
+46 -3
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import numpy as np import numpy as np
import pytest import pytest
import k1link.sessions.canonical_lab_spatial as spatial_module
from k1link.sessions.canonical_lab_spatial import ( from k1link.sessions.canonical_lab_spatial import (
_TimedPoints,
_TimedPoses,
_bounded_local_slam, _bounded_local_slam,
_estimate_sensor_height, _CanonicalSpatialIndex,
_estimate_local_sensor_height, _estimate_local_sensor_height,
_estimate_sensor_height,
_gravity_stable_basis_map_from_body, _gravity_stable_basis_map_from_body,
_ground_origin_map, _ground_origin_map,
_TimedPoints,
_TimedPoses,
canonical_lab_spatial_playback_points,
) )
@@ -127,3 +130,43 @@ def test_sensor_height_tracks_current_source_window_instead_of_fixed_mount() ->
assert high == pytest.approx(1.05, abs=0.03) assert high == pytest.approx(1.05, abs=0.03)
assert low_samples >= 3 and high_samples >= 3 assert low_samples >= 3 and high_samples >= 3
assert low_source == high_source == "local-source-cloud-ground-quantile-median" assert low_source == high_source == "local-source-cloud-ground-quantile-median"
def test_playback_track_binds_sparse_map_increments_to_dense_camera_timeline(
tmp_path,
monkeypatch,
) -> None:
recording = tmp_path / "recording.rrd"
recording.write_bytes(b"sealed")
points = _TimedPoints(
times_ns=(10, 20),
values=(
np.asarray([[1.0, 2.0, 3.0]], dtype=np.float32),
np.asarray([[4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], dtype=np.float32),
),
)
empty_poses = _TimedPoses(times_ns=(), translations=(), quaternions_xyzw=())
index = _CanonicalSpatialIndex(
points=points,
poses=empty_poses,
trajectories=_TimedPoints(times_ns=(), values=()),
sensor_height_m=0.4,
sensor_height_sample_count=0,
sensor_height_mad_m=0.0,
)
monkeypatch.setattr(spatial_module, "_load_index", lambda *_args: index)
track, offsets = canonical_lab_spatial_playback_points(
recording,
"a" * 64,
(10, 15, 20, 25),
)
assert offsets == (0, 1, 1, 3, 3)
assert track.tolist() == [
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0],
]
assert track.dtype == np.dtype("<f4")
assert not track.flags.writeable
+20
View File
@@ -22,6 +22,7 @@ from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review from k1link.laboratory.vegetation_policy_review import seal_vegetation_policy_review
from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab from k1link.laboratory.vegetation_shadow_lab import seal_vegetation_shadow_lab
from k1link.web.vegetation_shadow_lab_api import ( from k1link.web.vegetation_shadow_lab_api import (
_canonical_route_playback_chunk_descriptor,
_mask_component_boxes, _mask_component_boxes,
_route_tgs_anchor_payload, _route_tgs_anchor_payload,
build_vegetation_shadow_lab_router, build_vegetation_shadow_lab_router,
@@ -42,6 +43,25 @@ def test_semantic_component_boxes_keep_distinct_objects_separate() -> None:
] ]
def test_route_playback_chunk_descriptor_seals_only_requested_binary_window() -> None:
points = np.arange(18, dtype="<f4").reshape(6, 3)
descriptor = _canonical_route_playback_chunk_descriptor(
"/api/v1/laboratory/vegetation-shadow",
f"lab-v1-vegetation-shadow-{'a' * 64}",
memoryview(points).cast("B"),
(0, 1, 1, 3, 6),
0,
)
assert descriptor is not None
assert descriptor["start"] == 0
assert descriptor["count"] == 4
assert descriptor["point_count"] == 6
assert descriptor["bytes"] == points.nbytes
assert descriptor["shape"] == [6, 3]
assert len(str(descriptor["sha256"])) == 64
def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None: def test_route_tgs_anchor_payload_preserves_metric_evidence(tmp_path: Path) -> None:
path = tmp_path / "tgs-evidence.npz" path = tmp_path / "tgs-evidence.npz"
point_counts = np.arange(1, 11, dtype=np.int64) point_counts = np.arange(1, 11, dtype=np.int64)