diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 9e11dc2..33f4d33 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -352,6 +352,7 @@ interface RecordedSegmentStreamRuntime { target: RecordedSegmentTarget | null; fetchAbort: AbortController | null; notifiedRevision: number; + hasPresentedFrame: boolean; pumping: boolean; disposed: boolean; onTargetBuffered: ((target: RecordedSegmentTarget) => void) | null; @@ -944,6 +945,7 @@ export function RecordedFmp4Player({ target: null, fetchAbort: null, notifiedRevision: 0, + hasPresentedFrame: false, pumping: false, disposed: false, onTargetBuffered: null, @@ -1090,8 +1092,13 @@ export function RecordedFmp4Player({ runtime.fetchAbort?.abort(); runtime.target = target; const alreadyBuffered = recordedSegmentTargetBuffered(runtime, target); + const retainForwardFrame = Boolean( + runtime.hasPresentedFrame + && previousTarget + && candidateTarget.sequence >= previousTarget.sequence, + ); video.pause(); - if (!alreadyBuffered) { + if (!alreadyBuffered && !retainForwardFrame) { setReadyGeneration(null); setState("loading"); } @@ -1119,6 +1126,7 @@ export function RecordedFmp4Player({ || runtime.target?.revision !== bufferedTarget.revision ) return; setBufferRevision((revision) => revision + 1); + runtime.hasPresentedFrame = true; setReadyGeneration(runtime.generation); setState("ready"); reportAdmission({ diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index f14897c..a4bb6bd 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -1,6 +1,7 @@ import { forwardRef, type CSSProperties, + useCallback, useEffect, useImperativeHandle, useRef, @@ -53,6 +54,12 @@ export interface LaboratoryMetricCellEvidence { state: LaboratoryMetricCellState; } +export interface LaboratoryMetricPackedCellEvidence { + centersBodyXyM: Float32Array; + zBoundsM: Float32Array; + stateCodes: Uint8Array; +} + export interface LaboratoryMetricLegendEntry { id: LaboratoryMetricDecision | LaboratoryMetricCellState @@ -63,6 +70,13 @@ export interface LaboratoryMetricLegendEntry { label: string; } +interface LaboratoryMetricRenderStats { + frameMs: number; + drawCalls: number; + triangles: number; + pixelRatio: number; +} + export function laboratoryMetricLegendEntries({ pointCloudCount, localSurfaceCount, @@ -256,6 +270,7 @@ LaboratoryMetricEvidenceSceneHandle, semanticClasses?: readonly RecordedEvidenceSemanticClass[]; semanticPalette?: readonly RecordedEvidenceSemanticPaletteEntry[]; classifiedCells?: readonly LaboratoryMetricCellEvidence[]; + classifiedPackedCells?: LaboratoryMetricPackedCellEvidence; classifiedCellSizeM?: number; showClassifiedCells?: boolean; } @@ -276,6 +291,7 @@ LaboratoryMetricEvidenceSceneHandle, semanticClasses, semanticPalette, classifiedCells = [], + classifiedPackedCells, classifiedCellSizeM = 0.45, showClassifiedCells = true, }, ref) { @@ -288,10 +304,16 @@ LaboratoryMetricEvidenceSceneHandle, const classifiedContentRef = useRef(null); const localSurfacePointsRef = useRef(null); const currentIncrementPointsRef = useRef(null); + const requestRenderRef = useRef<() => void>(() => undefined); + const corridorForwardLengthRef = useRef(corridor.forwardLengthM); + const modeRef = useRef(mode); const classifiedMeshesRef = useRef>( new Map(), ); const [renderError, setRenderError] = useState(null); + const [renderStats, setRenderStats] = useState(null); + corridorForwardLengthRef.current = corridor.forwardLengthM; + modeRef.current = mode; useEffect(() => { const host = hostRef.current; @@ -368,27 +390,51 @@ LaboratoryMetricEvidenceSceneHandle, localSurfacePointsRef.current = localSurfacePoints; currentIncrementPointsRef.current = currentIncrementPoints; + let animationFrame: number | null = null; + let lastStatsAt = Number.NEGATIVE_INFINITY; + const render = () => { + animationFrame = null; + // OrbitControls emits another change while damping is still settling, so + // this remains smooth during interaction without rendering forever while + // the evidence scene is idle. + controls.update(); + const startedAt = performance.now(); + renderer.render(scene, camera); + const completedAt = performance.now(); + if (completedAt - lastStatsAt >= 1_000) { + lastStatsAt = completedAt; + setRenderStats({ + frameMs: completedAt - startedAt, + drawCalls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + pixelRatio: renderer.getPixelRatio(), + }); + } + }; + const requestRender = () => { + if (animationFrame !== null) return; + animationFrame = window.requestAnimationFrame(render); + }; + requestRenderRef.current = requestRender; + controls.addEventListener("change", requestRender); + const resize = () => { const width = Math.max(host.clientWidth, 1); const height = Math.max(host.clientHeight, 1); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); + requestRender(); }; const observer = new ResizeObserver(resize); observer.observe(host); resize(); - let animationFrame = 0; - const render = () => { - animationFrame = window.requestAnimationFrame(render); - controls.update(); - renderer.render(scene, camera); - }; - render(); + requestRender(); return () => { - window.cancelAnimationFrame(animationFrame); + if (animationFrame !== null) window.cancelAnimationFrame(animationFrame); observer.disconnect(); + controls.removeEventListener("change", requestRender); controls.dispose(); scene.traverse(disposeRenderable); renderer.dispose(); @@ -401,6 +447,7 @@ LaboratoryMetricEvidenceSceneHandle, classifiedContentRef.current = null; localSurfacePointsRef.current = null; currentIncrementPointsRef.current = null; + requestRenderRef.current = () => undefined; classifiedMeshesRef.current = new Map(); }; }, []); @@ -415,6 +462,7 @@ LaboratoryMetricEvidenceSceneHandle, if (!points) return; points.visible = showLocalSurface && localSurfaceBodyXyzM.length > 0; if (points.visible) updatePointPositions(points.geometry, localSurfaceBodyXyzM); + requestRenderRef.current(); }, [localSurfaceBodyXyzM, showLocalSurface]); useEffect(() => { @@ -422,7 +470,10 @@ LaboratoryMetricEvidenceSceneHandle, const points = currentIncrementPointsRef.current; if (!host || !points) return; points.visible = showCurrentIncrement && pointCloudBodyXyzM.length > 0; - if (!points.visible) return; + if (!points.visible) { + requestRenderRef.current(); + return; + } updatePointPositions(points.geometry, pointCloudBodyXyzM); const material = points.material as THREE.PointsMaterial; const hasAlignedSemanticClasses = @@ -436,6 +487,7 @@ LaboratoryMetricEvidenceSceneHandle, material.needsUpdate = true; } material.color.copy(tokenColor(host, "--nodedc-text-muted", [147, 151, 159])); + requestRenderRef.current(); return; } const declaredIds = new Set(semanticClasses.map((item) => item.id)); @@ -461,6 +513,7 @@ LaboratoryMetricEvidenceSceneHandle, material.needsUpdate = true; } material.color.setRGB(1, 1, 1); + requestRenderRef.current(); }, [ pointCloudBodyXyzM, pointSemanticClassIds, @@ -543,7 +596,7 @@ LaboratoryMetricEvidenceSceneHandle, content.add(centroid); } } - + requestRenderRef.current(); }, [ obstacles, occupiedVoxelSizeM, @@ -556,10 +609,21 @@ LaboratoryMetricEvidenceSceneHandle, const host = hostRef.current; const content = classifiedContentRef.current; if (!host || !content) return; - content.visible = showClassifiedCells && classifiedCells.length > 0; - if (!content.visible) return; + const packedCellCount = classifiedPackedCells?.stateCodes.length ?? 0; + const packedCellsValid = !classifiedPackedCells || ( + classifiedPackedCells.centersBodyXyM.length === packedCellCount * 2 + && classifiedPackedCells.zBoundsM.length === packedCellCount * 2 + ); + const cellCount = packedCellsValid && classifiedPackedCells + ? packedCellCount + : classifiedCells.length; + content.visible = showClassifiedCells && cellCount > 0; + if (!content.visible) { + requestRenderRef.current(); + return; + } - const requiredCapacity = classifiedCells.length; + const requiredCapacity = cellCount; const currentMeshes = classifiedMeshesRef.current; const firstMesh = currentMeshes.values().next().value as THREE.InstancedMesh | undefined; const needsAllocation = !firstMesh @@ -609,12 +673,16 @@ LaboratoryMetricEvidenceSceneHandle, const position = new THREE.Vector3(); const scale = new THREE.Vector3(1, 1, 1); const rotation = new THREE.Quaternion(); - for (const cell of classifiedCells) { - const mesh = meshes.get(cell.state); - if (!mesh) continue; - const index = counts.get(cell.state) ?? 0; - const minimum = cell.zBoundsM[0]; - const maximum = cell.zBoundsM[1]; + const setCell = ( + state: LaboratoryMetricCellState, + centerX: number, + centerY: number, + minimum: number | null, + maximum: number | null, + ) => { + const mesh = meshes.get(state); + if (!mesh) return; + const index = counts.get(state) ?? 0; const height = minimum === null || maximum === null ? 0.018 : Math.max(0.018, maximum - minimum); @@ -622,21 +690,55 @@ LaboratoryMetricEvidenceSceneHandle, ? -0.012 : (minimum + maximum) / 2; const [sceneX, sceneY, sceneZ] = scenePoint([ - cell.centerBodyXyM[0], - cell.centerBodyXyM[1], + centerX, + centerY, centerZ, ]); position.set(sceneX, sceneY, sceneZ); scale.set(1, height, 1); matrix.compose(position, rotation, scale); mesh.setMatrixAt(index, matrix); - counts.set(cell.state, index + 1); + counts.set(state, index + 1); + }; + if (packedCellsValid && classifiedPackedCells) { + for (let cellIndex = 0; cellIndex < packedCellCount; cellIndex += 1) { + const code = classifiedPackedCells.stateCodes[cellIndex]; + const state: LaboratoryMetricCellState = code === 1 + ? "ground-support" + : code === 2 + ? "nonground-occupied" + : code === 3 + ? "unknown-rejected" + : code === 0 + ? "unobserved" + : "unknown-rejected"; + const minimum = classifiedPackedCells.zBoundsM[cellIndex * 2]!; + const maximum = classifiedPackedCells.zBoundsM[cellIndex * 2 + 1]!; + setCell( + state, + classifiedPackedCells.centersBodyXyM[cellIndex * 2]!, + classifiedPackedCells.centersBodyXyM[cellIndex * 2 + 1]!, + Number.isFinite(minimum) ? minimum : null, + Number.isFinite(maximum) ? maximum : null, + ); + } + } else { + for (const cell of classifiedCells) { + setCell( + cell.state, + cell.centerBodyXyM[0], + cell.centerBodyXyM[1], + cell.zBoundsM[0], + cell.zBoundsM[1], + ); + } } for (const [state, mesh] of meshes) { mesh.count = counts.get(state) ?? 0; mesh.instanceMatrix.needsUpdate = true; } - }, [classifiedCellSizeM, classifiedCells, showClassifiedCells]); + requestRenderRef.current(); + }, [classifiedCellSizeM, classifiedCells, classifiedPackedCells, showClassifiedCells]); useEffect(() => { const host = hostRef.current; @@ -702,15 +804,24 @@ LaboratoryMetricEvidenceSceneHandle, material.depthWrite = false; }); content.add(grid); - }, [corridor, rig]); + requestRenderRef.current(); + }, [ + corridor.forwardLengthM, + corridor.halfWidthM, + corridor.rearMarginM, + rig.lengthM, + rig.nominalSensorHeightM, + rig.widthM, + ]); - const resetView = () => { + const resetView = useCallback(() => { const camera = cameraRef.current; const controls = controlsRef.current; if (!camera || !controls) return; - controls.target.set(corridor.forwardLengthM * 0.35, 0.6, 0); - if (mode === "plan") { - camera.position.set(corridor.forwardLengthM * 0.35, 15, 0.001); + const forwardLengthM = corridorForwardLengthRef.current; + controls.target.set(forwardLengthM * 0.35, 0.6, 0); + if (modeRef.current === "plan") { + camera.position.set(forwardLengthM * 0.35, 15, 0.001); camera.up.set(0, 0, -1); } else { camera.position.set(-4.5, 4.8, 8.5); @@ -718,10 +829,13 @@ LaboratoryMetricEvidenceSceneHandle, } camera.updateProjectionMatrix(); controls.update(); - }; + requestRenderRef.current(); + }, []); - useEffect(resetView, [corridor.forwardLengthM, mode]); - useImperativeHandle(ref, () => ({ resetView })); + // Playback data and rig object identities must never reset an operator's + // orbit. Only a deliberate 3D/PLAN mode transition chooses a default view. + useEffect(() => resetView(), [mode, resetView]); + useImperativeHandle(ref, () => ({ resetView }), [resetView]); const semanticLegendEntries = (() => { if ( @@ -755,8 +869,23 @@ LaboratoryMetricEvidenceSceneHandle, showLowStep, }); const classifiedLegendEntries = (() => { - if (!showClassifiedCells || !classifiedCells.length) return []; - const states = new Set(classifiedCells.map((cell) => cell.state)); + const states = new Set(); + if (classifiedPackedCells) { + for (const code of classifiedPackedCells.stateCodes) { + states.add(code === 1 + ? "ground-support" + : code === 2 + ? "nonground-occupied" + : code === 3 + ? "unknown-rejected" + : code === 0 + ? "unobserved" + : "unknown-rejected"); + } + } else { + classifiedCells.forEach((cell) => states.add(cell.state)); + } + if (!showClassifiedCells || !states.size) return []; return [ states.has("ground-support") ? { id: "ground-support" as const, label: "Ground support" } @@ -783,6 +912,16 @@ LaboratoryMetricEvidenceSceneHandle, {renderError ?

{renderError}

: null}
+ {renderStats ? ( + + 3D {renderStats.frameMs.toFixed(1)} ms · {renderStats.drawCalls} draw · {renderStats.triangles.toLocaleString("ru-RU")} tri · {( + classifiedPackedCells?.stateCodes.length ?? classifiedCells.length + ).toLocaleString("ru-RU")} cells · {renderStats.pixelRatio.toFixed(1)}× + + ) : null} {metricLegendEntries.map((entry) => ( {entry.label} ))} diff --git a/apps/control-station/src/core/laboratory/m49PhysicalSafetyPlayback.ts b/apps/control-station/src/core/laboratory/m49PhysicalSafetyPlayback.ts new file mode 100644 index 0000000..2f88c0f --- /dev/null +++ b/apps/control-station/src/core/laboratory/m49PhysicalSafetyPlayback.ts @@ -0,0 +1,671 @@ +import type { LaboratoryFetch } from "./advancedResults"; + +const RESULT_ID = /^m49-physical-safety-playback-[a-f0-9]{64}$/; +const SOURCE_RESULT_ID = /^m49-tgs-full-shadow-[a-f0-9]{64}$/; +const SCHEMA = "missioncore.m49-physical-safety-playback/v1"; +const CHUNK_MAGIC = "MCPSCH01"; +const CHUNK_HEADER_BYTES = 28; +const ENDPOINT_ROOT = "/api/v1/laboratory/m49/physical-safety-playback"; + +export interface M49PhysicalSafetyPlaybackFrameMetadata { + sourceSequence: number; + sourceFrameIndex: number; + sessionSeconds: number; + sampleAvailable: boolean; + metrics: Readonly>; +} + +interface ArtifactDescriptor { + url: string; + mediaType: string; + byteLength: number; + sha256: string; +} + +interface ChunkDescriptor extends ArtifactDescriptor { + index: number; + start: number; + count: number; + headerBytes: 28; + format: "mcpsch01-states-u8-aligned-z-bounds-f32le"; +} + +export interface M49PhysicalSafetyPlaybackManifest { + resultId: string; + createdAtUtc: string; + sourceResultId: string; + frameCount: number; + cellCount: number; + cellSizeM: number; + radiusM: number; + sourcePaceHz: number; + stateCodes: Readonly>; + chunkFrameCount: 32; + startupPrebufferChunkCount: 2; + residentChunkCountMax: 3; + forwardPrefetchChunkCount: 1; + centers: ArtifactDescriptor; + frames: ArtifactDescriptor; + chunks: readonly ChunkDescriptor[]; +} + +interface ResidentChunk { + descriptor: ChunkDescriptor; + states: Uint8Array; + zBoundsM: Float32Array; + byteLength: number; +} + +export interface M49PhysicalSafetyPlaybackFrame { + metadata: M49PhysicalSafetyPlaybackFrameMetadata; + centersXyM: Float32Array; + states: Uint8Array; + zBoundsM: Float32Array; + cellSizeM: number; + radiusM: number; +} + +export interface M49PhysicalSafetyPlaybackProgress { + phase: "manifest" | "static" | "prebuffer" | "ready" | "seek"; + residentChunkIndexes: readonly number[]; + loadedBytes: number; +} + +export class M49PhysicalSafetyPlaybackContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +function text(value: unknown, label: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалась строка.`); + } + return value; +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось число.`); + } + return value; +} + +function integer(value: unknown, label: string): number { + const parsed = finiteNumber(value, label); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидалось целое число.`); + } + return parsed; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался boolean.`); + } + return value; +} + +function exact(value: unknown, expected: T, label: string): T { + if (value !== expected) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: нарушен контракт.`); + } + return expected; +} + +function sha256(value: unknown, label: string): string { + const parsed = text(value, label); + if (!/^[a-f0-9]{64}$/.test(parsed)) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: SHA-256 недопустим.`); + } + return parsed; +} + +function localUrl(value: unknown, expected: string, label: string): string { + const parsed = text(value, label); + if (parsed !== expected || parsed.includes("worker")) { + throw new M49PhysicalSafetyPlaybackContractError(`${label}: разрешён только sealed local API.`); + } + return parsed; +} + +function descriptor( + value: unknown, + expectedUrl: string, + label: string, +): ArtifactDescriptor { + const row = objectValue(value, label); + return { + url: localUrl(row.url, expectedUrl, `${label}.url`), + mediaType: text(row.media_type, `${label}.media_type`), + byteLength: integer(row.byte_length, `${label}.byte_length`), + sha256: sha256(row.sha256, `${label}.sha256`), + }; +} + +async function sha256Hex(buffer: ArrayBuffer): Promise { + if (!globalThis.crypto?.subtle) { + throw new M49PhysicalSafetyPlaybackContractError( + "Браузер не поддерживает проверку playback SHA-256.", + ); + } + const digest = await globalThis.crypto.subtle.digest("SHA-256", buffer); + return [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +async function fetchVerified( + artifact: ArtifactDescriptor, + fetcher: LaboratoryFetch, + signal?: AbortSignal, +): Promise { + const response = await fetcher(artifact.url, { + method: "GET", + headers: { Accept: artifact.mediaType }, + signal, + }); + if (!response.ok) { + throw new M49PhysicalSafetyPlaybackContractError( + `Sealed local playback недоступен: HTTP ${response.status}.`, + ); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength !== artifact.byteLength) { + throw new M49PhysicalSafetyPlaybackContractError( + "Sealed local playback: получен неверный размер.", + ); + } + if (await sha256Hex(buffer) !== artifact.sha256) { + throw new M49PhysicalSafetyPlaybackContractError( + "Sealed local playback: SHA-256 не совпал.", + ); + } + return buffer; +} + +function parseStateCodes(value: unknown): Readonly> { + const row = objectValue(value, "M49 physical-safety state codes"); + const parsed: Record = {}; + for (const [name, code] of Object.entries(row)) { + const stateCode = integer(code, `M49 physical-safety state code ${name}`); + if (stateCode > 255) { + throw new M49PhysicalSafetyPlaybackContractError("M49 state code превышает uint8."); + } + parsed[name] = stateCode; + } + if (parsed.UNOBSERVED !== 0 || !Object.keys(parsed).length) { + throw new M49PhysicalSafetyPlaybackContractError("M49 UNOBSERVED state изменился."); + } + return parsed; +} + +async function fetchManifest( + resultId: string, + fetcher: LaboratoryFetch, + signal?: AbortSignal, +): Promise { + if (!RESULT_ID.test(resultId)) { + throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety identity недопустима."); + } + const base = `${ENDPOINT_ROOT}/${encodeURIComponent(resultId)}`; + const response = await fetcher(`${base}/manifest`, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) { + throw new M49PhysicalSafetyPlaybackContractError( + `M49 physical-safety manifest недоступен: HTTP ${response.status}.`, + ); + } + const payload = objectValue(await response.json(), "M49 physical-safety manifest"); + exact(payload.schema_version, SCHEMA, "M49 physical-safety schema"); + exact(payload.result_id, resultId, "M49 physical-safety result"); + exact(payload.access, "read-only-sealed-local", "M49 physical-safety access"); + const identity = objectValue(payload.identity, "M49 physical-safety identity"); + const execution = objectValue(payload.execution, "M49 physical-safety execution"); + exact(execution.execution_class, "local-sequential-offline", "M49 execution class"); + exact(execution.worker_role, "realtime-only", "M49 worker role"); + exact(execution.worker_runtime_dependency, false, "M49 worker dependency"); + exact(execution.worker_requests_required, 0, "M49 worker requests"); + const authority = objectValue(payload.authority, "M49 physical-safety authority"); + exact(authority.commands_enabled, false, "M49 commands"); + exact(authority.navigation_or_safety_accepted, false, "M49 safety authority"); + exact(authority.actuation_accepted, false, "M49 actuation authority"); + const playback = objectValue(payload.playback, "M49 physical-safety playback"); + exact(playback.coordinate_frame, "map-gravity-local", "M49 coordinate frame"); + const frameCount = integer(playback.frame_count, "M49 frame count"); + const cellCount = integer(playback.cell_count, "M49 cell count"); + if (!frameCount || !cellCount) { + throw new M49PhysicalSafetyPlaybackContractError("M49 playback не содержит evidence."); + } + const chunkFrameCount = exact( + integer(playback.chunk_frame_count, "M49 chunk frame count"), + 32, + "M49 chunk frame count", + ); + const startupPrebufferChunkCount = exact( + integer(playback.startup_prebuffer_chunk_count, "M49 startup prebuffer"), + 2, + "M49 startup prebuffer", + ); + const residentChunkCountMax = exact( + integer(playback.resident_chunk_count_max, "M49 resident chunks"), + 3, + "M49 resident chunks", + ); + const forwardPrefetchChunkCount = exact( + integer(playback.forward_prefetch_chunk_count, "M49 forward prefetch"), + 1, + "M49 forward prefetch", + ); + const centers = descriptor(playback.centers, `${base}/tracks/centers`, "M49 centers"); + const centerRow = objectValue(playback.centers, "M49 centers"); + exact(centerRow.dtype, " { + const row = objectValue(value, `M49 chunk ${index}`); + const common = descriptor(value, `${base}/chunks/${index}`, `M49 chunk ${index}`); + const start = index * chunkFrameCount; + const count = Math.min(chunkFrameCount, frameCount - start); + exact(integer(row.index, `M49 chunk ${index}.index`), index, `M49 chunk ${index}.index`); + exact(integer(row.start, `M49 chunk ${index}.start`), start, `M49 chunk ${index}.start`); + exact(integer(row.count, `M49 chunk ${index}.count`), count, `M49 chunk ${index}.count`); + return { + ...common, + index, + start, + count, + headerBytes: exact( + integer(row.header_bytes, `M49 chunk ${index}.header`), + CHUNK_HEADER_BYTES, + `M49 chunk ${index}.header`, + ), + format: exact( + row.format, + "mcpsch01-states-u8-aligned-z-bounds-f32le", + `M49 chunk ${index}.format`, + ), + }; + }); + return { + resultId, + createdAtUtc: text(payload.created_at_utc, "M49 physical-safety created"), + sourceResultId: text(identity.source_result_id, "M49 physical-safety source"), + frameCount, + cellCount, + cellSizeM: finiteNumber(playback.cell_size_m, "M49 cell size"), + radiusM: finiteNumber(playback.radius_m, "M49 radius"), + sourcePaceHz: finiteNumber(playback.source_pace_hz, "M49 source pace"), + stateCodes: parseStateCodes(playback.state_codes), + chunkFrameCount, + startupPrebufferChunkCount, + residentChunkCountMax, + forwardPrefetchChunkCount, + centers, + frames, + chunks, + }; +} + +export async function fetchM49PhysicalSafetyPlaybackIdForSource( + sourceResultId: string, + { + fetcher = fetch, + signal, + }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (!SOURCE_RESULT_ID.test(sourceResultId)) { + throw new M49PhysicalSafetyPlaybackContractError("M49 source identity недопустима."); + } + const response = await fetcher( + `${ENDPOINT_ROOT}/results?limit=2&source_result_id=${encodeURIComponent(sourceResultId)}`, + { method: "GET", headers: { Accept: "application/json" }, signal }, + ); + if (!response.ok) { + throw new M49PhysicalSafetyPlaybackContractError( + `M49 physical-safety catalog недоступен: HTTP ${response.status}.`, + ); + } + const payload = objectValue(await response.json(), "M49 physical-safety catalog"); + exact( + payload.schema_version, + "missioncore.m49-physical-safety-playback-catalog/v1", + "M49 physical-safety catalog schema", + ); + exact(payload.worker_runtime_dependency, false, "M49 physical-safety catalog worker"); + exact(payload.access, "read-only-sealed-local", "M49 physical-safety catalog access"); + if (!Array.isArray(payload.items)) { + throw new M49PhysicalSafetyPlaybackContractError("M49 physical-safety catalog изменился."); + } + if (!payload.items.length) return null; + if (payload.items.length !== 1) { + throw new M49PhysicalSafetyPlaybackContractError( + "M49 physical-safety source имеет несколько активных playback artifacts.", + ); + } + const item = objectValue(payload.items[0], "M49 physical-safety catalog item"); + exact(item.source_result_id, sourceResultId, "M49 physical-safety catalog source"); + exact(item.worker_runtime_dependency, false, "M49 physical-safety item worker"); + exact(item.navigation_or_safety_accepted, false, "M49 physical-safety item authority"); + const resultId = text(item.result_id, "M49 physical-safety catalog result"); + if (!RESULT_ID.test(resultId)) { + throw new M49PhysicalSafetyPlaybackContractError( + "M49 physical-safety catalog result недопустим.", + ); + } + return resultId; +} + +function parseFrames( + buffer: ArrayBuffer, + frameCount: number, +): readonly M49PhysicalSafetyPlaybackFrameMetadata[] { + const lines = new TextDecoder().decode(buffer).trim().split("\n"); + if (lines.length !== frameCount) { + throw new M49PhysicalSafetyPlaybackContractError("M49 frame catalog изменил размер."); + } + return lines.map((line, sourceSequence) => { + const row = objectValue(JSON.parse(line), `M49 frame ${sourceSequence}`); + const metrics: Record = {}; + for (const [name, value] of Object.entries(row)) { + if (name.endsWith("_count") && typeof value === "number" && Number.isFinite(value)) { + metrics[name] = value; + } + } + return { + sourceSequence, + sourceFrameIndex: integer(row.source_frame_index, `M49 frame ${sourceSequence}.source`), + sessionSeconds: finiteNumber(row.session_seconds, `M49 frame ${sourceSequence}.time`), + sampleAvailable: booleanValue( + row.sample_available, + `M49 frame ${sourceSequence}.available`, + ), + metrics, + }; + }); +} + +function parseChunk( + buffer: ArrayBuffer, + descriptorValue: ChunkDescriptor, + cellCount: number, + frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[], + admittedStateCodes: ReadonlySet, +): ResidentChunk { + const bytes = new Uint8Array(buffer); + if (bytes.byteLength < CHUNK_HEADER_BYTES + || new TextDecoder("latin1").decode(bytes.subarray(0, 8)) !== CHUNK_MAGIC) { + throw new M49PhysicalSafetyPlaybackContractError("M49 chunk signature изменилась."); + } + const view = new DataView(buffer); + const start = view.getUint32(8, true); + const count = view.getUint32(12, true); + const cells = view.getUint32(16, true); + const stateBytes = view.getUint32(20, true); + const zBytes = view.getUint32(24, true); + const expectedStateBytes = count * cellCount; + const expectedZBytes = count * cellCount * 2 * 4; + const zOffset = CHUNK_HEADER_BYTES + stateBytes + + ((4 - ((CHUNK_HEADER_BYTES + stateBytes) % 4)) % 4); + if (start !== descriptorValue.start + || count !== descriptorValue.count + || cells !== cellCount + || stateBytes !== expectedStateBytes + || zBytes !== expectedZBytes + || zOffset + zBytes !== bytes.byteLength) { + throw new M49PhysicalSafetyPlaybackContractError("M49 chunk layout изменился."); + } + const states = new Uint8Array(buffer, CHUNK_HEADER_BYTES, stateBytes); + const zBoundsM = new Float32Array(buffer, zOffset, count * cellCount * 2); + for (let localFrame = 0; localFrame < count; localFrame += 1) { + const stateStart = localFrame * cellCount; + const zStart = localFrame * cellCount * 2; + for (let cell = 0; cell < cellCount; cell += 1) { + const state = states[stateStart + cell]; + if (!admittedStateCodes.has(state)) { + throw new M49PhysicalSafetyPlaybackContractError("M49 chunk содержит неизвестный state."); + } + if (!frames[start + localFrame].sampleAvailable + && (state !== 0 + || Number.isFinite(zBoundsM[zStart + cell * 2]) + || Number.isFinite(zBoundsM[zStart + cell * 2 + 1]))) { + throw new M49PhysicalSafetyPlaybackContractError( + "M49 missing sample перестал быть fail-closed.", + ); + } + } + } + return { descriptor: descriptorValue, states, zBoundsM, byteLength: buffer.byteLength }; +} + +export class M49PhysicalSafetyPlaybackBuffer { + readonly manifest: M49PhysicalSafetyPlaybackManifest; + readonly centersXyM: Float32Array; + readonly frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[]; + + private readonly fetcher: LaboratoryFetch; + private readonly signal: AbortSignal | undefined; + private readonly onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined; + private readonly chunks = new Map(); + private readonly pending = new Map>(); + private readonly admittedStateCodes: ReadonlySet; + private staticBytes: number; + private preparedWindowKey: string | null = null; + private pendingPrepare: { key: string; promise: Promise } | null = null; + + private constructor( + manifest: M49PhysicalSafetyPlaybackManifest, + centersXyM: Float32Array, + frames: readonly M49PhysicalSafetyPlaybackFrameMetadata[], + fetcher: LaboratoryFetch, + signal: AbortSignal | undefined, + onProgress: ((value: M49PhysicalSafetyPlaybackProgress) => void) | undefined, + ) { + this.manifest = manifest; + this.centersXyM = centersXyM; + this.frames = frames; + this.fetcher = fetcher; + this.signal = signal; + this.onProgress = onProgress; + this.admittedStateCodes = new Set(Object.values(manifest.stateCodes)); + this.staticBytes = centersXyM.byteLength + manifest.frames.byteLength; + } + + static async open( + resultId: string, + { + fetcher = fetch, + signal, + onProgress, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + onProgress?: (value: M49PhysicalSafetyPlaybackProgress) => void; + } = {}, + ): Promise { + onProgress?.({ phase: "manifest", residentChunkIndexes: [], loadedBytes: 0 }); + const manifest = await fetchManifest(resultId, fetcher, signal); + const [centersBuffer, framesBuffer] = await Promise.all([ + fetchVerified(manifest.centers, fetcher, signal), + fetchVerified(manifest.frames, fetcher, signal), + ]); + onProgress?.({ + phase: "static", + residentChunkIndexes: [], + loadedBytes: centersBuffer.byteLength + framesBuffer.byteLength, + }); + const centersXyM = new Float32Array(centersBuffer); + const frames = parseFrames(framesBuffer, manifest.frameCount); + const playback = new M49PhysicalSafetyPlaybackBuffer( + manifest, + centersXyM, + frames, + fetcher, + signal, + onProgress, + ); + const startupCount = Math.min( + manifest.startupPrebufferChunkCount, + manifest.chunks.length, + ); + await Promise.all(Array.from( + { length: startupCount }, + (_, index) => playback.loadChunk(index, "prebuffer"), + )); + playback.emit("ready"); + return playback; + } + + get residentChunkIndexes(): readonly number[] { + return [...this.chunks.keys()].sort((left, right) => left - right); + } + + get residentByteLength(): number { + return this.staticBytes + + [...this.chunks.values()].reduce((sum, chunk) => sum + chunk.byteLength, 0); + } + + async prepare(sourceSequence: number): Promise { + const chunkIndex = this.chunkIndex(sourceSequence); + const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount; + const indexes = [chunkIndex, nextIndex].filter( + (index) => index < this.manifest.chunks.length, + ); + const key = indexes.join(":"); + if (this.preparedWindowKey === key && indexes.every((index) => this.chunks.has(index))) { + return; + } + if (this.pendingPrepare?.key === key) return this.pendingPrepare.promise; + const promise = Promise.all(indexes.map((index) => this.loadChunk(index, "seek"))) + .then(() => { + this.trim(new Set(indexes)); + this.preparedWindowKey = key; + this.emit("seek"); + }) + .finally(() => { + if (this.pendingPrepare?.key === key) this.pendingPrepare = null; + }); + this.pendingPrepare = { key, promise }; + return promise; + } + + async frame(sourceSequence: number): Promise { + const chunkIndex = this.chunkIndex(sourceSequence); + const chunk = await this.loadChunk(chunkIndex, "seek"); + const frame = this.frameFromChunk(sourceSequence, chunk); + const nextIndex = chunkIndex + this.manifest.forwardPrefetchChunkCount; + if (nextIndex < this.manifest.chunks.length && !this.chunks.has(nextIndex)) { + void this.loadChunk(nextIndex, "seek").catch(() => undefined); + } + return frame; + } + + frameIfResident(sourceSequence: number): M49PhysicalSafetyPlaybackFrame | null { + const chunkIndex = this.chunkIndex(sourceSequence); + const chunk = this.chunks.get(chunkIndex); + return chunk ? this.frameFromChunk(sourceSequence, chunk) : null; + } + + private frameFromChunk( + sourceSequence: number, + chunk: ResidentChunk, + ): M49PhysicalSafetyPlaybackFrame { + const localFrame = sourceSequence - chunk.descriptor.start; + const stateOffset = localFrame * this.manifest.cellCount; + const zOffset = stateOffset * 2; + return { + metadata: this.frames[sourceSequence], + centersXyM: this.centersXyM, + states: chunk.states.subarray(stateOffset, stateOffset + this.manifest.cellCount), + zBoundsM: chunk.zBoundsM.subarray(zOffset, zOffset + this.manifest.cellCount * 2), + cellSizeM: this.manifest.cellSizeM, + radiusM: this.manifest.radiusM, + }; + } + + private chunkIndex(sourceSequence: number): number { + if (!Number.isSafeInteger(sourceSequence) + || sourceSequence < 0 + || sourceSequence >= this.manifest.frameCount) { + throw new M49PhysicalSafetyPlaybackContractError("M49 source sequence недопустима."); + } + return Math.floor(sourceSequence / this.manifest.chunkFrameCount); + } + + private async loadChunk( + index: number, + phase: "prebuffer" | "seek", + ): Promise { + const existing = this.chunks.get(index); + if (existing) { + this.chunks.delete(index); + this.chunks.set(index, existing); + return existing; + } + const inflight = this.pending.get(index); + if (inflight) return inflight; + const descriptorValue = this.manifest.chunks[index]; + if (!descriptorValue) { + throw new M49PhysicalSafetyPlaybackContractError("M49 chunk отсутствует."); + } + const promise = fetchVerified(descriptorValue, this.fetcher, this.signal) + .then((buffer) => parseChunk( + buffer, + descriptorValue, + this.manifest.cellCount, + this.frames, + this.admittedStateCodes, + )) + .then((chunk) => { + this.chunks.set(index, chunk); + this.trim(new Set([index])); + this.emit(phase); + return chunk; + }) + .finally(() => this.pending.delete(index)); + this.pending.set(index, promise); + return promise; + } + + private trim(protectedIndexes: ReadonlySet): void { + while (this.chunks.size > this.manifest.residentChunkCountMax) { + const removable = [...this.chunks.keys()].find((index) => !protectedIndexes.has(index)); + if (removable === undefined) break; + this.chunks.delete(removable); + } + } + + private emit(phase: M49PhysicalSafetyPlaybackProgress["phase"]): void { + this.onProgress?.({ + phase, + residentChunkIndexes: this.residentChunkIndexes, + loadedBytes: this.residentByteLength, + }); + } +} diff --git a/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts index 82eb56e..bd4e7dd 100644 --- a/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts +++ b/apps/control-station/src/core/laboratory/m49TgsFullShadow.ts @@ -120,7 +120,7 @@ export interface M49TgsFullShadowPlaybackPack { frameCount: 4489; cellCount: 2244; totalByteLength: number; - centersXyM: readonly (readonly [number, number])[]; + centersXyM: Float32Array; states: Uint8Array; zBoundsM: Float32Array; frames: readonly M49TgsFullShadowPlaybackFrame[]; @@ -628,9 +628,6 @@ export async function fetchM49TgsFullShadowPlaybackPack( " ( - [centersRaw[index * 2]!, centersRaw[index * 2 + 1]!] as const - )); const states = parseM49TgsNpyTrack( buffers.get("states")!, "|u1", @@ -653,7 +650,7 @@ export async function fetchM49TgsFullShadowPlaybackPack( frameCount: manifest.frameCount, cellCount: manifest.cellCount, totalByteLength: manifest.totalByteLength, - centersXyM, + centersXyM: centersRaw, states, zBoundsM, frames, diff --git a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts index bd8127c..14f980d 100644 --- a/apps/control-station/src/core/laboratory/m4ReplayThreat.ts +++ b/apps/control-station/src/core/laboratory/m4ReplayThreat.ts @@ -238,6 +238,37 @@ export interface M4ThreatPlaybackPointPack { pointCount: number; pointOffsets: Uint32Array; pointsMapXyzM: Float32Array; + pointStart?: number; + startSequence?: number; + sequenceCount?: number; +} + +export interface M4ThreatPlaybackChunkDescriptor { + index: number; + start: number; + count: number; + pointStart: number; + pointCount: number; + url: string; + mediaType: "application/octet-stream"; + byteLength: number; + sha256: string; +} + +export interface M4ThreatPlaybackManifest { + resultId: string; + frameCount: 4489; + pointCount: number; + pointOffsets: Uint32Array; + chunkFrameCount: 24; + residentChunkCountMax: 4; + forwardPrefetchChunkCount: 1; + chunks: readonly M4ThreatPlaybackChunkDescriptor[]; + track: { + url: string; + byteLength: number; + sha256: string; + }; } type LaboratoryFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -889,7 +920,14 @@ export function hydrateM4ThreatTimelineFrame( frame: M4ThreatTimelineFrame, pack: M4ThreatPlaybackPointPack, ): M4ThreatTimelineFrame { - if (frame.sequence >= pack.frameCount || pack.resultId === "") { + if ( + frame.sequence >= pack.frameCount + || pack.resultId === "" + || (pack.startSequence !== undefined && ( + frame.sequence < pack.startSequence + || frame.sequence >= pack.startSequence + (pack.sequenceCount ?? 0) + )) + ) { throw new M4ThreatContractError("M4.6 binary playback frame: нарушена идентичность."); } if (!frame.sourceAvailable || !frame.spatialAvailable || !frame.bodyFrame) { @@ -900,7 +938,15 @@ export function hydrateM4ThreatTimelineFrame( } const start = pack.pointOffsets[frame.sequence]; const stop = pack.pointOffsets[frame.sequence + 1]; - if (start === undefined || stop === undefined || stop < start || stop > pack.pointCount) { + const pointStart = pack.pointStart ?? 0; + const pointStop = pointStart + pack.pointCount; + if ( + start === undefined + || stop === undefined + || stop < start + || start < pointStart + || stop > pointStop + ) { throw new M4ThreatContractError("M4.6 binary playback offsets: нарушена размерность."); } const count = stop - start; @@ -911,7 +957,7 @@ export function hydrateM4ThreatTimelineFrame( const basis = frame.bodyFrame.basisMapFromBody; const points = new Array(count); for (let index = 0; index < count; index += 1) { - const sourceOffset = (start + index) * 3; + const sourceOffset = (start - pointStart + index) * 3; const dx = pack.pointsMapXyzM[sourceOffset]! - origin[0]; const dy = pack.pointsMapXyzM[sourceOffset + 1]! - origin[1]; const dz = pack.pointsMapXyzM[sourceOffset + 2]! - origin[2]; @@ -924,22 +970,19 @@ export function hydrateM4ThreatTimelineFrame( return { ...frame, pointCloudBodyXyzM: points, pointCloudSampleCount: count }; } -export async function fetchM4ThreatPlaybackPointPack( +export async function fetchM4ThreatPlaybackManifest( result: string, { fetcher = fetch, signal, endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, - onProgress, }: { fetcher?: LaboratoryFetch; signal?: AbortSignal; endpointRoot?: string; - onProgress?: (progress: M4ThreatPlaybackProgress) => void; } = {}, -): Promise { +): Promise { resultId(result); - onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); const manifestResponse = await fetcher(`${endpointRoot}/${result}/timeline/playback`, { headers: { Accept: "application/json" }, signal, @@ -982,46 +1025,180 @@ export async function fetchM4ThreatPlaybackPointPack( if (!/^[a-f0-9]{64}$/.test(sha256)) { throw new M4ThreatContractError("M4.6 playback SHA-256: нарушен контракт."); } - const response = await fetcher(text(track.url, "M4.6 playback URL"), { - headers: { Accept: "application/octet-stream" }, - signal, + const trackUrl = text(track.url, "M4.6 playback URL"); + const chunkFrameCount = exact( + integer(manifest.chunk_frame_count, "M4.6 playback chunk frames"), + 24, + "M4.6 playback chunk frames", + ); + const residentChunkCountMax = exact( + integer(manifest.resident_chunk_count_max, "M4.6 playback resident chunks"), + 4, + "M4.6 playback resident chunks", + ); + const forwardPrefetchChunkCount = exact( + integer(manifest.forward_prefetch_chunk_count, "M4.6 playback prefetch chunks"), + 1, + "M4.6 playback prefetch chunks", + ); + const chunkRows = array(manifest.chunks, "M4.6 playback chunks"); + const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount); + if (chunkRows.length !== expectedChunkCount) { + throw new M4ThreatContractError("M4.6 playback chunk catalog: неверный размер."); + } + const chunks = chunkRows.map((value, index): M4ThreatPlaybackChunkDescriptor => { + const row = object(value, `M4.6 playback chunk ${index}`); + const start = index * chunkFrameCount; + const count = Math.min(chunkFrameCount, frameCount - start); + const pointStart = pointOffsets[start]!; + const pointStop = pointOffsets[start + count]!; + const pointChunkCount = pointStop - pointStart; + exact(integer(row.index, `M4.6 playback chunk ${index}.index`), index, `M4.6 playback chunk ${index}.index`); + exact(integer(row.start, `M4.6 playback chunk ${index}.start`), start, `M4.6 playback chunk ${index}.start`); + exact(integer(row.count, `M4.6 playback chunk ${index}.count`), count, `M4.6 playback chunk ${index}.count`); + exact(integer(row.point_start, `M4.6 playback chunk ${index}.point start`), pointStart, `M4.6 playback chunk ${index}.point start`); + exact(integer(row.point_count, `M4.6 playback chunk ${index}.point count`), pointChunkCount, `M4.6 playback chunk ${index}.point count`); + exact(row.media_type, "application/octet-stream", `M4.6 playback chunk ${index}.media type`); + exact(row.dtype, " integer(item, `M4.6 playback chunk ${index}.shape`)); + const chunkBytes = integer(row.bytes, `M4.6 playback chunk ${index}.bytes`); + if ( + shapeValue.length !== 2 + || shapeValue[0] !== pointChunkCount + || shapeValue[1] !== 3 + || chunkBytes !== pointChunkCount * 3 * Float32Array.BYTES_PER_ELEMENT + ) { + throw new M4ThreatContractError(`M4.6 playback chunk ${index}: нарушена размерность.`); + } + const chunkSha256 = text(row.sha256, `M4.6 playback chunk ${index}.SHA-256`); + if (!/^[a-f0-9]{64}$/.test(chunkSha256)) { + throw new M4ThreatContractError(`M4.6 playback chunk ${index}: SHA-256 недопустим.`); + } + const expectedUrl = `${endpointRoot}/${result}/timeline/playback/chunks/${index}`; + const url = text(row.url, `M4.6 playback chunk ${index}.URL`); + if (url !== expectedUrl || url.includes("worker")) { + throw new M4ThreatContractError(`M4.6 playback chunk ${index}: разрешён только sealed local API.`); + } + return { + index, + start, + count, + pointStart, + pointCount: pointChunkCount, + url, + mediaType: "application/octet-stream", + byteLength: chunkBytes, + sha256: chunkSha256, + }; }); - if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`); - const bytes = new Uint8Array(byteLength); - if (response.body) { - const reader = response.body.getReader(); - let offset = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (offset + value.byteLength > bytes.byteLength) { - throw new M4ThreatContractError("M4.6 playback points превысил объявленный размер."); - } - bytes.set(value, offset); - offset += value.byteLength; - onProgress?.({ phase: "download", loadedBytes: offset, totalBytes: byteLength }); - } - if (offset !== byteLength) { - throw new M4ThreatContractError("M4.6 playback points получен не полностью."); - } - } else { - const fallback = new Uint8Array(await response.arrayBuffer()); - if (fallback.byteLength !== byteLength) { - throw new M4ThreatContractError("M4.6 playback points: неверный размер."); - } - bytes.set(fallback); - } - onProgress?.({ phase: "verify", loadedBytes: byteLength, totalBytes: byteLength }); - if (await playbackSha256Hex(bytes.buffer) !== sha256) { - throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал."); - } - onProgress?.({ phase: "ready", loadedBytes: byteLength, totalBytes: byteLength }); return { resultId: result, frameCount, pointCount, pointOffsets, - pointsMapXyzM: new Float32Array(bytes.buffer), + chunkFrameCount, + residentChunkCountMax, + forwardPrefetchChunkCount, + chunks, + track: { url: trackUrl, byteLength, sha256 }, + }; +} + +export async function fetchM4ThreatPlaybackPointChunk( + manifest: M4ThreatPlaybackManifest, + chunkIndex: number, + { + fetcher = fetch, + signal, + onProgress, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + onProgress?: (progress: M4ThreatPlaybackProgress) => void; + } = {}, +): Promise { + const descriptor = manifest.chunks[chunkIndex]; + if (!descriptor || descriptor.index !== chunkIndex) { + throw new M4ThreatContractError("M4.6 playback chunk отсутствует."); + } + onProgress?.({ phase: "download", loadedBytes: 0, totalBytes: descriptor.byteLength }); + const response = await fetcher(descriptor.url, { + headers: { Accept: "application/octet-stream" }, + signal, + }); + if (!response.ok) { + throw new M4ThreatContractError(`M4.6 playback chunk: HTTP ${response.status}.`); + } + const buffer = await response.arrayBuffer(); + if (buffer.byteLength !== descriptor.byteLength) { + throw new M4ThreatContractError("M4.6 playback chunk: неверный размер."); + } + onProgress?.({ + phase: "verify", + loadedBytes: descriptor.byteLength, + totalBytes: descriptor.byteLength, + }); + if (await playbackSha256Hex(buffer) !== descriptor.sha256) { + throw new M4ThreatContractError("M4.6 playback chunk: SHA-256 не совпал."); + } + onProgress?.({ + phase: "ready", + loadedBytes: descriptor.byteLength, + totalBytes: descriptor.byteLength, + }); + return { + resultId: manifest.resultId, + frameCount: manifest.frameCount, + pointCount: descriptor.pointCount, + pointOffsets: manifest.pointOffsets, + pointsMapXyzM: new Float32Array(buffer), + pointStart: descriptor.pointStart, + startSequence: descriptor.start, + sequenceCount: descriptor.count, + }; +} + +export async function fetchM4ThreatPlaybackPointPack( + result: string, + { + fetcher = fetch, + signal, + endpointRoot = M4_THREAT_TIMELINE_ENDPOINT_ROOT, + onProgress, + }: { + fetcher?: LaboratoryFetch; + signal?: AbortSignal; + endpointRoot?: string; + onProgress?: (progress: M4ThreatPlaybackProgress) => void; + } = {}, +): Promise { + onProgress?.({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); + const manifest = await fetchM4ThreatPlaybackManifest(result, { + fetcher, + signal, + endpointRoot, + }); + const response = await fetcher(manifest.track.url, { + headers: { Accept: "application/octet-stream" }, + signal, + }); + if (!response.ok) throw new M4ThreatContractError(`M4.6 playback points: HTTP ${response.status}.`); + const buffer = await response.arrayBuffer(); + if (buffer.byteLength !== manifest.track.byteLength) { + throw new M4ThreatContractError("M4.6 playback points: неверный размер."); + } + onProgress?.({ phase: "verify", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength }); + if (await playbackSha256Hex(buffer) !== manifest.track.sha256) { + throw new M4ThreatContractError("M4.6 playback points: SHA-256 не совпал."); + } + onProgress?.({ phase: "ready", loadedBytes: buffer.byteLength, totalBytes: buffer.byteLength }); + return { + resultId: manifest.resultId, + frameCount: manifest.frameCount, + pointCount: manifest.pointCount, + pointOffsets: manifest.pointOffsets, + pointsMapXyzM: new Float32Array(buffer), }; } diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index b0d3d98..5a1c808 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -236,9 +236,10 @@ box-sizing: border-box; min-width: 0; align-items: stretch; - width: max-content; - max-width: calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem); - grid-template-columns: minmax(0, max-content); + width: min(17.5rem, calc(var(--m4-replay-threat-overlay-pane-width, 100%) - 1.2rem)); + max-width: none; + grid-auto-rows: 3.8rem; + grid-template-columns: minmax(0, 1fr); gap: 0.3rem; overflow: hidden; background: transparent; @@ -247,6 +248,9 @@ } .m4-replay-threat-visual__overlay > div { + box-sizing: border-box; + height: 3.8rem; + grid-template-rows: auto auto minmax(1.32rem, 1fr); border-radius: var(--nodedc-radius-control-compact); background: color-mix(in srgb, var(--nodedc-floating-surface) 80%, transparent); padding: 0.55rem 0.65rem; @@ -254,15 +258,24 @@ } .m4-replay-threat-visual__overlay span, -.m4-replay-threat-visual__overlay strong, -.m4-replay-threat-visual__overlay small { +.m4-replay-threat-visual__overlay strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.m4-replay-threat-visual__overlay small { + display: -webkit-box; + min-height: 1.32rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; +} + @media (max-width: 900px) { .m4-replay-threat-visual__overlay { + width: min(17.5rem, calc(100% - 1.2rem)); max-width: calc(100% - 1.2rem); } } @@ -372,3 +385,13 @@ background: var(--nodedc-text-muted); opacity: 0.42; } + +.laboratory-metric-evidence-scene__legend span[data-decision="performance"] { + font-variant-numeric: tabular-nums; +} + +.laboratory-metric-evidence-scene__legend span[data-decision="performance"]::before { + box-sizing: border-box; + border: 1px solid rgb(var(--nodedc-accent-rgb)); + background: transparent; +} diff --git a/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx index 56544ec..11c1714 100644 --- a/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx +++ b/apps/control-station/src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx @@ -8,13 +8,16 @@ import { fetchE47SemanticSlamResult, type E47SemanticSlamResult, } from "../../core/laboratory/e47SemanticSlam"; +import { + fetchM49PhysicalSafetyPlaybackIdForSource, + M49PhysicalSafetyPlaybackBuffer, + type M49PhysicalSafetyPlaybackProgress, +} from "../../core/laboratory/m49PhysicalSafetyPlayback"; import { fetchM49TgsFullShadowPlaybackPack, type M49TgsFullShadowResult, type M49TgsFullShadowPlaybackPack, type M49TgsFullShadowPlaybackProgress, - type M49TgsFullShadowSpatial, - type M49TgsFullShadowStateCode, } from "../../core/laboratory/m49TgsFullShadow"; import { M4ReplayThreatVisual, @@ -33,13 +36,6 @@ const PALETTE: readonly RecordedEvidenceSemanticPaletteEntry[] = [ { classId: 3, color: { kind: "token", token: "--nodedc-warning-rgb" } }, ]; -function cellState(code: M49TgsFullShadowStateCode): M4ReplayClassifiedSpatialFrame["cellsMapGravityLocal"][number]["state"] { - if (code === 1) return "ground-support"; - if (code === 2) return "nonground-occupied"; - if (code === 3) return "unknown-rejected"; - return "unobserved"; -} - function message(error: unknown): string { return error instanceof Error && error.message.trim() ? error.message @@ -51,6 +47,9 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR const [semantic, setSemantic] = useState(null); const [semanticError, setSemanticError] = useState(null); const [playbackPack, setPlaybackPack] = useState(null); + const [physicalPlayback, setPhysicalPlayback] = useState(null); + const [physicalProgress, setPhysicalProgress] = useState(null); + const [physicalRevision, setPhysicalRevision] = useState(0); const [playbackProgress, setPlaybackProgress] = useState({ phase: "manifest", trackId: null, @@ -83,12 +82,27 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR useEffect(() => { const controller = new AbortController(); setPlaybackPack(null); + setPhysicalPlayback(null); + setPhysicalProgress(null); + setPhysicalRevision(0); setPlaybackProgress({ phase: "manifest", trackId: null, loadedBytes: 0, totalBytes: 0 }); setError(null); - void fetchM49TgsFullShadowPlaybackPack(result.resultId, { + void fetchM49PhysicalSafetyPlaybackIdForSource(result.resultId, { signal: controller.signal, - onProgress: setPlaybackProgress, - }).then((pack) => { + }).then(async (physicalResultId) => { + if (controller.signal.aborted) return; + if (physicalResultId) { + const playback = await M49PhysicalSafetyPlaybackBuffer.open(physicalResultId, { + signal: controller.signal, + onProgress: setPhysicalProgress, + }); + if (!controller.signal.aborted) setPhysicalPlayback(playback); + return; + } + const pack = await fetchM49TgsFullShadowPlaybackPack(result.resultId, { + signal: controller.signal, + onProgress: setPlaybackProgress, + }); if (!controller.signal.aborted) setPlaybackPack(pack); }).catch((caught: unknown) => { if (!controller.signal.aborted) setError(message(caught)); @@ -96,69 +110,84 @@ export function M49TgsFullShadowEvidence({ result }: { result: M49TgsFullShadowR return () => controller.abort(); }, [result.resultId]); - const spatial = useMemo(() => { - if (activeSequence === null || !playbackPack) return null; + useEffect(() => { + if (activeSequence === null || !physicalPlayback) return; + let active = true; + const resident = physicalPlayback.frameIfResident(activeSequence); + void physicalPlayback.prepare(activeSequence).then(() => { + if (active && !resident) setPhysicalRevision((value) => value + 1); + }).catch((caught: unknown) => { + if (active) setError(message(caught)); + }); + return () => { + active = false; + }; + }, [activeSequence, physicalPlayback]); + + const classifiedFrame = useMemo(() => { + if (activeSequence === null) return null; + if (physicalPlayback) { + const frame = physicalPlayback.frameIfResident(activeSequence); + if (!frame) return null; + return { + sourceSequence: activeSequence, + sampleAvailable: frame.metadata.sampleAvailable, + sourcePointCount: frame.metadata.metrics.eligible_point_count ?? 0, + pointsMapGravityLocalXyzM: [], + pointClassIds: [], + cellsMapGravityLocal: [], + packedCellsMapGravityLocal: { + centersXyM: frame.centersXyM, + stateCodes: frame.states, + zBoundsM: frame.zBoundsM, + }, + cellSizeM: frame.cellSizeM, + classes: CLASSES, + palette: PALETTE, + }; + } + if (!playbackPack) return null; const frame = playbackPack.frames[activeSequence]; if (!frame) return null; const cellOffset = activeSequence * playbackPack.cellCount; const zOffset = cellOffset * 2; - const states = Array.from( - playbackPack.states.subarray(cellOffset, cellOffset + playbackPack.cellCount), - (value) => value as M49TgsFullShadowStateCode, - ); - const zBoundsM = Array.from({ length: playbackPack.cellCount }, (_, index) => { - const minimum = playbackPack.zBoundsM[zOffset + index * 2]!; - const maximum = playbackPack.zBoundsM[zOffset + index * 2 + 1]!; - return [ - Number.isFinite(minimum) ? minimum : null, - Number.isFinite(maximum) ? maximum : null, - ] as const; - }); return { - resultId: playbackPack.resultId, sourceSequence: activeSequence, - sourceFrameIndex: frame.sourceFrameIndex, - sessionSeconds: frame.sessionSeconds, sampleAvailable: frame.sampleAvailable, - costmap: { - cellSizeM: result.configuration.cellSizeM, - radiusM: result.configuration.radiusM, + sourcePointCount: frame.metrics.eligiblePointCount, + pointsMapGravityLocalXyzM: [], + pointClassIds: [], + cellsMapGravityLocal: [], + packedCellsMapGravityLocal: { centersXyM: playbackPack.centersXyM, - states, - zBoundsM, + stateCodes: playbackPack.states.subarray( + cellOffset, + cellOffset + playbackPack.cellCount, + ), + zBoundsM: playbackPack.zBoundsM.subarray( + zOffset, + zOffset + playbackPack.cellCount * 2, + ), }, - metrics: frame.metrics, + cellSizeM: result.configuration.cellSizeM, + classes: CLASSES, + palette: PALETTE, }; - }, [activeSequence, playbackPack, result.configuration.cellSizeM, result.configuration.radiusM]); - const loading = activeSequence !== null && !spatial && !error; + }, [activeSequence, physicalPlayback, physicalRevision, playbackPack, result.configuration.cellSizeM]); + const loading = activeSequence !== null && !classifiedFrame && !error; const progressPercent = playbackProgress.totalBytes > 0 ? Math.min(100, Math.round((playbackProgress.loadedBytes / playbackProgress.totalBytes) * 100)) : 0; - const loadingLabel = playbackProgress.phase === "manifest" + const loadingLabel = physicalProgress + ? physicalProgress.phase === "ready" + ? "Sealed TGS playback готов" + : `Буферизуем sealed TGS · ${physicalProgress.residentChunkIndexes.length}/3 chunks · ${(physicalProgress.loadedBytes / 1_048_576).toFixed(1)} МБ` + : playbackProgress.phase === "manifest" ? "Проверяем playback manifest" : playbackProgress.phase === "ready" ? "TGS playback готов" : `Подготавливаем TGS playback · ${progressPercent}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ`; - const classifiedFrame = useMemo(() => { - if (!spatial) return null; - return { - sourceSequence: spatial.sourceSequence, - sampleAvailable: spatial.sampleAvailable, - sourcePointCount: spatial.metrics.eligiblePointCount, - pointsMapGravityLocalXyzM: [], - pointClassIds: [], - cellsMapGravityLocal: spatial.costmap.centersXyM.map((center, index) => ({ - centerXyM: center, - zBoundsM: spatial.costmap.zBoundsM[index]!, - state: cellState(spatial.costmap.states[index]!), - })), - cellSizeM: spatial.costmap.cellSizeM, - classes: CLASSES, - palette: PALETTE, - }; - }, [spatial]); - const handleSequenceChange = useCallback((sequence: number | null) => { setActiveSequence(sequence); }, []); diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index f3b4ea8..41af4c0 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -14,6 +14,7 @@ import { LaboratoryMetricEvidenceScene, type LaboratoryMetricCellEvidence, type LaboratoryMetricEvidenceSceneHandle, + type LaboratoryMetricPackedCellEvidence, type LaboratoryMetricSceneMode, } from "../../components/laboratory/LaboratoryMetricEvidenceScene"; import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer"; @@ -118,6 +119,11 @@ export interface M4ReplayClassifiedSpatialFrame { zBoundsM: readonly [number | null, number | null]; state: LaboratoryMetricCellEvidence["state"]; }[]; + packedCellsMapGravityLocal?: { + centersXyM: Float32Array; + zBoundsM: Float32Array; + stateCodes: Uint8Array; + }; cellSizeM: number; classes: readonly RecordedEvidenceSemanticClass[]; palette: readonly RecordedEvidenceSemanticPaletteEntry[]; @@ -414,19 +420,41 @@ export function M4ReplayThreatVisual({ resultId: string; frame: M4ReplayClassifiedSpatialFrame; } | null>(null); - if (classifiedSpatialLayer?.frame) { - lastClassifiedSpatialFrameRef.current = { resultId, frame: classifiedSpatialLayer.frame }; + const incomingClassifiedSpatialFrame = classifiedSpatialLayer?.frame ?? null; + if (incomingClassifiedSpatialFrame && incomingClassifiedSpatialFrame.sampleAvailable !== false) { + lastClassifiedSpatialFrameRef.current = { resultId, frame: incomingClassifiedSpatialFrame }; } + const lastAvailableClassifiedSpatialFrame = lastClassifiedSpatialFrameRef.current?.resultId === resultId + ? lastClassifiedSpatialFrameRef.current.frame + : null; + // `undefined !== false` is true, so the optional-chain form here used to + // select `null` during every short chunk miss. That unmounted the WebGL + // scene, flashed the alert state and recreated OrbitControls at the default + // view on the next frame. Keep the last complete volume until the exact + // classified frame is resident again. const displayedClassifiedSpatialFrame = classifiedSpatialFrame - ?? (lastClassifiedSpatialFrameRef.current?.resultId === resultId - ? lastClassifiedSpatialFrameRef.current.frame - : null); + && classifiedSpatialFrame.sampleAvailable !== false + ? classifiedSpatialFrame + : lastAvailableClassifiedSpatialFrame ?? classifiedSpatialFrame; + const displayedClassifiedFrameHeld = Boolean( + classifiedSpatialFrame + && displayedClassifiedSpatialFrame + && classifiedSpatialFrame.sourceSequence !== displayedClassifiedSpatialFrame.sourceSequence, + ); + const classifiedContextSpatialFrame = displayedClassifiedSpatialFrame + ? timelineFrame.availableFrames.find( + (candidate) => candidate.spatialAvailable + && candidate.sequence === displayedClassifiedSpatialFrame.sourceSequence, + ) ?? (spatialFrame?.sequence === displayedClassifiedSpatialFrame.sourceSequence + ? spatialFrame + : null) + : null; const replaceClassifiedPointCloud = classifiedSpatialLayer?.replacePointCloud ?? true; const nominalSensorHeightM = metadata.timeline?.rig.nominalSensorHeightM ?? 0; const mapGravityLocalSensorToBodyGround = useCallback(( point: readonly [number, number, number], ): readonly [number, number, number] => { - const basis = activeSpatialFrame?.bodyFrame?.basisMapFromBody; + const basis = classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody; const rotated: readonly [number, number, number] = basis ? [ basis[0][0] * point[0] + basis[1][0] * point[1] + basis[2][0] * point[2], basis[0][1] * point[0] + basis[1][1] * point[1] + basis[2][1] * point[2], @@ -435,7 +463,7 @@ export function M4ReplayThreatVisual({ // TGS evidence is translation-only map-gravity-local with the current LiDAR // as its origin. The metric scene uses the body ground projection as z=0. return [rotated[0], rotated[1], rotated[2] + nominalSensorHeightM]; - }, [activeSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]); + }, [classifiedContextSpatialFrame?.bodyFrame?.basisMapFromBody, nominalSensorHeightM]); const classifiedPointsBody = useMemo( () => displayedClassifiedSpatialFrame?.pointsMapGravityLocalXyzM.map( mapGravityLocalSensorToBodyGround, @@ -465,20 +493,58 @@ export function M4ReplayThreatVisual({ }) ?? [], [displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM], ); - const classifiedCellCounts = useMemo(() => ({ - ground: classifiedSpatialFrame?.cellsMapGravityLocal.filter( - (cell) => cell.state === "ground-support", - ).length ?? 0, - occupied: classifiedSpatialFrame?.cellsMapGravityLocal.filter( - (cell) => cell.state === "nonground-occupied", - ).length ?? 0, - rejected: classifiedSpatialFrame?.cellsMapGravityLocal.filter( - (cell) => cell.state === "unknown-rejected", - ).length ?? 0, - unobserved: classifiedSpatialFrame?.cellsMapGravityLocal.filter( - (cell) => cell.state === "unobserved", - ).length ?? 0, - }), [classifiedSpatialFrame]); + const classifiedPackedCellsBody = useMemo(() => { + const packed = displayedClassifiedSpatialFrame?.packedCellsMapGravityLocal; + if (!packed) return undefined; + const cellCount = packed.stateCodes.length; + if (packed.centersXyM.length !== cellCount * 2 || packed.zBoundsM.length !== cellCount * 2) { + return undefined; + } + const centersBodyXyM = new Float32Array(cellCount * 2); + const zBoundsM = new Float32Array(cellCount * 2); + for (let index = 0; index < cellCount; index += 1) { + const body = mapGravityLocalSensorToBodyGround([ + packed.centersXyM[index * 2]!, + packed.centersXyM[index * 2 + 1]!, + 0, + ]); + centersBodyXyM[index * 2] = body[0]; + centersBodyXyM[index * 2 + 1] = body[1]; + const minimum = packed.zBoundsM[index * 2]!; + const maximum = packed.zBoundsM[index * 2 + 1]!; + zBoundsM[index * 2] = Number.isFinite(minimum) + ? minimum + nominalSensorHeightM + : Number.NaN; + zBoundsM[index * 2 + 1] = Number.isFinite(maximum) + ? maximum + nominalSensorHeightM + : Number.NaN; + } + return { centersBodyXyM, zBoundsM, stateCodes: packed.stateCodes }; + }, [displayedClassifiedSpatialFrame, mapGravityLocalSensorToBodyGround, nominalSensorHeightM]); + const classifiedCellCounts = useMemo(() => { + const counts = { ground: 0, occupied: 0, rejected: 0, unobserved: 0 }; + const packed = classifiedSpatialFrame?.packedCellsMapGravityLocal; + if (packed) { + for (const code of packed.stateCodes) { + if (code === 1) counts.ground += 1; + else if (code === 2) counts.occupied += 1; + else if (code === 3) counts.rejected += 1; + else if (code === 0) counts.unobserved += 1; + else counts.rejected += 1; + } + return counts; + } + for (const cell of classifiedSpatialFrame?.cellsMapGravityLocal ?? []) { + if (cell.state === "ground-support") counts.ground += 1; + else if (cell.state === "nonground-occupied") counts.occupied += 1; + else if (cell.state === "unknown-rejected") counts.rejected += 1; + else counts.unobserved += 1; + } + return counts; + }, [classifiedSpatialFrame]); + const classifiedCellCount = classifiedSpatialFrame?.packedCellsMapGravityLocal?.stateCodes.length + ?? classifiedSpatialFrame?.cellsMapGravityLocal.length + ?? 0; const sceneObstacles = useMemo(() => spatialFrame?.metricObstacles.map((obstacle) => ({ id: obstacle.componentId, decision: obstacle.assessment.decision, @@ -840,14 +906,16 @@ export function M4ReplayThreatVisual({ {classifiedSpatialLayer ? classifiedSpatialFrame ? replaceClassifiedPointCloud - ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} cells` - : `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedSpatialFrame.cellsMapGravityLocal.length.toLocaleString("ru-RU")} TGS cells` + ? `${classifiedSpatialFrame.pointsMapGravityLocalXyzM.length.toLocaleString("ru-RU")} TGS points · ${classifiedCellCount.toLocaleString("ru-RU")} cells` + : `${(activeSpatialFrame?.pointCloudSourceCount ?? classifiedSpatialFrame.sourcePointCount ?? 0).toLocaleString("ru-RU")} source points · ${classifiedCellCount.toLocaleString("ru-RU")} TGS cells` : "TGS spatial buffer" : `${currentIncrementObstacles.length} current · ${rollingMapObstacles.length} rolling${metadata.timeline.occupancyProvenanceDelivery ? ` · ${lowStepObstacles.length} low-step` : ""}`} {classifiedSpatialLayer ? classifiedSpatialFrame ? classifiedSpatialFrame.sampleAvailable === false - ? "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s" + ? displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame + ? `LiDAR отсутствует · current UNOBSERVED · объёмный контекст удержан с frame ${displayedClassifiedSpatialFrame.sourceSequence + 1}` + : "LiDAR отсутствует · все ячейки принудительно UNOBSERVED · causal rolling 1 s" : activeSpatialFrame ? "map-gravity-local · all eligible points accounted · causal rolling 1 s" : "TGS рассчитан · linked source cloud недоступен для этого кадра" @@ -987,37 +1055,36 @@ export function M4ReplayThreatVisual({
) : null} - {(!classifiedSpatialLayer ? spatialFrame : displayedClassifiedSpatialFrame) ? ( - - ) : null} + {classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
{classifiedSpatialLayer.loading || displayingBufferedFrame @@ -1033,7 +1100,10 @@ export function M4ReplayThreatVisual({ ) : null} {classifiedSpatialFrame?.sampleAvailable === false ? (
- Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; все 2 244 TGS-ячейки явно UNOBSERVED. + Кадр {classifiedSpatialFrame.sourceSequence + 1}: LiDAR отсутствует; current safety — все {classifiedCellCount.toLocaleString("ru-RU")} TGS-ячейки UNOBSERVED + {displayedClassifiedFrameHeld && displayedClassifiedSpatialFrame + ? `; для ориентации удержан последний объёмный контекст кадра ${displayedClassifiedSpatialFrame.sourceSequence + 1}.` + : "."}
) : classifiedSpatialFrame && !activeSpatialFrame ? (
diff --git a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts index 6a41838..a9b7b38 100644 --- a/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts +++ b/apps/control-station/src/workspaces/laboratory/useM4ThreatTimeline.ts @@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { fetchM4ThreatCameraPointOverlay, - fetchM4ThreatPlaybackPointPack, + fetchM4ThreatPlaybackManifest, + fetchM4ThreatPlaybackPointChunk, fetchM4ThreatTimeline, fetchM4ThreatTimelineChunk, M4_THREAT_TIMELINE_ENDPOINT_ROOT, selectM4ThreatTimelineSequence, type M4ThreatCameraPointOverlay, - type M4ThreatPlaybackPointPack, + type M4ThreatPlaybackManifest, type M4ThreatPlaybackProgress, type M4ThreatTimeline, type M4ThreatTimelineChunk, @@ -86,7 +87,7 @@ export function useM4ThreatTimelineFrame({ () => new Map(), ); const [error, setError] = useState(null); - const [pointPack, setPointPack] = useState(null); + const [playbackManifest, setPlaybackManifest] = useState(null); const [playbackProgress, setPlaybackProgress] = useState({ phase: "manifest", loadedBytes: 0, @@ -102,7 +103,7 @@ export function useM4ThreatTimelineFrame({ useEffect(() => { const controller = new AbortController(); - setPointPack(null); + setPlaybackManifest(null); setPlaybackError(null); setPlaybackProgress({ phase: "manifest", loadedBytes: 0, totalBytes: 0 }); if (!timeline) return () => controller.abort(); @@ -110,15 +111,15 @@ export function useM4ThreatTimelineFrame({ setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 }); return () => controller.abort(); } - void fetchM4ThreatPlaybackPointPack(resultId, { + void fetchM4ThreatPlaybackManifest(resultId, { signal: controller.signal, endpointRoot, - onProgress: (progress) => { - if (!controller.signal.aborted) setPlaybackProgress(progress); - }, }) - .then((pack) => { - if (!controller.signal.aborted) setPointPack(pack); + .then((manifest) => { + if (!controller.signal.aborted) { + setPlaybackManifest(manifest); + setPlaybackProgress({ phase: "ready", loadedBytes: 0, totalBytes: 0 }); + } }) .catch((caught: unknown) => { if (!controller.signal.aborted) { @@ -157,7 +158,7 @@ export function useM4ThreatTimelineFrame({ activeChunkStartRef.current = activeChunkStart; useEffect(() => { - if (!timeline || activeChunkStart === null || (binaryPlayback && !pointPack)) return; + if (!timeline || activeChunkStart === null || (binaryPlayback && !playbackManifest)) return; const starts = m4ThreatChunkWindowStarts( activeChunkStart, chunkSize, @@ -168,12 +169,28 @@ export function useM4ThreatTimelineFrame({ if (chunksRef.current.has(start) || inFlight.current.has(start)) continue; const controller = new AbortController(); inFlight.current.set(start, controller); - void fetchM4ThreatTimelineChunk(resultId, start, chunkSize, { - signal: controller.signal, - endpointRoot, - cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, - playbackPointPack: binaryPlayback ? pointPack ?? undefined : undefined, - }) + void (async () => { + const playbackPointPack = binaryPlayback && playbackManifest + ? await fetchM4ThreatPlaybackPointChunk( + playbackManifest, + Math.floor(start / playbackManifest.chunkFrameCount), + { + signal: controller.signal, + onProgress: (progress) => { + if (!controller.signal.aborted && start === activeChunkStartRef.current) { + setPlaybackProgress(progress); + } + }, + }, + ) + : undefined; + return fetchM4ThreatTimelineChunk(resultId, start, chunkSize, { + signal: controller.signal, + endpointRoot, + cameraObstacleProjectionDelivery: timeline.cameraObstacleProjectionDelivery, + playbackPointPack, + }); + })() .then((chunk) => { if (controller.signal.aborted) return; setChunks((current) => { @@ -203,7 +220,7 @@ export function useM4ThreatTimelineFrame({ // loaded first, then the next chunk is prefetched on the following render. break; } - }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, pointPack, resultId, timeline]); + }, [activeChunkStart, binaryPlayback, chunkSize, chunks, endpointRoot, playbackManifest, resultId, timeline]); const activeFrame: M4ThreatTimelineFrame | null = useMemo(() => { if (activeSequence === null || activeChunkStart === null) return null; @@ -230,8 +247,8 @@ export function useM4ThreatTimelineFrame({ ? "Проверяем M4 playback manifest" : playbackProgress.phase === "ready" ? "Подготавливаем текущий spatial-кадр" - : `${playbackProgress.phase === "verify" ? "Проверяем" : "Загружаем"} M4 spatial playback · ${playbackProgress.totalBytes > 0 - ? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)}/${(playbackProgress.totalBytes / 1_048_576).toFixed(1)} МБ` + : `${playbackProgress.phase === "verify" ? "Проверяем" : "Буферизуем"} текущий spatial chunk · ${playbackProgress.totalBytes > 0 + ? `${Math.min(100, Math.round(playbackProgress.loadedBytes / playbackProgress.totalBytes * 100))}% · ${(playbackProgress.loadedBytes / 1_048_576).toFixed(1)} МБ` : "0%"}`, error: playbackError ?? error, }; diff --git a/apps/control-station/test/m48ObjectCentricQuality.test.mjs b/apps/control-station/test/m48ObjectCentricQuality.test.mjs index 4693109..8e85f5a 100644 --- a/apps/control-station/test/m48ObjectCentricQuality.test.mjs +++ b/apps/control-station/test/m48ObjectCentricQuality.test.mjs @@ -691,3 +691,18 @@ test("metric evidence keeps one WebGL renderer while frame labels advance", () = /querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/, ); }); + +test("metric evidence renders on demand while preserving damped camera interaction", () => { + const source = readFileSync( + new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), + "utf8", + ); + + assert.match(source, /const requestRenderRef = useRef/); + assert.match(source, /controls\.addEventListener\("change", requestRender\)/); + assert.match(source, /requestRenderRef\.current\(\)/); + assert.doesNotMatch( + source, + /const render = \(\) => \{\s*animationFrame = window\.requestAnimationFrame\(render\)/, + ); +}); diff --git a/apps/control-station/test/m49PhysicalSafetyPlayback.test.mjs b/apps/control-station/test/m49PhysicalSafetyPlayback.test.mjs new file mode 100644 index 0000000..cb49d74 --- /dev/null +++ b/apps/control-station/test/m49PhysicalSafetyPlayback.test.mjs @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { after, before, test } from "node:test"; + +import { createServer } from "vite"; + +let server; +let M49PhysicalSafetyPlaybackBuffer; +let fetchM49PhysicalSafetyPlaybackIdForSource; + +const resultId = `m49-physical-safety-playback-${"a".repeat(64)}`; +const endpoint = `/api/v1/laboratory/m49/physical-safety-playback/${resultId}`; +const frameCount = 160; +const cellCount = 4; + +before(async () => { + server = await createServer({ + appType: "custom", + logLevel: "silent", + server: { middlewareMode: true }, + }); + ({ + M49PhysicalSafetyPlaybackBuffer, + fetchM49PhysicalSafetyPlaybackIdForSource, + } = await server.ssrLoadModule( + "/src/core/laboratory/m49PhysicalSafetyPlayback.ts", + )); +}); + +after(async () => { + await server?.close(); +}); + +function sha256(buffer) { + return createHash("sha256").update(Buffer.from(buffer)).digest("hex"); +} + +function chunk(index) { + const start = index * 32; + const count = Math.min(32, frameCount - start); + const stateBytes = count * cellCount; + const zBytes = count * cellCount * 2 * 4; + const headerBytes = 28; + const padding = (4 - ((headerBytes + stateBytes) % 4)) % 4; + const buffer = new ArrayBuffer(headerBytes + stateBytes + padding + zBytes); + const bytes = new Uint8Array(buffer); + bytes.set(new TextEncoder().encode("MCPSCH01")); + const view = new DataView(buffer); + view.setUint32(8, start, true); + view.setUint32(12, count, true); + view.setUint32(16, cellCount, true); + view.setUint32(20, stateBytes, true); + view.setUint32(24, zBytes, true); + const states = new Uint8Array(buffer, headerBytes, stateBytes); + states.fill(1); + const z = new Float32Array(buffer, headerBytes + stateBytes + padding, count * cellCount * 2); + for (let local = 0; local < count; local += 1) { + const sequence = start + local; + for (let cell = 0; cell < cellCount; cell += 1) { + z[(local * cellCount + cell) * 2] = 0; + z[(local * cellCount + cell) * 2 + 1] = 0.12; + } + if (sequence === 17) { + states.fill(0, local * cellCount, (local + 1) * cellCount); + z.fill(Number.NaN, local * cellCount * 2, (local + 1) * cellCount * 2); + } + } + return buffer; +} + +function fixture() { + const centers = new Float32Array([0, 0, 0.15, 0, 0.3, 0, 0.45, 0]).buffer; + const frames = new TextEncoder().encode(Array.from({ length: frameCount }, (_, sequence) => ( + JSON.stringify({ + source_frame_index: sequence, + session_seconds: sequence / 10, + sample_available: sequence !== 17, + eligible_point_count: sequence === 17 ? 0 : cellCount, + }) + )).join("\n") + "\n").buffer; + const chunks = Array.from({ length: Math.ceil(frameCount / 32) }, (_, index) => chunk(index)); + const manifest = { + schema_version: "missioncore.m49-physical-safety-playback/v1", + result_id: resultId, + created_at_utc: "2026-08-27T12:00:00Z", + access: "read-only-sealed-local", + identity: { source_result_id: `m49-tgs-full-shadow-${"b".repeat(64)}` }, + execution: { + execution_class: "local-sequential-offline", + worker_role: "realtime-only", + worker_runtime_dependency: false, + worker_requests_required: 0, + }, + authority: { + commands_enabled: false, + navigation_or_safety_accepted: false, + actuation_accepted: false, + }, + playback: { + coordinate_frame: "map-gravity-local", + source_pace_hz: 10, + frame_count: frameCount, + cell_count: cellCount, + cell_size_m: 0.15, + radius_m: 12, + state_codes: { + UNOBSERVED: 0, + GROUND_SUPPORT: 1, + NONGROUND_OCCUPIED: 2, + UNKNOWN_REJECTED: 3, + }, + chunk_frame_count: 32, + startup_prebuffer_chunk_count: 2, + resident_chunk_count_max: 3, + forward_prefetch_chunk_count: 1, + centers: { + url: `${endpoint}/tracks/centers`, + media_type: "application/octet-stream", + byte_length: centers.byteLength, + sha256: sha256(centers), + dtype: " ({ + index, + start: index * 32, + count: Math.min(32, frameCount - index * 32), + url: `${endpoint}/chunks/${index}`, + media_type: "application/octet-stream", + byte_length: buffer.byteLength, + sha256: sha256(buffer), + header_bytes: 28, + format: "mcpsch01-states-u8-aligned-z-bounds-f32le", + })), + }, + }; + const responses = new Map([ + [`${endpoint}/tracks/centers`, centers], + [`${endpoint}/tracks/frames`, frames], + ...chunks.map((buffer, index) => [`${endpoint}/chunks/${index}`, buffer]), + ]); + return { manifest, responses }; +} + +test("physical-safety playback opens and seeks with bounded local chunks only", async () => { + const { manifest, responses } = fixture(); + const requested = []; + const progress = []; + const fetcher = async (input) => { + const url = String(input); + requested.push(url); + if (url === `${endpoint.replace(`/${resultId}`, "")}/results?limit=2&source_result_id=${manifest.identity.source_result_id}`) { + return Response.json({ + schema_version: "missioncore.m49-physical-safety-playback-catalog/v1", + worker_runtime_dependency: false, + access: "read-only-sealed-local", + items: [{ + result_id: resultId, + source_result_id: manifest.identity.source_result_id, + worker_runtime_dependency: false, + navigation_or_safety_accepted: false, + }], + }); + } + if (url === `${endpoint}/manifest`) { + return Response.json(manifest); + } + const payload = responses.get(url); + return payload + ? new Response(payload, { headers: { "Content-Type": "application/octet-stream" } }) + : new Response(null, { status: 404 }); + }; + + const resolvedResultId = await fetchM49PhysicalSafetyPlaybackIdForSource( + manifest.identity.source_result_id, + { fetcher }, + ); + assert.equal(resolvedResultId, resultId); + const playback = await M49PhysicalSafetyPlaybackBuffer.open(resolvedResultId, { + fetcher, + onProgress: (value) => progress.push(value), + }); + + assert.deepEqual(playback.residentChunkIndexes, [0, 1]); + assert.equal(progress.at(-1).phase, "ready"); + assert.equal(requested.some((url) => url.includes("worker")), false); + const startupRequestCount = requested.length; + await playback.prepare(0); + await playback.prepare(0); + assert.equal(requested.length, startupRequestCount); + const missing = await playback.frame(17); + assert.equal(missing.metadata.sampleAvailable, false); + assert.deepEqual(new Set(missing.states), new Set([0])); + assert.equal([...missing.zBoundsM].every((value) => Number.isNaN(value)), true); + + await playback.prepare(100); + assert.equal(playback.residentChunkIndexes.length <= 3, true); + assert.equal(playback.residentChunkIndexes.includes(3), true); + assert.equal(playback.residentChunkIndexes.includes(4), true); + const selected = await playback.frame(100); + assert.deepEqual([...selected.states], [1, 1, 1, 1]); + const maximumResidentBytes = manifest.playback.centers.byte_length + + manifest.playback.frames.byte_length + + 3 * Math.max(...manifest.playback.chunks.map((value) => value.byte_length)); + assert.equal(playback.residentByteLength <= maximumResidentBytes, true); + + await playback.prepare(0); + assert.equal(playback.residentChunkIndexes.length <= 3, true); + assert.equal(playback.residentChunkIndexes.includes(0), true); + assert.equal(playback.residentChunkIndexes.includes(1), true); + assert.equal( + requested.every((url) => url.startsWith("/api/v1/laboratory/m49/physical-safety-playback")), + true, + ); +}); + +test("physical-safety playback rejects a manifest that points at Worker", async () => { + const { manifest } = fixture(); + manifest.playback.centers.url = "http://worker-006/centers"; + await assert.rejects( + M49PhysicalSafetyPlaybackBuffer.open(resultId, { + fetcher: async () => Response.json(manifest), + }), + /sealed local API/, + ); +}); diff --git a/apps/control-station/test/m49TgsFullShadow.test.mjs b/apps/control-station/test/m49TgsFullShadow.test.mjs index 47d8a09..a66345b 100644 --- a/apps/control-station/test/m49TgsFullShadow.test.mjs +++ b/apps/control-station/test/m49TgsFullShadow.test.mjs @@ -74,24 +74,53 @@ test("M4.9T5 chunk keeps every missing-LiDAR cell explicitly UNOBSERVED", async assert.deepEqual(new Set(chunk.frames[0].costmap.states), new Set([0])); }); -test("M4.9T5 viewer preloads one immutable binary playback pack", async () => { - const [source, contract] = await Promise.all([ +test("M4.9T5 viewer prefers autonomous chunks and keeps a sealed legacy fallback", async () => { + const [source, visual, scene, contract] = await Promise.all([ readFile( new URL("../src/workspaces/laboratory/M49TgsFullShadowEvidence.tsx", import.meta.url), "utf8", ), + readFile( + new URL("../src/workspaces/laboratory/M4ReplayThreatVisual.tsx", import.meta.url), + "utf8", + ), + readFile( + new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url), + "utf8", + ), readFile( new URL("../src/core/laboratory/m49TgsFullShadow.ts", import.meta.url), "utf8", ), ]); + assert.match(source, /fetchM49PhysicalSafetyPlaybackIdForSource/); + assert.match(source, /M49PhysicalSafetyPlaybackBuffer\.open/); + assert.match(source, /physicalPlayback\.frameIfResident/); + assert.match(source, /physicalPlayback\.prepare/); assert.match(source, /fetchM49TgsFullShadowPlaybackPack/); assert.match(source, /playbackProgress/); assert.doesNotMatch(source, /fetchM49TgsFullShadowSpatialChunk/); - assert.match(source, /sampleAvailable: spatial\.sampleAvailable/); + assert.match(source, /sampleAvailable: frame\.sampleAvailable/); + assert.match(source, /packedCellsMapGravityLocal/); + assert.match(source, /playbackPack\.states\.subarray/); + assert.match(source, /playbackPack\.zBoundsM\.subarray/); + assert.doesNotMatch(source, /Array\.from\(\s*playbackPack\.states/); + assert.doesNotMatch(source, /centersXyM\.map\(/); assert.match(source, /fetchE47SemanticSlamResult/); assert.match(source, /next\.baseM4ResultId !== result\.source\.linkedVisualResultId/); assert.match(source, /semantic=\{semantic \? \{/); + assert.match( + visual, + /classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/, + ); + assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/); + assert.match(visual, /timelineFrame\.availableFrames\.find/); + assert.match(visual, / resetView\(\), \[mode, resetView\]\)/); assert.match(contract, /linked_semantic_result_id/); assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/); }); diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 50bede6..4d6edb7 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { after, before, test } from "node:test"; @@ -9,6 +10,8 @@ let fetchM4ThreatReplayResult; let fetchM4ThreatVisual; let fetchM4ThreatTimeline; let fetchM4ThreatTimelineChunk; +let fetchM4ThreatPlaybackManifest; +let fetchM4ThreatPlaybackPointChunk; let fetchM4ThreatCameraPointOverlay; let hydrateM4ThreatTimelineFrame; let selectM4ThreatTimelineFrame; @@ -33,6 +36,8 @@ before(async () => { fetchM4ThreatVisual, fetchM4ThreatTimeline, fetchM4ThreatTimelineChunk, + fetchM4ThreatPlaybackManifest, + fetchM4ThreatPlaybackPointChunk, fetchM4ThreatCameraPointOverlay, hydrateM4ThreatTimelineFrame, selectM4ThreatTimelineFrame, @@ -373,6 +378,76 @@ test("M4.6 hydrates a lightweight timeline frame from one retained binary point assert.equal(typeof hydrateM4ThreatTimelineFrame, "function"); }); +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 points = new Float32Array([11, 20, 30.25, 12, 19.5, 30]); + const pointBytes = points.buffer; + const pointSha256 = createHash("sha256").update(Buffer.from(pointBytes)).digest("hex"); + const endpointRoot = "/api/v1/laboratory/m4-threat/results"; + const chunks = Array.from({ length: 188 }, (_, index) => { + const start = index * 24; + const count = Math.min(24, 4489 - start); + const pointStart = pointOffsets[start]; + const pointStop = pointOffsets[start + count]; + const pointCount = pointStop - pointStart; + return { + index, + start, + count, + point_start: pointStart, + point_count: pointCount, + url: `${endpointRoot}/${resultId}/timeline/playback/chunks/${index}`, + media_type: "application/octet-stream", + dtype: " { + const url = String(input); + requested.push(url); + if (url.endsWith("/timeline/playback")) return Response.json(manifestPayload); + if (url.endsWith("/timeline/playback/chunks/0")) return new Response(pointBytes.slice(0)); + return new Response(null, { status: 404 }); + }; + const manifest = await fetchM4ThreatPlaybackManifest(resultId, { fetcher }); + const chunk = await fetchM4ThreatPlaybackPointChunk(manifest, 0, { fetcher }); + + assert.deepEqual(requested, [ + `${endpointRoot}/${resultId}/timeline/playback`, + `${endpointRoot}/${resultId}/timeline/playback/chunks/0`, + ]); + assert.equal(chunk.pointCount, 2); + assert.equal(chunk.pointStart, 0); + assert.equal(chunk.sequenceCount, 24); + assert.equal(chunk.pointsMapXyzM.byteLength, 24); + assert.equal(requested.some((url) => url.endsWith("points-map-f32")), false); +}); + test("M4.8S timeline binds factory-KB4 camera points through its exact endpoint", async () => { const replayResultId = `m48s-fixed-class-detector-lab-${"b".repeat(64)}`; const endpointRoot = "/api/v1/laboratory/m48s/fixed-class-detector"; @@ -825,10 +900,15 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async () assert.match(visualCss, /width: 33\.333333%/); assert.match(visualCss, /flex-flow: column nowrap/); assert.match(visualCss, /m4-replay-threat-visual__overlay > div/); - assert.match(visualCss, /grid-template-columns: minmax\(0, max-content\)/); - assert.match(visualCss, /width: max-content/); + assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/); + assert.match(visualCss, /width: min\(17\.5rem/); + assert.match(visualCss, /grid-auto-rows: 3\.8rem/); assert.match(visualCss, /--m4-replay-threat-overlay-pane-width/); assert.match(visualCss, /white-space: nowrap/); + assert.match(visualCss, /-webkit-line-clamp: 2/); + assert.match(visual, /classifiedCellCount/); + assert.match(visual, /lastAvailableClassifiedSpatialFrame/); + assert.match(visual, /current UNOBSERVED/); assert.match(visualCss, /laboratory-metric-evidence-scene__legend/); assert.match(visualCss, /bottom: auto/); assert.match(videoScene, /\s*SEMANTICS\s*<\/Button>/); - assert.match(visual, /все 2 244 TGS-ячейки явно UNOBSERVED/); + assert.match(visual, /current safety — все \{classifiedCellCount\.toLocaleString\("ru-RU"\)\} TGS-ячейки UNOBSERVED/); assert.match( visual, /mapGravityLocalSensorToBodyGround[\s\S]*rotated\[2\] \+ nominalSensorHeightM/, diff --git a/apps/control-station/test/recordedCameraBuffering.test.mjs b/apps/control-station/test/recordedCameraBuffering.test.mjs index 5d0720b..2c5de07 100644 --- a/apps/control-station/test/recordedCameraBuffering.test.mjs +++ b/apps/control-station/test/recordedCameraBuffering.test.mjs @@ -173,6 +173,9 @@ test("recorded player keeps full-archive range fallback and uses bounded generat assert.match(source, /pumpRecordedSegmentWindow/); assert.match(source, /waitForRecordedVideoTarget/); assert.match(source, /removeRecordedMediaRange/); + assert.match(source, /hasPresentedFrame/); + assert.match(source, /retainForwardFrame/); + assert.match(source, /candidateTarget\.sequence >= previousTarget\.sequence/); assert.equal(recordedMediaDecodeStartSequence([1, 1491, 1501], 1500), 1491); assert.deepEqual( diff --git a/apps/control-station/test/semanticEvidencePrimitives.test.mjs b/apps/control-station/test/semanticEvidencePrimitives.test.mjs index f7ea90a..3bec288 100644 --- a/apps/control-station/test/semanticEvidencePrimitives.test.mjs +++ b/apps/control-station/test/semanticEvidencePrimitives.test.mjs @@ -57,7 +57,10 @@ test("metric evidence keeps missing semantic assignments as context and exposes assert.match(source, /recordedEvidenceSemanticCssColor/); assert.match(source, /DynamicDrawUsage/); assert.match(source, /classifiedMeshesRef/); + assert.match(source, /classifiedPackedCells/); assert.match(source, /updatePointPositions/); + assert.match(source, /renderer\.info\.render\.calls/); + assert.match(source, /data-decision="performance"/); }); test("semantic point alignment follows the last qualified spatial increment", async () => { diff --git a/config/perception/m49-physical-safety-shadow-v0.json b/config/perception/m49-physical-safety-shadow-v0.json new file mode 100644 index 0000000..e641167 --- /dev/null +++ b/config/perception/m49-physical-safety-shadow-v0.json @@ -0,0 +1,107 @@ +{ + "schema_version": "missioncore.m49-physical-safety-shadow-profile/v1", + "profile_id": "m49-mission-core-rover-physical-safety-shadow/v0", + "status": "planning-only-unqualified", + "vehicle": { + "source": "operator-supplied-2026-08-27-unverified", + "body_length_m": 1.0, + "body_width_m": 0.8, + "body_height_m": 0.4, + "ground_clearance_m": 0.15, + "nominal_mass_kg": 100.0, + "mass_qualification": "estimate-not-weighed", + "provisional_centered_footprint_body_xy_m": [ + [0.5, 0.4], + [0.5, -0.4], + [-0.5, -0.4], + [-0.5, 0.4] + ], + "nominal_speed_mps": 0.2777777778, + "maximum_operating_speed_mps": 0.5555555556, + "drive": { + "motor_count": 2, + "rated_power_per_motor_w": 500.0, + "motor_per_side": true, + "steering_model": "unqualified-likely-differential" + } + }, + "unresolved_physical_inputs": [ + "measured-mass-and-center-of-gravity", + "wheel-diameter-and-width", + "wheelbase-track-and-overhangs", + "qualified-base-link-origin", + "controller-braking-and-loss-of-power-behavior", + "measured-stopping-envelope", + "maximum-step-slope-and-side-slope", + "lidar-camera-to-body-extrinsics", + "negative-obstacle-sensor-coverage" + ], + "terrain_evidence": { + "accepted_baseline_profile": "m49-tgs-full-shadow-v1.json", + "accepted_baseline_cell_size_m": 0.45, + "baseline_mutation_allowed": false, + "challenger_cell_sizes_m": [0.15, 0.1], + "challenger_radius_m": 12.0, + "missing_support_policy": "stop-never-free", + "required_states": [ + "SUPPORTED", + "RIGID_OR_UNKNOWN_OBSTACLE", + "NEGATIVE_OR_UNSUPPORTED", + "VEGETATION_UNKNOWN", + "VEGETATION_POTENTIALLY_TRAVERSABLE", + "VEGETATION_WITH_RIGID_GEOMETRY" + ] + }, + "operator_visual": { + "host_profile": "14-inch-2023-macbook-pro-18-gib", + "worker_role": "realtime-only", + "lab_artifact_generation": "local-sequential-offline-or-imported-sealed", + "lab_build_may_take_longer_than_source_duration": true, + "source_pace_hz": 10.0, + "render_policy": "on-demand-with-interaction-damping", + "transport": "sealed-local-binary-chunks-no-per-cell-json", + "worker_runtime_dependency": false, + "sealed_artifact_required": true, + "offline_worker_open_required": true, + "full_session_high_resolution_preload_allowed": false, + "playback_buffer": { + "chunk_frame_count": 32, + "startup_prebuffer_chunk_count": 2, + "startup_prebuffer_seconds": 6.4, + "resident_chunk_count_max": 3, + "resident_window_seconds_max": 9.6, + "forward_prefetch_chunk_count": 1, + "backward_seek_policy": "load-target-and-next-from-sealed-local-artifact" + }, + "lod": { + "safety_computation_reduced_by_visual_lod": false, + "near_field_radius_m": 4.0, + "near_field_cell_size_m": 0.15, + "mid_field_radius_m": 8.0, + "mid_field_cell_size_m": 0.3, + "far_field_radius_m": 12.0, + "far_field_cell_size_m": 0.45, + "paused_active_frame_full_resolution_available": true + }, + "acceptance_budget": { + "main_thread_frame_prepare_p95_ms_max": 10.0, + "webgl_render_submit_p95_ms_target": 16.7, + "webgl_render_submit_p95_ms_max": 33.3, + "evidence_to_visible_p95_ms_max": 100.0, + "incremental_resident_memory_mib_max": 256.0, + "post_warmup_memory_growth_mib_max": 32.0, + "long_task_over_50ms_count_max": 0, + "webgl_context_loss_count_max": 0, + "worker_unavailable_open_failure_count_max": 0, + "worker_request_count_during_lab_open_max": 0, + "worker_request_count_during_lab_build_max": 0 + } + }, + "authority": { + "commands_enabled": false, + "navigation_accepted": false, + "safety_accepted": false, + "actuation_accepted": false, + "physical_test_accepted": false + } +} diff --git a/docs/adr/0043-physical-safety-shadow-and-bounded-operator-visual.md b/docs/adr/0043-physical-safety-shadow-and-bounded-operator-visual.md new file mode 100644 index 0000000..e809264 --- /dev/null +++ b/docs/adr/0043-physical-safety-shadow-and-bounded-operator-visual.md @@ -0,0 +1,186 @@ +# ADR 0043: Physical safety shadow and bounded operator visual + +Date: 2026-08-27 +Status: autonomous transport slice implemented; 0.15 m challenger and measured acceptance incomplete + +## Context + +Mission Core has accepted a source-paced RF-DETR-native plus TRAVEL TGS +recorded shadow on RAVNOVES00. The retained TGS result uses a `0.45 m` cell, +`12 m` radius and four explicit states. It is useful operator evidence but is +not a planner-authoritative terrain provider. The integrated acceptance joins +TGS and the reference graph as evidence; TGS does not modify graph state. + +The intended physical rover is not a lightweight laboratory fixture. The +operator supplied the following planning facts, not yet independently measured: + +- body `1.0 x 0.8 x 0.4 m`; +- ground clearance `0.15 m`; +- mass approximately `100 kg`; +- one nominal `500 W` motor per side; +- nominal speed `0.2778 m/s` (`1 km/h`); +- maximum operating speed `0.5556 m/s` (`2 km/h`). + +Controller, remote dead-man, braking behavior, physical E-stop, body/sensor +extrinsics, center of gravity and negative-obstacle coverage are unavailable. +Commands therefore remain disabled. + +The current full TGS playback pack is about `88 MiB`: approximately `9.6 MiB` +states, `77 MiB` z-bounds and `1.1 MiB` frame metadata. Holding the same +`12 m` disk for all `4,489` frames would scale to roughly `0.8 GiB` at `0.15 m` +and `1.8 GiB` at `0.10 m`, before richer terrain features, video, semantics and +renderer allocations. Full-session high-resolution browser preload is not an +admitted design for the current 18 GiB MacBook host. + +## Decision + +Recorded LAB and realtime perception are different runtime contours. Worker +006 is reserved for realtime perception. A new recorded LAB is built by a +bounded sequential offline builder on the MacBook, or admits an already sealed +immutable artifact. The offline build may take longer than source duration. It +must not become a hidden Worker job. An opened LAB never depends on Worker 006. + +```text +recorded LAB publication, Worker 006 absent +recorded source -> local sequential offline build + -> seal immutable result + binary LOD tracks + | + v + local/CAS artifact store + | + Worker 006 remains absent + | + v + canonical local backend, read-only + | + v + MacBook LAB CAMERA + 3D + PLAN + +separate future realtime contour +K1 -> Worker 006 -> live world-state/planner shadow +``` + +Opening, seeking, looping and visually inspecting a sealed LAB must perform +zero requests to Worker 006. If the worker, its host, Docker or the network path +to it is unavailable, a locally present sealed result opens unchanged. If a +manifest-declared local object is absent or corrupt, LAB fails with an explicit +artifact error; it does not start compute, scan worker results or silently +rebuild evidence. This preserves ADR 0033. + +Publishing a new LAB also performs zero Worker requests. Slow local preparation +is acceptable, but it is explicit and bounded: build first, seal second, open +third. Historical LAB artifacts that were originally computed on Worker 006 +remain valid after sealing because replay depends only on their immutable +artifacts; they do not justify a Worker dependency for new LABs. + +Visual performance may never change a safety state, erase unknown space or +reduce the resolution used by the safety consumer. Conversely, the MacBook does +not need every cell of every high-resolution frame resident at once in order to +provide a complete operator explanation. + +The retained `0.45 m` M49T5 profile and sealed result remain immutable. A new +physical-safety challenger may evaluate `0.15 m` and `0.10 m` cells without +rewriting that baseline. The first candidate resolution is `0.15 m`; `0.10 m` +is admitted only if LiDAR density improves the evidence. Worker compute +acceptance and autonomous LAB viewer acceptance are measured separately. + +The operator transport uses binary typed arrays. It must not serialize one JSON +object per cell or expand binary frames into nested JavaScript objects before +updating GPU instances. Recorded high-resolution inspection reads only sealed +local binary chunks and active-frame detail, not a full-session high-resolution +preload. + +The proposed spatial chunk is `32` source frames (`3.2 s` at `10 Hz`). Before +the spatial scene declares itself ready, it may preload the first two chunks, +or `6.4 s`, from the local artifact. The browser retains at most three chunks +(`9.6 s`), normally previous/current/next, and prefetches one forward chunk. +A backward seek loads the target and next chunk from the same sealed artifact. +This bounded startup delay is preferable to either Worker dependency or a +multi-gigabyte browser preload. + +## Implemented autonomous transport slice + +The first executable transport slice is now present without creating a new LAB +surface or transport clock: + +- `m49_physical_safety_playback.py` is a local, sequential, filesystem-only + sealer. It accepts an already sealed source, verifies its declared hashes, + and writes immutable binary chunks atomically. It contains no Worker client, + job submission or network path. +- `m49_physical_safety_playback_api.py` exposes only the configured local + artifact root. Manifest, shared centers, frame catalog and every requested + chunk are hash-verified. Corruption returns an explicit artifact failure. +- `m49PhysicalSafetyPlayback.ts` rejects non-canonical URLs, verifies byte + length and SHA-256 in the browser, prebuffers two chunks, retains no more than + three and loads target plus next on seek. +- the existing M49 evidence composition resolves this transport by exact + `source_result_id`. If no chunked derivative has been published, it may still + read the old sealed local full pack; neither path uses Worker 006. + +A real transport canary was generated from the accepted immutable `0.45 m` +M49T5 result without recomputing TGS or changing that source result. The canary +identity is +`m49-physical-safety-playback-0d0e41a995a902a4701935a79655d93f2f1898806f8fa82ebbd207129f111d69`. +It contains all `4,489` frames and `2,244` cells in `141` chunks. A normal +32-frame chunk is `646,300` bytes; two startup chunks are about `1.23 MiB` and +three spatial chunks are about `1.85 MiB`, plus the shared centers and frame +catalog. This is the transport canary only. It is not a `0.15 m` safety result +and no upsampled baseline is presented as new evidence. + +The `0.15 m` shape, fail-closed missing-sample rule, bounded seek behavior and +zero-Worker URL policy are covered with synthetic contract fixtures. A real +`0.15 m` challenger still requires the local sequential terrain computation +and a separately sealed result. + +The visual uses explicit LOD: + +- `0-4 m`: `0.15 m` near-field cells; +- `4-8 m`: `0.30 m` explanatory cells; +- `8-12 m`: `0.45 m` context cells; +- pause/anchor inspection: full available active-frame resolution. + +LOD changes only presentation. The sealed result retains the full candidate +evidence needed to reproduce its decisions; a future realtime worker separately +retains the full live safety grid across the configured safety radius. + +The metric Three.js scene retains instanced meshes and all evidence layers. It +renders on evidence, camera interaction, resize or layer changes. It does not +run a permanent animation loop while idle. The scene exposes render-pass time, +draw calls, triangles, cell count and pixel ratio in the operator surface. + +## Acceptance budget + +Acceptance is measured on the named 14-inch 2023 MacBook Pro with 18 GiB RAM, +with recorded video, semantic overlay and TGS 3D/PLAN evidence available: + +- source pace: `10 Hz` without evidence loss; +- main-thread frame preparation p95 at most `10 ms`; +- CPU-side WebGL render submission p95 target `16.7 ms`, hard maximum + `33.3 ms`; GPU completion requires a separate supported timer query; +- evidence-to-visible p95 at most `100 ms`; +- incremental visual resident memory at most `256 MiB`; +- post-warmup growth at most `32 MiB` over a ten-minute soak; +- zero browser long tasks above `50 ms` during source-paced playback; +- zero WebGL context losses; +- zero Worker 006 requests while opening, seeking or playing a LAB; +- zero Worker 006 requests while building a new recorded LAB; +- the sealed LAB opens with Worker 006 unavailable; +- idle metric scene performs no continuous render loop. + +These are proposed thresholds until a canonical browser run emits a sealed +performance ledger. Passing them does not grant navigation, safety or actuation +authority. + +## Consequences + +- The operator keeps the full explanatory visual instead of receiving a + stripped-down safety UI. +- High-resolution terrain work does not turn the MacBook into a second + perception worker. +- Historical LAB remains usable during Worker maintenance, Docker failure or + loss of the Worker network path. +- Evidence identity and safety decisions remain independent of display LOD. +- The next implementation boundary is a packed, chunked terrain contract plus + footprint-aware states; no physical connection or command path is introduced. +- Physical authority remains blocked by controller/E-stop, measured braking, + vehicle geometry, sensor extrinsics and negative-obstacle coverage. diff --git a/scripts/build_m49_physical_safety_playback.py b/scripts/build_m49_physical_safety_playback.py new file mode 100644 index 0000000..1d89bd9 --- /dev/null +++ b/scripts/build_m49_physical_safety_playback.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Seal a local M49 playback without contacting Worker 006.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from k1link.laboratory.m49_physical_safety_playback import ( + seal_m49_physical_safety_playback, +) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Build an autonomous sealed M49 physical-safety playback artifact." + ) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--destination-root", type=Path, required=True) + parser.add_argument( + "--profile", + type=Path, + default=Path("config/perception/m49-physical-safety-shadow-v0.json"), + ) + arguments = parser.parse_args() + result = seal_m49_physical_safety_playback( + source_root=arguments.source_root, + destination_root=arguments.destination_root, + profile_path=arguments.profile, + ) + print(result.result_id) + + +if __name__ == "__main__": + main() diff --git a/src/k1link/laboratory/m49_physical_safety_playback.py b/src/k1link/laboratory/m49_physical_safety_playback.py new file mode 100644 index 0000000..59b155b --- /dev/null +++ b/src/k1link/laboratory/m49_physical_safety_playback.py @@ -0,0 +1,538 @@ +"""Build and verify autonomous chunked M49 physical-safety playback artifacts.""" + +from __future__ import annotations + +import hashlib +import json +import math +import shutil +import struct +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Final + +import numpy as np + +SCHEMA: Final = "missioncore.m49-physical-safety-playback/v1" +PROFILE_SCHEMA: Final = "missioncore.m49-physical-safety-shadow-profile/v1" +PREFIX: Final = "m49-physical-safety-playback-" +CHUNK_MAGIC: Final = b"MCPSCH01" +CHUNK_HEADER: Final = struct.Struct("<8sIIIII") +SOURCE_TRACKS: Final = ( + "costmap-cell-centers-xy-m.npy", + "costmap-states.npy", + "costmap-z-bounds-m.npy", + "frames.ndjson", +) +_HASH_BLOCK_BYTES: Final = 1024 * 1024 +_MAX_JSON_BYTES: Final = 4 * 1024 * 1024 + + +class M49PhysicalSafetyPlaybackError(RuntimeError): + """The autonomous playback artifact is missing or violates its contract.""" + + +@dataclass(frozen=True, slots=True) +class M49PhysicalSafetyPlayback: + result_id: str + root: Path + manifest: dict[str, Any] + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(_HASH_BLOCK_BYTES), b""): + digest.update(block) + return digest.hexdigest() + + +def _canonical_sha256(value: object) -> str: + content = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(content).hexdigest() + + +def _json(path: Path, label: str) -> dict[str, Any]: + if path.is_symlink() or not path.is_file() or path.stat().st_size > _MAX_JSON_BYTES: + raise M49PhysicalSafetyPlaybackError(f"{label} is unavailable") + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise M49PhysicalSafetyPlaybackError(f"{label} is invalid") from error + if not isinstance(value, dict): + raise M49PhysicalSafetyPlaybackError(f"{label} is invalid") + return value + + +def _artifact(path: Path, *, media_type: str, role: str) -> dict[str, object]: + return { + "path": path.name, + "media_type": media_type, + "role": role, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + + +def _source_artifacts(source: Path, manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + raise M49PhysicalSafetyPlaybackError("source artifact catalog is unavailable") + result: dict[str, dict[str, Any]] = {} + for value in artifacts: + if not isinstance(value, dict) or not isinstance(value.get("path"), str): + raise M49PhysicalSafetyPlaybackError("source artifact catalog is invalid") + name = value["path"] + if name in (*SOURCE_TRACKS, "report.json"): + path = source / name + if ( + path.parent != source + or path.is_symlink() + or not path.is_file() + or value.get("byte_length") != path.stat().st_size + or value.get("sha256") != _sha256(path) + ): + raise M49PhysicalSafetyPlaybackError(f"source artifact changed: {name}") + result[name] = value + if set(result) != {*SOURCE_TRACKS, "report.json"}: + raise M49PhysicalSafetyPlaybackError("source spatial tracks are incomplete") + return result + + +def _state_codes(report: dict[str, Any]) -> dict[str, int]: + candidates = ( + report.get("state_codes"), + report.get("visual_review", {}).get("state_codes") + if isinstance(report.get("visual_review"), dict) + else None, + ) + for candidate in candidates: + if not isinstance(candidate, dict): + continue + parsed = { + str(name): int(code) + for name, code in candidate.items() + if isinstance(name, str) + and isinstance(code, int) + and not isinstance(code, bool) + and 0 <= code <= 255 + } + if len(parsed) == len(candidate) and parsed.get("UNOBSERVED") == 0: + return parsed + raise M49PhysicalSafetyPlaybackError("source state codes are unavailable") + + +def _frame_rows(path: Path) -> tuple[dict[str, Any], ...]: + rows: list[dict[str, Any]] = [] + try: + with path.open("r", encoding="utf-8") as stream: + for sequence, line in enumerate(stream): + value = json.loads(line) + if ( + not isinstance(value, dict) + or not isinstance(value.get("sample_available"), bool) + or not isinstance(value.get("source_frame_index"), int) + or isinstance(value.get("source_frame_index"), bool) + or not isinstance(value.get("session_seconds"), (int, float)) + or isinstance(value.get("session_seconds"), bool) + or not math.isfinite(float(value["session_seconds"])) + ): + raise M49PhysicalSafetyPlaybackError( + f"source frame catalog changed at sequence {sequence}" + ) + rows.append(value) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise M49PhysicalSafetyPlaybackError("source frame catalog is invalid") from error + if not rows: + raise M49PhysicalSafetyPlaybackError("source frame catalog is empty") + return tuple(rows) + + +def _source_geometry(report: dict[str, Any]) -> tuple[float, float, str]: + configuration = report.get("configuration") + if not isinstance(configuration, dict): + raise M49PhysicalSafetyPlaybackError("source geometry is unavailable") + cell_size = configuration.get("cell_size_m") + radius = configuration.get("radius_m") + coordinate_frame = configuration.get("coordinate_frame") + if ( + not isinstance(cell_size, (int, float)) + or isinstance(cell_size, bool) + or not math.isfinite(float(cell_size)) + or float(cell_size) <= 0 + or not isinstance(radius, (int, float)) + or isinstance(radius, bool) + or not math.isfinite(float(radius)) + or float(radius) <= 0 + or coordinate_frame != "map-gravity-local" + ): + raise M49PhysicalSafetyPlaybackError("source geometry changed") + return float(cell_size), float(radius), coordinate_frame + + +def _profile_policy(profile: dict[str, Any], cell_size_m: float) -> dict[str, int]: + visual = profile.get("operator_visual") + terrain = profile.get("terrain_evidence") + if ( + profile.get("schema_version") != PROFILE_SCHEMA + or not isinstance(visual, dict) + or not isinstance(terrain, dict) + or visual.get("worker_role") != "realtime-only" + or visual.get("worker_runtime_dependency") is not False + or visual.get("sealed_artifact_required") is not True + ): + raise M49PhysicalSafetyPlaybackError("physical-safety profile changed") + playback = visual.get("playback_buffer") + admitted_sizes = [ + terrain.get("accepted_baseline_cell_size_m"), + *(terrain.get("challenger_cell_sizes_m") or []), + ] + if ( + not isinstance(playback, dict) + or playback.get("chunk_frame_count") != 32 + or playback.get("startup_prebuffer_chunk_count") != 2 + or playback.get("resident_chunk_count_max") != 3 + or not any( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isclose(float(value), cell_size_m, abs_tol=1e-9) + for value in admitted_sizes + ) + ): + raise M49PhysicalSafetyPlaybackError("physical-safety playback policy changed") + return { + "chunk_frame_count": 32, + "startup_prebuffer_chunk_count": 2, + "resident_chunk_count_max": 3, + "forward_prefetch_chunk_count": 1, + } + + +def _chunk_payload( + path: Path, + *, + start: int, + states: np.ndarray, + z_bounds: np.ndarray, +) -> None: + frame_count, cell_count = states.shape + state_values = np.ascontiguousarray(states, dtype=np.uint8) + z_values = np.ascontiguousarray(z_bounds, dtype=" M49PhysicalSafetyPlayback: + """Sequentially seal local tracks without starting or contacting Worker 006.""" + + raw_source = source_root.expanduser().absolute() + if raw_source.is_symlink() or not raw_source.is_dir(): + raise M49PhysicalSafetyPlaybackError("sealed source root is unavailable") + source = raw_source.resolve(strict=True) + destination = destination_root.expanduser().absolute() + destination.mkdir(parents=True, exist_ok=True) + if destination.is_symlink(): + raise M49PhysicalSafetyPlaybackError("destination must not be a symlink") + raw_profile = profile_path.expanduser().absolute() + if raw_profile.is_symlink() or not raw_profile.is_file(): + raise M49PhysicalSafetyPlaybackError("physical-safety profile is unavailable") + profile_file = raw_profile.resolve(strict=True) + + source_manifest = _json(source / "manifest.json", "source manifest") + source_report = _json(source / "report.json", "source report") + source_result_id = source_manifest.get("result_id") + source_identity = source_manifest.get("identity") + source_identity_sha256 = source_manifest.get("identity_sha256") + if ( + not isinstance(source_result_id, str) + or source_report.get("result_id") != source_result_id + or not isinstance(source_identity, dict) + or not isinstance(source_identity_sha256, str) + or source_identity_sha256 != _canonical_sha256(source_identity) + or not source_result_id.endswith(source_identity_sha256) + ): + raise M49PhysicalSafetyPlaybackError("source result identity changed") + source_artifacts = _source_artifacts(source, source_manifest) + state_codes = _state_codes(source_report) + cell_size_m, radius_m, coordinate_frame = _source_geometry(source_report) + profile = _json(profile_file, "physical-safety profile") + policy = _profile_policy(profile, cell_size_m) + profile_sha256 = _sha256(profile_file) + source_manifest_sha256 = _sha256(source / "manifest.json") + + try: + centers = np.load( + source / "costmap-cell-centers-xy-m.npy", mmap_mode="r", allow_pickle=False + ) + states = np.load(source / "costmap-states.npy", mmap_mode="r", allow_pickle=False) + z_bounds = np.load( + source / "costmap-z-bounds-m.npy", mmap_mode="r", allow_pickle=False + ) + except (OSError, ValueError) as error: + raise M49PhysicalSafetyPlaybackError("source numeric tracks are invalid") from error + frames = _frame_rows(source / "frames.ndjson") + if ( + centers.dtype != np.dtype(" M49PhysicalSafetyPlayback: + raw_candidate = root.expanduser().absolute() + if raw_candidate.is_symlink() or not raw_candidate.is_dir(): + raise M49PhysicalSafetyPlaybackError("physical-safety playback root is invalid") + candidate = raw_candidate.resolve(strict=True) + manifest = _json(candidate / "manifest.json", "physical-safety playback manifest") + identity = manifest.get("identity") + execution = manifest.get("execution") + if ( + manifest.get("schema_version") != SCHEMA + or not isinstance(identity, dict) + or not isinstance(execution, dict) + or manifest.get("identity_sha256") != _canonical_sha256(identity) + or manifest.get("result_id") != candidate.name + or candidate.name != f"{PREFIX}{manifest.get('identity_sha256')}" + or execution.get("worker_requests_required") != 0 + or execution.get("worker_runtime_dependency") is not False + ): + raise M49PhysicalSafetyPlaybackError("physical-safety playback identity changed") + playback = manifest.get("playback") + if not isinstance(playback, dict) or not isinstance(playback.get("chunks"), list): + raise M49PhysicalSafetyPlaybackError("physical-safety playback catalog changed") + descriptors = [playback.get("centers"), playback.get("frames"), *playback["chunks"]] + seen: set[str] = set() + for descriptor in descriptors: + if not isinstance(descriptor, dict) or not isinstance(descriptor.get("path"), str): + raise M49PhysicalSafetyPlaybackError("physical-safety artifact entry changed") + name = descriptor["path"] + path = candidate / name + if ( + name in seen + or path.parent != candidate + or path.is_symlink() + or not path.is_file() + or descriptor.get("byte_length") != path.stat().st_size + or not isinstance(descriptor.get("sha256"), str) + or len(descriptor["sha256"]) != 64 + or any(character not in "0123456789abcdef" for character in descriptor["sha256"]) + ): + raise M49PhysicalSafetyPlaybackError("physical-safety artifact changed") + seen.add(name) + return M49PhysicalSafetyPlayback(candidate.name, candidate, manifest) + + +def verify_m49_physical_safety_artifact( + result: M49PhysicalSafetyPlayback, + descriptor: dict[str, Any], +) -> Path: + name = descriptor.get("path") + if not isinstance(name, str): + raise M49PhysicalSafetyPlaybackError("physical-safety artifact path changed") + path = result.root / name + if ( + path.parent != result.root + or path.is_symlink() + or not path.is_file() + or descriptor.get("byte_length") != path.stat().st_size + or descriptor.get("sha256") != _sha256(path) + ): + raise M49PhysicalSafetyPlaybackError("physical-safety artifact digest changed") + return path + + +__all__ = [ + "CHUNK_HEADER", + "CHUNK_MAGIC", + "M49PhysicalSafetyPlayback", + "M49PhysicalSafetyPlaybackError", + "PREFIX", + "SCHEMA", + "read_m49_physical_safety_playback", + "seal_m49_physical_safety_playback", + "verify_m49_physical_safety_artifact", +] diff --git a/src/k1link/perception/threat_replay.py b/src/k1link/perception/threat_replay.py index 71b225f..4f57a7e 100644 --- a/src/k1link/perception/threat_replay.py +++ b/src/k1link/perception/threat_replay.py @@ -452,6 +452,32 @@ def build_threat_replay( def read_threat_replay_result(root: Path) -> ThreatReplayResult: + """Read and deeply verify a sealed threat replay result. + + This is the acceptance/build seam. It deliberately walks every ledger row + before returning and therefore must not sit on the synchronous LAB open + path for a hundreds-of-megabytes recorded result. + """ + + return _read_threat_replay_result(root, validate_ledgers=True) + + +def read_threat_replay_result_metadata(root: Path) -> ThreatReplayResult: + """Read verified result metadata without eagerly parsing the full ledger. + + Artifact bytes are still digest checked against the sealed manifest. The + bounded timeline reader validates every requested row before projection; + only the redundant all-4489-row JSON accounting pass is deferred. + """ + + return _read_threat_replay_result(root, validate_ledgers=False) + + +def _read_threat_replay_result( + root: Path, + *, + validate_ledgers: bool, +) -> ThreatReplayResult: resolved = root.resolve(strict=True) if resolved.is_symlink() or not resolved.name.startswith(THREAT_REPLAY_RESULT_PREFIX): raise ThreatReplayError("threat replay result root is invalid") @@ -498,7 +524,8 @@ def read_threat_replay_result(root: Path) -> ThreatReplayResult: for role, (name, identity_key) in expected.items(): path = _validated_artifact(resolved, by_role[role], name) paths[role] = path - if identity_key is not None and _file_sha256(path) != identity.get(identity_key): + artifact = _object(by_role[role], "threat artifact") + if identity_key is not None and artifact.get("sha256") != identity.get(identity_key): raise ThreatReplayError("threat artifact identity changed") report = _read_json(paths["threat-replay-report"]) metrics = _object(identity.get("metrics"), "threat metrics") @@ -522,12 +549,13 @@ def read_threat_replay_result(root: Path) -> ThreatReplayResult: or requirements != expected_requirements ): raise ThreatReplayError("threat replay report or acceptance changed") - _validate_ledgers( - paths["threat-replay-frames"], - paths["threat-visual-frames"], - metrics, - is_v2=is_v2, - ) + if validate_ledgers: + _validate_ledgers( + paths["threat-replay-frames"], + paths["threat-visual-frames"], + metrics, + is_v2=is_v2, + ) return ThreatReplayResult( result_id=resolved.name, result_root=resolved, diff --git a/src/k1link/perception/threat_timeline.py b/src/k1link/perception/threat_timeline.py index 4cae20d..c078cb3 100644 --- a/src/k1link/perception/threat_timeline.py +++ b/src/k1link/perception/threat_timeline.py @@ -13,6 +13,8 @@ from pathlib import Path from threading import RLock from typing import Final +import numpy as np + from .geometry import RecordedGeometryStore from .recorded_source import RECORDED_REPRESENTATION_ID from .spatial_evidence import ( @@ -78,24 +80,56 @@ class RecordedThreatTimeline: } if any(identity.get(key) != value for key, value in expected_identity.items()): raise RecordedThreatTimelineError("recorded timeline escaped the threat profile") - self.store = RecordedGeometryStore.from_repository(self.repository_root) - if ( - self.store.profile.source_pack_id != self.profile.source_pack_id - or self.store.profile.source_pack_sha256 != self.profile.source_pack_sha256 - or self.store.profile.frame_count != _EXPECTED_FRAME_COUNT - ): - raise RecordedThreatTimelineError("recorded timeline geometry identity changed") - if self.store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT: - raise RecordedThreatTimelineError( - "recorded timeline exact point delivery exceeds its declared bound" - ) - self.body_frames = RecordedReplayBodyFrameResolver( - self.store, - profile=self.profile.body_frame, + self._maximum_source_points_per_frame = _read_source_point_bound( + self.repository_root, + source_pack_id=self.profile.source_pack_id, + source_pack_sha256=self.profile.source_pack_sha256, ) + self._store: RecordedGeometryStore | None = None + self._body_frames: RecordedReplayBodyFrameResolver | None = None self.index = _index_frame_ledger(self.frames_path) self._lock = RLock() + @property + def store(self) -> RecordedGeometryStore: + """Load and deeply verify the large geometry archives on first use. + + Timeline metadata, camera playback and the independent TGS artifact can + become visible without waiting for the source cloud archive. Any + endpoint that actually delivers source points still crosses the full + digest and shape validation in ``RecordedGeometryStore``. + """ + + with self._lock: + if self._store is None: + store = RecordedGeometryStore.from_repository(self.repository_root) + if ( + store.profile.source_pack_id != self.profile.source_pack_id + or store.profile.source_pack_sha256 != self.profile.source_pack_sha256 + or store.profile.frame_count != _EXPECTED_FRAME_COUNT + or store.maximum_current_point_count + != self._maximum_source_points_per_frame + ): + raise RecordedThreatTimelineError( + "recorded timeline geometry identity changed" + ) + if store.maximum_current_point_count > RECORDED_SPATIAL_POINT_LIMIT: + raise RecordedThreatTimelineError( + "recorded timeline exact point delivery exceeds its declared bound" + ) + self._store = store + return self._store + + @property + def body_frames(self) -> RecordedReplayBodyFrameResolver: + with self._lock: + if self._body_frames is None: + self._body_frames = RecordedReplayBodyFrameResolver( + self.store, + profile=self.profile.body_frame, + ) + return self._body_frames + def metadata(self) -> dict[str, object]: times = self.index.source_times_ns intervals = [(current - previous) / 1_000_000_000 for previous, current in pairwise(times)] @@ -120,7 +154,7 @@ class RecordedThreatTimeline: "max_chunk_frames": RECORDED_SPATIAL_MAX_CHUNK_FRAMES, "point_sample_limit": RECORDED_SPATIAL_POINT_LIMIT, "point_delivery": "exact-current-increment", - "maximum_source_points_per_frame": self.store.maximum_current_point_count, + "maximum_source_points_per_frame": self._maximum_source_points_per_frame, "local_surface_visualization": { "derivation": "bounded-registered-increment-accumulation", "window_seconds": RECORDED_LOCAL_SURFACE_WINDOW_SECONDS, @@ -258,6 +292,82 @@ class RecordedThreatTimeline: } +def _read_source_point_bound( + repository_root: Path, + *, + source_pack_id: str, + source_pack_sha256: str, +) -> int: + """Read the small offsets member without inflating the 70 MiB source pack. + + This is metadata only. The exact archive digest, every array shape and the + local-surface binding are still verified lazily by ``RecordedGeometryStore`` + before any source point is delivered. + """ + + base = ( + repository_root / ".runtime/compute-experiments/e10/lidar-packs" + ).resolve(strict=True) + pack_root = (base / source_pack_id).resolve(strict=True) + manifest_path = (pack_root / "manifest.json").resolve(strict=True) + pack_path = (pack_root / "lidar-pack.npz").resolve(strict=True) + if ( + pack_root.parent != base + or pack_root.is_symlink() + or manifest_path.parent != pack_root + or manifest_path.is_symlink() + or pack_path.parent != pack_root + or pack_path.is_symlink() + or not pack_path.is_file() + ): + raise RecordedThreatTimelineError("recorded timeline source pack path changed") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise RecordedThreatTimelineError( + "recorded timeline source pack manifest is invalid" + ) + identity = manifest.get("identity") + artifact = manifest.get("artifact") + if not isinstance(identity, dict) or not isinstance(artifact, dict): + raise RecordedThreatTimelineError( + "recorded timeline source pack manifest is invalid" + ) + point_count = identity.get("point_count") + if ( + manifest.get("pack_id") != source_pack_id + or identity.get("frame_count") != _EXPECTED_FRAME_COUNT + or not isinstance(point_count, int) + or isinstance(point_count, bool) + or point_count < 0 + or artifact.get("path") != "lidar-pack.npz" + or artifact.get("sha256") != source_pack_sha256 + or artifact.get("byte_length") != pack_path.stat().st_size + ): + raise RecordedThreatTimelineError( + "recorded timeline source pack identity changed" + ) + with np.load(pack_path, allow_pickle=False) as archive: + offsets = np.asarray(archive["cloud_offsets"], dtype=np.int64) + except (KeyError, OSError, ValueError, json.JSONDecodeError) as error: + raise RecordedThreatTimelineError( + "recorded timeline source point offsets are unavailable" + ) from error + if ( + offsets.shape != (_EXPECTED_FRAME_COUNT + 1,) + or int(offsets[0]) != 0 + or int(offsets[-1]) != point_count + or np.any(np.diff(offsets) < 0) + ): + raise RecordedThreatTimelineError("recorded timeline source point offsets changed") + maximum = int(np.diff(offsets).max(initial=0)) + if maximum > RECORDED_SPATIAL_POINT_LIMIT: + raise RecordedThreatTimelineError( + "recorded timeline exact point delivery exceeds its declared bound" + ) + return maximum + + def _index_frame_ledger(path: Path) -> RecordedThreatTimelineIndex: offsets: list[int] = [] source_times: list[int] = [] diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index a81bd86..a97155a 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -133,6 +133,9 @@ from k1link.web.m48s_fixed_class_detector_lab_api import ( build_m48s_fixed_class_detector_lab_router, ) from k1link.web.m48t_risk_quality_lab_api import build_m48t_risk_quality_lab_router +from k1link.web.m49_physical_safety_playback_api import ( + build_m49_physical_safety_playback_router, +) from k1link.web.m49_tgs_fail_closed_api import build_m49_tgs_fail_closed_router from k1link.web.m49_tgs_full_shadow_api import build_m49_tgs_full_shadow_router from k1link.web.map_api import ( @@ -1016,6 +1019,17 @@ app.include_router( ), ) ) +app.include_router( + build_m49_physical_safety_playback_router( + root_provider=lambda: ( + REPOSITORY_ROOT + / ".runtime" + / "compute-experiments" + / "m49" + / "physical-safety-playback-results" + ), + ) +) app.include_router( build_m48s_fixed_class_detector_lab_router( root_provider=lambda: ( diff --git a/src/k1link/web/m49_physical_safety_playback_api.py b/src/k1link/web/m49_physical_safety_playback_api.py new file mode 100644 index 0000000..3ac0483 --- /dev/null +++ b/src/k1link/web/m49_physical_safety_playback_api.py @@ -0,0 +1,221 @@ +"""Read-only local API for autonomous M49 physical-safety playback artifacts.""" + +from __future__ import annotations + +import copy +import re +from collections.abc import Callable +from functools import lru_cache +from pathlib import Path +from typing import Any, Final + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import FileResponse + +from k1link.laboratory.m49_physical_safety_playback import ( + PREFIX, + M49PhysicalSafetyPlayback, + M49PhysicalSafetyPlaybackError, + read_m49_physical_safety_playback, + verify_m49_physical_safety_artifact, +) + +RootProvider = Callable[[], Path | None] +RESULT_ID: Final = re.compile(rf"^{re.escape(PREFIX)}[a-f0-9]{{64}}$") +SOURCE_RESULT_ID: Final = re.compile(r"^m49-tgs-full-shadow-[a-f0-9]{64}$") +ENDPOINT_ROOT: Final = "/api/v1/laboratory/m49/physical-safety-playback" + + +def build_m49_physical_safety_playback_router( + *, root_provider: RootProvider = lambda: None +) -> APIRouter: + router = APIRouter(prefix=ENDPOINT_ROOT, tags=["laboratory"]) + + def sealed(result_id: str) -> M49PhysicalSafetyPlayback: + root = _configured_root(root_provider) + if root is None or RESULT_ID.fullmatch(result_id) is None: + raise HTTPException(status_code=404, detail="M49 physical-safety playback not found") + candidate = root / result_id + if candidate.is_symlink() or not candidate.is_dir(): + raise HTTPException(status_code=404, detail="M49 physical-safety playback not found") + try: + resolved = candidate.resolve(strict=True) + if resolved.parent != root: + raise ValueError("result escaped configured root") + return _read_cached(str(resolved), _signature(resolved)) + except (M49PhysicalSafetyPlaybackError, OSError, ValueError): + raise HTTPException( + status_code=404, detail="M49 physical-safety playback not found" + ) from None + + @router.get("/results") + def list_results( + limit: int = Query(default=10, ge=1, le=25), + source_result_id: str | None = Query(default=None), + ) -> dict[str, object]: + if source_result_id is not None and SOURCE_RESULT_ID.fullmatch(source_result_id) is None: + raise HTTPException(status_code=422, detail="M49 source result identity is invalid") + root = _configured_root(root_provider) + if root is None: + return _catalog([], configured=False, invalid_total=0) + items: list[dict[str, object]] = [] + invalid = 0 + for candidate in sorted(root.iterdir()): + if candidate.is_symlink() or not candidate.is_dir() or not RESULT_ID.fullmatch( + candidate.name + ): + continue + try: + summary = _summary( + _read_cached(str(candidate.resolve()), _signature(candidate)) + ) + if source_result_id is None or summary["source_result_id"] == source_result_id: + items.append(summary) + except (M49PhysicalSafetyPlaybackError, OSError, ValueError): + invalid += 1 + items.sort( + key=lambda value: (str(value["created_at_utc"]), str(value["result_id"])), + reverse=True, + ) + return _catalog(items[:limit], configured=True, invalid_total=invalid) + + @router.get("/{result_id}/manifest") + def get_manifest(result_id: str) -> dict[str, object]: + return _project_manifest(sealed(result_id)) + + @router.get("/{result_id}/tracks/{track_id}") + def get_track(result_id: str, track_id: str) -> FileResponse: + result = sealed(result_id) + playback = _playback(result) + if track_id == "centers": + descriptor = _descriptor(playback.get("centers"), "centers") + elif track_id == "frames": + descriptor = _descriptor(playback.get("frames"), "frames") + else: + raise HTTPException(status_code=404, detail="M49 physical-safety track not found") + return _file_response(result, descriptor) + + @router.get("/{result_id}/chunks/{chunk_index}") + def get_chunk(result_id: str, chunk_index: int) -> FileResponse: + result = sealed(result_id) + chunks = _playback(result).get("chunks") + if not isinstance(chunks, list) or chunk_index < 0 or chunk_index >= len(chunks): + raise HTTPException(status_code=404, detail="M49 physical-safety chunk not found") + descriptor = _descriptor(chunks[chunk_index], f"chunk {chunk_index}") + if descriptor.get("index") != chunk_index: + raise HTTPException(status_code=503, detail="M49 physical-safety chunk catalog changed") + return _file_response(result, descriptor) + + return router + + +@lru_cache(maxsize=8) +def _read_cached(root: str, signature: tuple[int, ...]) -> M49PhysicalSafetyPlayback: + del signature + return read_m49_physical_safety_playback(Path(root)) + + +def _file_response( + result: M49PhysicalSafetyPlayback, + descriptor: dict[str, Any], +) -> FileResponse: + try: + path = verify_m49_physical_safety_artifact(result, descriptor) + except (KeyError, OSError, TypeError, ValueError, M49PhysicalSafetyPlaybackError): + raise HTTPException( + status_code=503, + detail="M49 physical-safety playback artifact failed verification", + ) from None + return FileResponse( + path, + media_type=str(descriptor.get("media_type") or "application/octet-stream"), + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Encoding": "identity", + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + "X-Mission-Core-Worker-Dependency": "none", + }, + ) + + +def _playback(result: M49PhysicalSafetyPlayback) -> dict[str, Any]: + playback = result.manifest.get("playback") + if not isinstance(playback, dict): + raise HTTPException(status_code=503, detail="M49 physical-safety playback changed") + return playback + + +def _descriptor(value: object, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise HTTPException(status_code=503, detail=f"M49 physical-safety {label} changed") + return value + + +def _project_manifest(result: M49PhysicalSafetyPlayback) -> dict[str, object]: + payload = copy.deepcopy(result.manifest) + playback = payload["playback"] + assert isinstance(playback, dict) + centers = playback["centers"] + frames = playback["frames"] + chunks = playback["chunks"] + assert isinstance(centers, dict) and isinstance(frames, dict) and isinstance(chunks, list) + centers["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/tracks/centers" + frames["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/tracks/frames" + for index, value in enumerate(chunks): + assert isinstance(value, dict) + value["url"] = f"{ENDPOINT_ROOT}/{result.result_id}/chunks/{index}" + payload["access"] = "read-only-sealed-local" + return payload + + +def _summary(result: M49PhysicalSafetyPlayback) -> dict[str, object]: + playback = _playback(result) + return { + "schema_version": "missioncore.m49-physical-safety-playback-summary/v1", + "result_id": result.result_id, + "created_at_utc": result.manifest.get("created_at_utc"), + "source_result_id": result.manifest["identity"]["source_result_id"], + "frame_count": playback.get("frame_count"), + "cell_count": playback.get("cell_count"), + "cell_size_m": playback.get("cell_size_m"), + "radius_m": playback.get("radius_m"), + "chunk_count": len(playback.get("chunks", [])), + "worker_runtime_dependency": False, + "navigation_or_safety_accepted": False, + } + + +def _catalog( + items: list[dict[str, object]], *, configured: bool, invalid_total: int +) -> dict[str, object]: + return { + "schema_version": "missioncore.m49-physical-safety-playback-catalog/v1", + "configured": configured, + "items": items, + "candidate_total": len(items) + invalid_total, + "invalid_total": invalid_total, + "worker_runtime_dependency": False, + "access": "read-only-sealed-local", + } + + +def _configured_root(provider: RootProvider) -> Path | None: + value = provider() + if value is None: + return None + raw = value.expanduser().absolute() + if raw.is_symlink() or not raw.is_dir(): + return None + return raw.resolve(strict=True) + + +def _signature(root: Path) -> tuple[int, ...]: + path = root / "manifest.json" + if path.is_symlink() or not path.is_file(): + raise ValueError("physical-safety manifest unavailable") + stat = path.stat() + return (stat.st_size, stat.st_mtime_ns) + + +__all__ = ["build_m49_physical_safety_playback_router"] diff --git a/src/k1link/web/m4_threat_replay_api.py b/src/k1link/web/m4_threat_replay_api.py index b6fa8e5..2411e7a 100644 --- a/src/k1link/web/m4_threat_replay_api.py +++ b/src/k1link/web/m4_threat_replay_api.py @@ -22,7 +22,7 @@ from k1link.perception.threat_replay import ( THREAT_REPLAY_VISUAL_SCHEMA_V2, ThreatReplayError, ThreatReplayResult, - read_threat_replay_result, + read_threat_replay_result_metadata, ) from k1link.perception.threat_timeline import ( RECORDED_SPATIAL_MAX_CHUNK_FRAMES, @@ -34,6 +34,7 @@ from k1link.sessions import RecordedCameraFrame, SessionIntegrityError M4_THREAT_CATALOG_SCHEMA: Final = "missioncore.m4-threat-replay-catalog/v1" M4_THREAT_VIEW_SCHEMA: Final = "missioncore.m4-threat-replay-view/v1" M4_THREAT_VISUAL_CATALOG_SCHEMA: Final = "missioncore.m4-threat-visual-catalog/v1" +M4_THREAT_PLAYBACK_CHUNK_FRAMES: Final = 24 _RESULT_ID = re.compile(rf"^{THREAT_REPLAY_RESULT_PREFIX}[a-f0-9]{{64}}$") RootProvider = Callable[[], Path | None] CameraFrameProvider = Callable[[str, int], RecordedCameraFrame] @@ -212,13 +213,19 @@ def build_m4_threat_replay_router( projected = timeline(result_id) points = projected.store.playback_points_map() offsets = projected.store.playback_point_offsets() - content_sha256 = hashlib.sha256(memoryview(points).cast("B")).hexdigest() + points_view = memoryview(points).cast("B") + content_sha256 = hashlib.sha256(points_view).hexdigest() + chunks = _playback_chunk_catalog(result_id, points_view, offsets) return { "schema_version": "missioncore.recorded-spatial-playback/v1", "result_id": result_id, "frame_count": len(offsets) - 1, "point_count": int(points.shape[0]), "point_offsets": list(offsets), + "chunk_frame_count": M4_THREAT_PLAYBACK_CHUNK_FRAMES, + "resident_chunk_count_max": 4, + "forward_prefetch_chunk_count": 1, + "chunks": chunks, "track": { "id": "points-map-f32", "url": ( @@ -238,6 +245,42 @@ def build_m4_threat_replay_router( "access": "read-only-sealed-binary-playback", } + @router.get( + "/results/{result_id}/timeline/playback/chunks/{chunk_index}", + response_class=Response, + ) + def get_timeline_playback_chunk(result_id: str, chunk_index: int) -> Response: + projected = timeline(result_id) + points = projected.store.playback_points_map() + offsets = projected.store.playback_point_offsets() + points_view = memoryview(points).cast("B") + descriptor = _playback_chunk_descriptor( + result_id, + points_view, + offsets, + chunk_index, + ) + if descriptor is None: + raise HTTPException(status_code=404, detail="M4.6 playback chunk не найден") + point_start = descriptor["point_start"] + byte_length = descriptor["bytes"] + assert isinstance(point_start, int) + assert isinstance(byte_length, int) + byte_start = point_start * 3 * 4 + byte_stop = byte_start + byte_length + return Response( + content=bytes(points_view[byte_start:byte_stop]), + media_type="application/octet-stream", + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "Content-Encoding": "identity", + "Content-Length": str(byte_length), + "ETag": f'"{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + "X-Uncompressed-Content-Length": str(byte_length), + }, + ) + @router.get( "/results/{result_id}/timeline/playback/tracks/points-map-f32", response_class=StreamingResponse, @@ -276,13 +319,71 @@ def _binary_chunks(view: memoryview, chunk_size: int = 1024 * 1024) -> Iterator[ yield bytes(view[start : start + chunk_size]) +def _playback_chunk_catalog( + result_id: str, + points_view: memoryview, + offsets: tuple[int, ...], +) -> list[dict[str, object]]: + frame_count = len(offsets) - 1 + chunk_count = ( + frame_count + M4_THREAT_PLAYBACK_CHUNK_FRAMES - 1 + ) // M4_THREAT_PLAYBACK_CHUNK_FRAMES + return [ + descriptor + for chunk_index in range(chunk_count) + if ( + descriptor := _playback_chunk_descriptor( + result_id, + points_view, + offsets, + chunk_index, + ) + ) + is not None + ] + + +def _playback_chunk_descriptor( + result_id: str, + points_view: memoryview, + offsets: tuple[int, ...], + chunk_index: int, +) -> dict[str, object] | None: + frame_count = len(offsets) - 1 + start = chunk_index * M4_THREAT_PLAYBACK_CHUNK_FRAMES + if chunk_index < 0 or start >= frame_count: + return None + count = min(M4_THREAT_PLAYBACK_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"/api/v1/laboratory/m4-threat/results/{result_id}" + f"/timeline/playback/chunks/{chunk_index}" + ), + "media_type": "application/octet-stream", + "dtype": " ThreatReplayResult: del signature - return read_threat_replay_result(Path(root_value)) + return read_threat_replay_result_metadata(Path(root_value)) @lru_cache(maxsize=4) diff --git a/tests/test_m49_physical_safety_playback.py b/tests/test_m49_physical_safety_playback.py new file mode 100644 index 0000000..d97b3a6 --- /dev/null +++ b/tests/test_m49_physical_safety_playback.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import hashlib +import json +import socket +from pathlib import Path + +import numpy as np +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.laboratory.m49_physical_safety_playback import ( + CHUNK_HEADER, + CHUNK_MAGIC, + read_m49_physical_safety_playback, + seal_m49_physical_safety_playback, +) +from k1link.web.m49_physical_safety_playback_api import ( + build_m49_physical_safety_playback_router, +) + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _sealed_source(root: Path, *, frame_count: int = 65, cell_count: int = 8) -> Path: + root.mkdir() + centers = np.column_stack( + ( + np.arange(cell_count, dtype=np.float32) * np.float32(0.15), + np.zeros(cell_count, dtype=np.float32), + ) + ).astype(np.float32) + states = np.ones((frame_count, cell_count), dtype=np.uint8) + z_bounds = np.zeros((frame_count, cell_count, 2), dtype=np.float32) + z_bounds[..., 1] = np.float32(0.12) + missing_sequence = 17 + states[missing_sequence] = 0 + z_bounds[missing_sequence] = np.nan + np.save(root / "costmap-cell-centers-xy-m.npy", centers, allow_pickle=False) + np.save(root / "costmap-states.npy", states, allow_pickle=False) + np.save(root / "costmap-z-bounds-m.npy", z_bounds, allow_pickle=False) + frames = [ + { + "source_frame_index": sequence, + "session_seconds": sequence / 10, + "sample_available": sequence != missing_sequence, + "eligible_point_count": 0 if sequence == missing_sequence else cell_count, + "ground_point_count": 0 if sequence == missing_sequence else cell_count, + "nonground_point_count": 0, + "rejected_point_count": 0, + "occupied_cell_count": 0, + } + for sequence in range(frame_count) + ] + (root / "frames.ndjson").write_text( + "".join(json.dumps(frame, sort_keys=True) + "\n" for frame in frames), + encoding="utf-8", + ) + source_identity = {"schema_version": "missioncore.test-sealed-source/v1"} + source_identity_sha256 = hashlib.sha256( + json.dumps(source_identity, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + result_id = "m49-tgs-full-shadow-" + source_identity_sha256 + report = { + "schema_version": "missioncore.m49-tgs-full-shadow-report/v1", + "result_id": result_id, + "configuration": { + "coordinate_frame": "map-gravity-local", + "cell_size_m": 0.15, + "radius_m": 12.0, + }, + "visual_review": { + "state_codes": { + "UNOBSERVED": 0, + "GROUND_SUPPORT": 1, + "NONGROUND_OCCUPIED": 2, + "UNKNOWN_REJECTED": 3, + } + }, + } + (root / "report.json").write_text(json.dumps(report), encoding="utf-8") + artifacts = [] + for name in ( + "costmap-cell-centers-xy-m.npy", + "costmap-states.npy", + "costmap-z-bounds-m.npy", + "frames.ndjson", + "report.json", + ): + path = root / name + artifacts.append( + { + "path": name, + "byte_length": path.stat().st_size, + "sha256": _sha256(path), + } + ) + (root / "manifest.json").write_text( + json.dumps( + { + "schema_version": "missioncore.m49-tgs-full-shadow-lab/v1", + "result_id": result_id, + "identity": source_identity, + "identity_sha256": source_identity_sha256, + "artifacts": artifacts, + } + ), + encoding="utf-8", + ) + return root + + +def test_local_builder_seals_bounded_chunks_without_worker_network( + monkeypatch, + tmp_path: Path, +) -> None: + source = _sealed_source(tmp_path / "source") + + def forbid_network(*_args: object, **_kwargs: object) -> None: + raise AssertionError("local LAB builder attempted network access") + + monkeypatch.setattr(socket, "create_connection", forbid_network) + sealed = seal_m49_physical_safety_playback( + source_root=source, + destination_root=tmp_path / "results", + profile_path=( + REPOSITORY_ROOT / "config" / "perception" / "m49-physical-safety-shadow-v0.json" + ), + created_at_utc="2026-08-27T12:00:00Z", + ) + + assert sealed.manifest["execution"] == { + "execution_class": "local-sequential-offline", + "worker_role": "realtime-only", + "worker_runtime_dependency": False, + "worker_requests_required": 0, + } + playback = sealed.manifest["playback"] + assert playback["cell_size_m"] == 0.15 + assert playback["chunk_frame_count"] == 32 + assert playback["startup_prebuffer_chunk_count"] == 2 + assert playback["resident_chunk_count_max"] == 3 + assert [(chunk["start"], chunk["count"]) for chunk in playback["chunks"]] == [ + (0, 32), + (32, 32), + (64, 1), + ] + first = (sealed.root / playback["chunks"][0]["path"]).read_bytes() + magic, start, count, cells, state_bytes, z_bytes = CHUNK_HEADER.unpack_from(first) + assert (magic, start, count, cells) == (CHUNK_MAGIC, 0, 32, 8) + assert state_bytes == 32 * 8 + assert z_bytes == 32 * 8 * 2 * 4 + assert read_m49_physical_safety_playback(sealed.root).result_id == sealed.result_id + + +def test_local_api_serves_only_hash_verified_sealed_tracks(tmp_path: Path) -> None: + source = _sealed_source(tmp_path / "source") + destination = tmp_path / "results" + sealed = seal_m49_physical_safety_playback( + source_root=source, + destination_root=destination, + profile_path=( + REPOSITORY_ROOT / "config" / "perception" / "m49-physical-safety-shadow-v0.json" + ), + created_at_utc="2026-08-27T12:00:00Z", + ) + app = FastAPI() + app.include_router( + build_m49_physical_safety_playback_router(root_provider=lambda: destination) + ) + client = TestClient(app) + + manifest_response = client.get( + f"/api/v1/laboratory/m49/physical-safety-playback/{sealed.result_id}/manifest" + ) + assert manifest_response.status_code == 200 + manifest = manifest_response.json() + assert manifest["execution"]["worker_requests_required"] == 0 + assert manifest["access"] == "read-only-sealed-local" + assert "worker" not in manifest["playback"]["centers"]["url"] + assert client.get(manifest["playback"]["centers"]["url"]).status_code == 200 + assert client.get(manifest["playback"]["chunks"][0]["url"]).status_code == 200 + source_result_id = sealed.manifest["identity"]["source_result_id"] + catalog = client.get( + "/api/v1/laboratory/m49/physical-safety-playback/results", + params={"source_result_id": source_result_id}, + ) + assert catalog.status_code == 200 + assert [item["result_id"] for item in catalog.json()["items"]] == [sealed.result_id] + + chunk_path = sealed.root / sealed.manifest["playback"]["chunks"][0]["path"] + damaged = bytearray(chunk_path.read_bytes()) + damaged[-1] ^= 1 + chunk_path.write_bytes(damaged) + assert client.get(manifest["playback"]["chunks"][0]["url"]).status_code == 503 diff --git a/tests/test_m4_threat_replay_result.py b/tests/test_m4_threat_replay_result.py index 28c45b7..c77f4cc 100644 --- a/tests/test_m4_threat_replay_result.py +++ b/tests/test_m4_threat_replay_result.py @@ -1,11 +1,18 @@ from __future__ import annotations +import hashlib import json from pathlib import Path from fastapi.routing import APIRoute +from pytest import MonkeyPatch -from k1link.perception.threat_replay import read_threat_replay_result +from k1link.perception.geometry import RecordedGeometryStore +from k1link.perception.threat_replay import ( + read_threat_replay_result, + read_threat_replay_result_metadata, +) +from k1link.perception.threat_timeline import RecordedThreatTimeline from k1link.sessions import RecordedCameraFrame from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router @@ -128,6 +135,9 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: get_timeline = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline") get_chunk = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/chunk") get_playback = _endpoint("/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback") + get_playback_chunk = _endpoint( + "/api/v1/laboratory/m4-threat/results/{result_id}/timeline/playback/chunks/{chunk_index}" + ) timeline = get_timeline(RESULT_ID) assert timeline["schema_version"] == "missioncore.recorded-spatial-evidence-timeline/v1" @@ -178,6 +188,33 @@ def test_m4_6_timeline_is_indexed_and_spatial_evidence_is_chunked() -> None: assert playback["track"]["shape"] == [9_207_270, 3] assert playback["track"]["bytes"] == 110_487_240 assert len(playback["track"]["sha256"]) == 64 + assert playback["chunk_frame_count"] == 24 + assert playback["resident_chunk_count_max"] == 4 + assert playback["forward_prefetch_chunk_count"] == 1 + assert len(playback["chunks"]) == 188 + first_chunk = playback["chunks"][0] + assert first_chunk["start"] == 0 + assert first_chunk["count"] == 24 + assert first_chunk["bytes"] < 3 * 1024 * 1024 + chunk_response = get_playback_chunk(RESULT_ID, 0) + assert len(chunk_response.body) == first_chunk["bytes"] + assert hashlib.sha256(chunk_response.body).hexdigest() == first_chunk["sha256"] + + +def test_m4_6_timeline_metadata_defers_large_geometry_archives( + monkeypatch: MonkeyPatch, +) -> None: + result = read_threat_replay_result_metadata(RESULTS_ROOT / RESULT_ID) + + def reject_eager_load(*_args: object, **_kwargs: object) -> RecordedGeometryStore: + raise AssertionError("timeline metadata eagerly loaded the geometry archives") + + monkeypatch.setattr(RecordedGeometryStore, "from_repository", reject_eager_load) + timeline = RecordedThreatTimeline(repository_root=REPOSITORY_ROOT, result=result) + + assert timeline.metadata()["frame_count"] == 4489 + assert timeline.metadata()["maximum_source_points_per_frame"] == 3092 + assert timeline._store is None def test_m4_6_exact_camera_endpoint_is_bound_to_selected_visual_sequence() -> None: