feat(lab): stabilize autonomous TGS playback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<THREE.Group | null>(null);
|
||||
const localSurfacePointsRef = useRef<THREE.Points | null>(null);
|
||||
const currentIncrementPointsRef = useRef<THREE.Points | null>(null);
|
||||
const requestRenderRef = useRef<() => void>(() => undefined);
|
||||
const corridorForwardLengthRef = useRef(corridor.forwardLengthM);
|
||||
const modeRef = useRef(mode);
|
||||
const classifiedMeshesRef = useRef<ReadonlyMap<LaboratoryMetricCellState, THREE.InstancedMesh>>(
|
||||
new Map(),
|
||||
);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
const [renderStats, setRenderStats] = useState<LaboratoryMetricRenderStats | null>(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<LaboratoryMetricCellState>();
|
||||
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 ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
{renderStats ? (
|
||||
<span
|
||||
data-decision="performance"
|
||||
title="CPU-время отправки последнего WebGL render pass; это не GPU timer. В покое сцена не перерисовывается."
|
||||
>
|
||||
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)}×
|
||||
</span>
|
||||
) : null}
|
||||
{metricLegendEntries.map((entry) => (
|
||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
|
||||
@@ -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<Record<string, number>>;
|
||||
}
|
||||
|
||||
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<Record<string, number>>;
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<T extends string | number | boolean>(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<string> {
|
||||
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<ArrayBuffer> {
|
||||
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<Record<string, number>> {
|
||||
const row = objectValue(value, "M49 physical-safety state codes");
|
||||
const parsed: Record<string, number> = {};
|
||||
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<M49PhysicalSafetyPlaybackManifest> {
|
||||
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, "<f4", "M49 centers dtype");
|
||||
if (!Array.isArray(centerRow.shape)
|
||||
|| centerRow.shape.length !== 2
|
||||
|| centerRow.shape[0] !== cellCount
|
||||
|| centerRow.shape[1] !== 2
|
||||
|| centers.byteLength !== cellCount * 2 * 4) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 centers shape изменилась.");
|
||||
}
|
||||
const frames = descriptor(playback.frames, `${base}/tracks/frames`, "M49 frames");
|
||||
const frameRow = objectValue(playback.frames, "M49 frames");
|
||||
exact(frameRow.dtype, "ndjson", "M49 frames dtype");
|
||||
if (!Array.isArray(frameRow.shape)
|
||||
|| frameRow.shape.length !== 1
|
||||
|| frameRow.shape[0] !== frameCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 frames shape изменилась.");
|
||||
}
|
||||
if (!Array.isArray(playback.chunks)) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk catalog отсутствует.");
|
||||
}
|
||||
const expectedChunkCount = Math.ceil(frameCount / chunkFrameCount);
|
||||
if (playback.chunks.length !== expectedChunkCount) {
|
||||
throw new M49PhysicalSafetyPlaybackContractError("M49 chunk accounting нарушен.");
|
||||
}
|
||||
const chunks = playback.chunks.map((value, index): ChunkDescriptor => {
|
||||
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<string | null> {
|
||||
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<string, number> = {};
|
||||
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<number>,
|
||||
): 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<number, ResidentChunk>();
|
||||
private readonly pending = new Map<number, Promise<ResidentChunk>>();
|
||||
private readonly admittedStateCodes: ReadonlySet<number>;
|
||||
private staticBytes: number;
|
||||
private preparedWindowKey: string | null = null;
|
||||
private pendingPrepare: { key: string; promise: Promise<void> } | 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<M49PhysicalSafetyPlaybackBuffer> {
|
||||
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<void> {
|
||||
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<M49PhysicalSafetyPlaybackFrame> {
|
||||
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<ResidentChunk> {
|
||||
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<number>): 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
"<f4",
|
||||
centersTrack.shape,
|
||||
) as Float32Array;
|
||||
const centersXyM = Array.from({ length: manifest.cellCount }, (_, index) => (
|
||||
[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,
|
||||
|
||||
@@ -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<Response>;
|
||||
@@ -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<M4Point3>(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<M4ThreatPlaybackPointPack> {
|
||||
): Promise<M4ThreatPlaybackManifest> {
|
||||
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, "<f4", `M4.6 playback chunk ${index}.dtype`);
|
||||
const shapeValue = array(row.shape, `M4.6 playback chunk ${index}.shape`)
|
||||
.map((item) => 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<M4ThreatPlaybackPointPack> {
|
||||
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<M4ThreatPlaybackPointPack> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<E47SemanticSlamResult | null>(null);
|
||||
const [semanticError, setSemanticError] = useState<string | null>(null);
|
||||
const [playbackPack, setPlaybackPack] = useState<M49TgsFullShadowPlaybackPack | null>(null);
|
||||
const [physicalPlayback, setPhysicalPlayback] = useState<M49PhysicalSafetyPlaybackBuffer | null>(null);
|
||||
const [physicalProgress, setPhysicalProgress] = useState<M49PhysicalSafetyPlaybackProgress | null>(null);
|
||||
const [physicalRevision, setPhysicalRevision] = useState(0);
|
||||
const [playbackProgress, setPlaybackProgress] = useState<M49TgsFullShadowPlaybackProgress>({
|
||||
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<M49TgsFullShadowSpatial | null>(() => {
|
||||
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<M4ReplayClassifiedSpatialFrame | null>(() => {
|
||||
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<M4ReplayClassifiedSpatialFrame | null>(() => {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
@@ -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<LaboratoryMetricPackedCellEvidence | undefined>(() => {
|
||||
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({
|
||||
<strong>{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` : ""}`}</strong>
|
||||
<small>{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({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{(!classifiedSpatialLayer ? spatialFrame : displayedClassifiedSpatialFrame) ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? classifiedPointsBody
|
||||
: activeSpatialFrame?.pointCloudBodyXyzM ?? []}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={displayedClassifiedSpatialFrame ? [] : sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
showLowStep={displayedClassifiedSpatialFrame ? false : showLowStep}
|
||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.pointClassIds
|
||||
: alignedSemanticPointIds}
|
||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.classes
|
||||
: semanticClasses}
|
||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.palette
|
||||
: semanticPalette}
|
||||
classifiedCells={classifiedCellsBody}
|
||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||
showClassifiedCells={showRollingMap}
|
||||
/>
|
||||
) : null}
|
||||
<LaboratoryMetricEvidenceScene
|
||||
ref={metricSceneRef}
|
||||
pointCloudBodyXyzM={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? classifiedPointsBody
|
||||
: classifiedContextSpatialFrame?.pointCloudBodyXyzM ?? []}
|
||||
localSurfaceBodyXyzM={localSurface.pointsBodyXyzM}
|
||||
obstacles={classifiedSpatialLayer ? [] : sceneObstacles}
|
||||
rig={timeline.rig}
|
||||
corridor={timeline.corridor}
|
||||
occupiedVoxelSizeM={displayedClassifiedSpatialFrame?.cellSizeM ?? timeline.occupiedVoxelSizeM}
|
||||
mode={spatialMode}
|
||||
label={`${evidenceLabel} exact current increment, bounded local SLAM surface and rolling occupancy`}
|
||||
showCurrentIncrement={showCurrentIncrement}
|
||||
showLocalSurface={showLocalSurface}
|
||||
showRollingMap={showRollingMap}
|
||||
showLowStep={classifiedSpatialLayer ? false : showLowStep}
|
||||
pointSemanticClassIds={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.pointClassIds
|
||||
: alignedSemanticPointIds}
|
||||
semanticClasses={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.classes
|
||||
: semanticClasses}
|
||||
semanticPalette={displayedClassifiedSpatialFrame && replaceClassifiedPointCloud
|
||||
? displayedClassifiedSpatialFrame.palette
|
||||
: semanticPalette}
|
||||
classifiedCells={classifiedCellsBody}
|
||||
classifiedPackedCells={classifiedPackedCellsBody}
|
||||
classifiedCellSizeM={displayedClassifiedSpatialFrame?.cellSizeM}
|
||||
showClassifiedCells={showRollingMap}
|
||||
/>
|
||||
{classifiedSpatialLayer && !displayedClassifiedSpatialFrame ? (
|
||||
<div className="l3-visual-audit__state" role={classifiedSpatialLayer.error ? "alert" : "status"}>
|
||||
{classifiedSpatialLayer.loading || displayingBufferedFrame
|
||||
@@ -1033,7 +1100,10 @@ export function M4ReplayThreatVisual({
|
||||
) : null}
|
||||
{classifiedSpatialFrame?.sampleAvailable === false ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||
Кадр {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}.`
|
||||
: "."}
|
||||
</div>
|
||||
) : classifiedSpatialFrame && !activeSpatialFrame ? (
|
||||
<div className="m4-replay-threat-visual__pane-status" role="status">
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [pointPack, setPointPack] = useState<M4ThreatPlaybackPointPack | null>(null);
|
||||
const [playbackManifest, setPlaybackManifest] = useState<M4ThreatPlaybackManifest | null>(null);
|
||||
const [playbackProgress, setPlaybackProgress] = useState<M4ThreatPlaybackProgress>({
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: "<f4",
|
||||
shape: [cellCount, 2],
|
||||
},
|
||||
frames: {
|
||||
url: `${endpoint}/tracks/frames`,
|
||||
media_type: "application/x-ndjson",
|
||||
byte_length: frames.byteLength,
|
||||
sha256: sha256(frames),
|
||||
dtype: "ndjson",
|
||||
shape: [frameCount],
|
||||
},
|
||||
chunks: chunks.map((buffer, index) => ({
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -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, /<LaboratoryMetricEvidenceScene/);
|
||||
assert.doesNotMatch(
|
||||
visual,
|
||||
/\{\(!classifiedSpatialLayer \? spatialFrame : displayedClassifiedSpatialFrame\) \? \(/,
|
||||
);
|
||||
assert.match(scene, /useEffect\(\(\) => resetView\(\), \[mode, resetView\]\)/);
|
||||
assert.match(contract, /linked_semantic_result_id/);
|
||||
assert.match(contract, /missioncore\.m49-tgs-full-shadow-playback\/v1/);
|
||||
});
|
||||
|
||||
@@ -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: "<f4",
|
||||
shape: [pointCount, 3],
|
||||
bytes: pointCount * 3 * 4,
|
||||
sha256: index === 0 ? pointSha256 : "0".repeat(64),
|
||||
};
|
||||
});
|
||||
const manifestPayload = {
|
||||
schema_version: "missioncore.recorded-spatial-playback/v1",
|
||||
result_id: resultId,
|
||||
frame_count: 4489,
|
||||
point_count: 2,
|
||||
point_offsets: pointOffsets,
|
||||
chunk_frame_count: 24,
|
||||
resident_chunk_count_max: 4,
|
||||
forward_prefetch_chunk_count: 1,
|
||||
chunks,
|
||||
track: {
|
||||
id: "points-map-f32",
|
||||
url: `${endpointRoot}/${resultId}/timeline/playback/tracks/points-map-f32`,
|
||||
media_type: "application/octet-stream",
|
||||
dtype: "<f4",
|
||||
shape: [2, 3],
|
||||
bytes: pointBytes.byteLength,
|
||||
sha256: pointSha256,
|
||||
},
|
||||
coordinate_frame: "map",
|
||||
access: "read-only-sealed-binary-playback",
|
||||
};
|
||||
const requested = [];
|
||||
const fetcher = async (input) => {
|
||||
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, /<RecordedFmp4Player/);
|
||||
@@ -842,16 +922,18 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(
|
||||
visual,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: activeSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||
/pointCloudBodyXyzM=\{displayedClassifiedSpatialFrame && replaceClassifiedPointCloud[\s\S]*\? classifiedPointsBody[\s\S]*: classifiedContextSpatialFrame\?\.pointCloudBodyXyzM \?\? \[\]\}/,
|
||||
);
|
||||
assert.match(
|
||||
visual,
|
||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame[\s\S]*lastClassifiedSpatialFrameRef/,
|
||||
/const classifiedSpatialFrame = classifiedSpatialLayer\?\.frame\?\.sourceSequence === timelineFrame\.activeSequence[\s\S]*lastClassifiedSpatialFrameRef[\s\S]*const displayedClassifiedSpatialFrame = classifiedSpatialFrame\s*&&\s*classifiedSpatialFrame\.sampleAvailable !== false/,
|
||||
);
|
||||
assert.doesNotMatch(visual, /classifiedSpatialFrame\?\.sampleAvailable !== false/);
|
||||
assert.match(visual, /timelineFrame\.availableFrames\.find/);
|
||||
assert.match(visual, /localSurfaceBodyXyzM=\{localSurface\.pointsBodyXyzM\}/);
|
||||
assert.match(visual, /showLocalSurface=\{showLocalSurface\}/);
|
||||
assert.match(visual, /\{semantic \? \([\s\S]*>\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/,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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="<f4")
|
||||
state_bytes = state_values.nbytes
|
||||
z_bytes = z_values.nbytes
|
||||
padding = (4 - ((CHUNK_HEADER.size + state_bytes) % 4)) % 4
|
||||
with path.open("wb") as stream:
|
||||
stream.write(
|
||||
CHUNK_HEADER.pack(
|
||||
CHUNK_MAGIC,
|
||||
start,
|
||||
frame_count,
|
||||
cell_count,
|
||||
state_bytes,
|
||||
z_bytes,
|
||||
)
|
||||
)
|
||||
stream.write(memoryview(state_values).cast("B"))
|
||||
if padding:
|
||||
stream.write(b"\0" * padding)
|
||||
stream.write(memoryview(z_values).cast("B"))
|
||||
|
||||
|
||||
def seal_m49_physical_safety_playback(
|
||||
*,
|
||||
source_root: Path,
|
||||
destination_root: Path,
|
||||
profile_path: Path,
|
||||
created_at_utc: str | None = None,
|
||||
) -> 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("<f4")
|
||||
or states.dtype != np.dtype("uint8")
|
||||
or z_bounds.dtype != np.dtype("<f4")
|
||||
or centers.ndim != 2
|
||||
or centers.shape[1] != 2
|
||||
or states.ndim != 2
|
||||
or z_bounds.ndim != 3
|
||||
or z_bounds.shape[2] != 2
|
||||
or states.shape[0] != len(frames)
|
||||
or states.shape[1] != centers.shape[0]
|
||||
or z_bounds.shape != (states.shape[0], states.shape[1], 2)
|
||||
or not np.isfinite(centers).all()
|
||||
):
|
||||
raise M49PhysicalSafetyPlaybackError("source numeric shape or dtype changed")
|
||||
frame_count, cell_count = states.shape
|
||||
admitted_codes = np.asarray(sorted(set(state_codes.values())), dtype=np.uint8)
|
||||
|
||||
identity = {
|
||||
"schema_version": SCHEMA,
|
||||
"source_result_id": source_result_id,
|
||||
"source_manifest_sha256": source_manifest_sha256,
|
||||
"source_track_sha256": {
|
||||
name: source_artifacts[name]["sha256"] for name in SOURCE_TRACKS
|
||||
},
|
||||
"profile_id": profile.get("profile_id"),
|
||||
"profile_sha256": profile_sha256,
|
||||
"coordinate_frame": coordinate_frame,
|
||||
"frame_count": frame_count,
|
||||
"cell_count": cell_count,
|
||||
"cell_size_m": cell_size_m,
|
||||
"radius_m": radius_m,
|
||||
"state_codes": state_codes,
|
||||
"playback_policy": policy,
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"actuation_accepted": False,
|
||||
},
|
||||
}
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
result_id = f"{PREFIX}{identity_sha256}"
|
||||
target = destination / result_id
|
||||
if target.exists():
|
||||
existing = read_m49_physical_safety_playback(target)
|
||||
existing_playback = existing.manifest["playback"]
|
||||
assert isinstance(existing_playback, dict)
|
||||
existing_chunks = existing_playback["chunks"]
|
||||
assert isinstance(existing_chunks, list)
|
||||
for value in (
|
||||
existing_playback["centers"],
|
||||
existing_playback["frames"],
|
||||
*existing_chunks,
|
||||
):
|
||||
assert isinstance(value, dict)
|
||||
verify_m49_physical_safety_artifact(existing, value)
|
||||
return existing
|
||||
|
||||
created = created_at_utc or datetime.now(tz=UTC).isoformat().replace("+00:00", "Z")
|
||||
chunk_frame_count = int(policy["chunk_frame_count"])
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="mission-core-m49-physical-safety-", dir=destination
|
||||
) as raw_staging:
|
||||
staging = Path(raw_staging) / result_id
|
||||
staging.mkdir()
|
||||
centers_path = staging / "centers.f32"
|
||||
np.ascontiguousarray(centers, dtype="<f4").tofile(centers_path)
|
||||
frames_path = staging / "frames.ndjson"
|
||||
shutil.copyfile(source / "frames.ndjson", frames_path)
|
||||
centers_artifact = _artifact(
|
||||
centers_path,
|
||||
media_type="application/octet-stream",
|
||||
role="costmap-cell-centers-f32le",
|
||||
)
|
||||
frames_artifact = _artifact(
|
||||
frames_path,
|
||||
media_type="application/x-ndjson",
|
||||
role="frame-catalog",
|
||||
)
|
||||
chunks: list[dict[str, object]] = []
|
||||
for index, start in enumerate(range(0, frame_count, chunk_frame_count)):
|
||||
count = min(chunk_frame_count, frame_count - start)
|
||||
state_slice = states[start : start + count]
|
||||
if not np.isin(state_slice, admitted_codes).all():
|
||||
raise M49PhysicalSafetyPlaybackError(
|
||||
f"source state code changed in chunk {index}"
|
||||
)
|
||||
for local_index, frame in enumerate(frames[start : start + count]):
|
||||
if frame["sample_available"] is False and (
|
||||
np.any(state_slice[local_index] != 0)
|
||||
or np.isfinite(z_bounds[start + local_index]).any()
|
||||
):
|
||||
raise M49PhysicalSafetyPlaybackError(
|
||||
"missing source sample did not remain fail-closed"
|
||||
)
|
||||
chunk_path = staging / f"chunk-{index:06d}.bin"
|
||||
_chunk_payload(
|
||||
chunk_path,
|
||||
start=start,
|
||||
states=state_slice,
|
||||
z_bounds=z_bounds[start : start + count],
|
||||
)
|
||||
descriptor = _artifact(
|
||||
chunk_path,
|
||||
media_type="application/octet-stream",
|
||||
role="states-u8-and-z-bounds-f32le",
|
||||
)
|
||||
descriptor.update(
|
||||
{
|
||||
"index": index,
|
||||
"start": start,
|
||||
"count": count,
|
||||
"header_bytes": CHUNK_HEADER.size,
|
||||
"format": "mcpsch01-states-u8-aligned-z-bounds-f32le",
|
||||
}
|
||||
)
|
||||
chunks.append(descriptor)
|
||||
|
||||
manifest = {
|
||||
"schema_version": SCHEMA,
|
||||
"result_id": result_id,
|
||||
"created_at_utc": created,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"execution": {
|
||||
"execution_class": "local-sequential-offline",
|
||||
"worker_role": "realtime-only",
|
||||
"worker_runtime_dependency": False,
|
||||
"worker_requests_required": 0,
|
||||
},
|
||||
"playback": {
|
||||
"coordinate_frame": coordinate_frame,
|
||||
"source_pace_hz": profile["operator_visual"]["source_pace_hz"],
|
||||
"frame_count": frame_count,
|
||||
"cell_count": cell_count,
|
||||
"cell_size_m": cell_size_m,
|
||||
"radius_m": radius_m,
|
||||
"state_codes": state_codes,
|
||||
**policy,
|
||||
"centers": {
|
||||
**centers_artifact,
|
||||
"dtype": "<f4",
|
||||
"shape": [cell_count, 2],
|
||||
},
|
||||
"frames": {
|
||||
**frames_artifact,
|
||||
"dtype": "ndjson",
|
||||
"shape": [frame_count],
|
||||
},
|
||||
"chunks": chunks,
|
||||
},
|
||||
"authority": identity["authority"],
|
||||
}
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
staging.replace(target)
|
||||
return read_m49_physical_safety_playback(target)
|
||||
|
||||
|
||||
def read_m49_physical_safety_playback(root: Path) -> 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",
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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"]
|
||||
@@ -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": "<f4",
|
||||
"shape": [point_stop - point_start, 3],
|
||||
"bytes": payload.nbytes,
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _read_threat_result_cached(
|
||||
root_value: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> ThreatReplayResult:
|
||||
del signature
|
||||
return read_threat_replay_result(Path(root_value))
|
||||
return read_threat_replay_result_metadata(Path(root_value))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user