Compare commits
3
Commits
992c5a8b74
...
eb416ff80a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb416ff80a | ||
|
|
4fa1669ab7 | ||
|
|
4fa6597b18 |
@@ -101,13 +101,14 @@ export function ObservationTimeline({
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button
|
||||
className="observation-timeline__transport"
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
variant="ghost"
|
||||
disabled={!buffered || !onPlayingChange}
|
||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
||||
onClick={() => onPlayingChange?.(!playing)}
|
||||
>
|
||||
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
||||
</Button>
|
||||
/>
|
||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
||||
<Select
|
||||
label="Скорость воспроизведения"
|
||||
@@ -117,7 +118,7 @@ export function ObservationTimeline({
|
||||
{ value: "1", label: "1×" },
|
||||
{ value: "2", label: "2×" },
|
||||
]}
|
||||
variant="split"
|
||||
variant="inline"
|
||||
menuWidth="anchor"
|
||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
||||
/>
|
||||
|
||||
@@ -90,6 +90,22 @@ export function recordedMediaSegmentAppendOrder(
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function recordedMediaCanRollTarget(
|
||||
previousSequence: number,
|
||||
nextSequence: number,
|
||||
playing: boolean,
|
||||
targetBuffered: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
playing
|
||||
&& targetBuffered
|
||||
&& Number.isInteger(previousSequence)
|
||||
&& Number.isInteger(nextSequence)
|
||||
&& previousSequence >= 1
|
||||
&& nextSequence >= previousSequence
|
||||
);
|
||||
}
|
||||
|
||||
function recordedMediaTimeRangesContain(
|
||||
ranges: TimeRanges,
|
||||
targetSeconds: number,
|
||||
@@ -1028,12 +1044,13 @@ export function RecordedFmp4Player({
|
||||
forceReset: false,
|
||||
resetAttempts: 0,
|
||||
};
|
||||
const rollingTarget = Boolean(
|
||||
const rollingTarget = Boolean(previousTarget && recordedMediaCanRollTarget(
|
||||
previousTarget.sequence,
|
||||
candidateTarget.sequence,
|
||||
playbackPlayingRef.current
|
||||
&& previousTarget
|
||||
&& runtime.notifiedRevision === previousTarget.revision
|
||||
&& recordedSegmentTargetBuffered(runtime, candidateTarget),
|
||||
);
|
||||
&& runtime.notifiedRevision === previousTarget.revision,
|
||||
recordedSegmentTargetBuffered(runtime, candidateTarget),
|
||||
));
|
||||
const reportPumpError = (error: unknown) => {
|
||||
if (
|
||||
runtime.disposed
|
||||
|
||||
@@ -32,6 +32,7 @@ export function LaboratoryEvidenceViewer<
|
||||
transport,
|
||||
trailingActions,
|
||||
modeControlsVisible = true,
|
||||
chromeLayout = "overlay",
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -52,6 +53,7 @@ export function LaboratoryEvidenceViewer<
|
||||
transport?: ReactNode;
|
||||
trailingActions?: ReactNode;
|
||||
modeControlsVisible?: boolean;
|
||||
chromeLayout?: "overlay" | "stacked";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -80,6 +82,36 @@ export function LaboratoryEvidenceViewer<
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const controls = (
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const viewer = (
|
||||
<dialog
|
||||
ref={viewerRef}
|
||||
@@ -90,44 +122,40 @@ export function LaboratoryEvidenceViewer<
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
data-mode-controls={modeControlsVisible ? undefined : "content"}
|
||||
data-chrome-layout={chromeLayout}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
{modeControlsVisible && secondaryMode ? (
|
||||
<SegmentedControl
|
||||
value={secondaryMode.value}
|
||||
items={[...secondaryMode.modes]}
|
||||
label={secondaryMode.label}
|
||||
onChange={secondaryMode.onChange}
|
||||
/>
|
||||
) : null}
|
||||
{modeControlsVisible ? (
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{trailingActions}
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{chromeLayout === "stacked" ? (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__header">
|
||||
<div className="laboratory-evidence-viewer__header-context">
|
||||
{overlay}
|
||||
</div>
|
||||
{controls}
|
||||
</div>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
{transport ? (
|
||||
<div className="laboratory-evidence-viewer__transport">
|
||||
{transport}
|
||||
</div>
|
||||
) : null}
|
||||
{controls}
|
||||
</>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
|
||||
|
||||
@@ -40,6 +40,55 @@ export interface LaboratoryMetricCorridorVisual {
|
||||
halfWidthM: number;
|
||||
}
|
||||
|
||||
export interface LaboratoryMetricLegendEntry {
|
||||
id: LaboratoryMetricDecision | "context" | "local-surface" | "rolling";
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function laboratoryMetricLegendEntries({
|
||||
pointCloudCount,
|
||||
localSurfaceCount,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
}: {
|
||||
pointCloudCount: number;
|
||||
localSurfaceCount: number;
|
||||
obstacles: readonly LaboratoryMetricObstacleVisual[];
|
||||
showCurrentIncrement: boolean;
|
||||
showLocalSurface: boolean;
|
||||
showRollingMap: boolean;
|
||||
}): readonly LaboratoryMetricLegendEntry[] {
|
||||
const visibleObstacles = obstacles.filter((obstacle) => (
|
||||
obstacle.state === "current"
|
||||
? showCurrentIncrement
|
||||
: obstacle.state === "retained"
|
||||
? showRollingMap
|
||||
: false
|
||||
));
|
||||
const decisions = new Set(visibleObstacles.map(({ decision }) => decision));
|
||||
const entries: LaboratoryMetricLegendEntry[] = [];
|
||||
if (decisions.has("threat")) entries.push({ id: "threat", label: "Угроза" });
|
||||
if (decisions.has("not-threat")) entries.push({ id: "not-threat", label: "Вне коридора" });
|
||||
if (decisions.has("unknown")) entries.push({ id: "unknown", label: "Неизвестно" });
|
||||
if (showCurrentIncrement && pointCloudCount > 0) {
|
||||
entries.push({ id: "context", label: "Текущий кадр" });
|
||||
}
|
||||
if (showLocalSurface && localSurfaceCount > 0) {
|
||||
entries.push({ id: "local-surface", label: "Локальная SLAM-поверхность" });
|
||||
}
|
||||
if (
|
||||
showRollingMap
|
||||
&& visibleObstacles.some((obstacle) => (
|
||||
obstacle.state === "retained" && obstacle.cellCentersBodyXyzM.length > 0
|
||||
))
|
||||
) {
|
||||
entries.push({ id: "rolling", label: "Занято на накопленной карте" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
@@ -167,7 +216,6 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(tokenColor(host, "--nodedc-canvas", [5, 5, 6]), 1);
|
||||
renderer.domElement.setAttribute("aria-label", label);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
@@ -221,6 +269,11 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
staticContentRef.current = null;
|
||||
dynamicContentRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = hostRef.current?.querySelector("canvas");
|
||||
if (canvas) canvas.setAttribute("aria-label", label);
|
||||
}, [label]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -478,6 +531,14 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
}];
|
||||
});
|
||||
})();
|
||||
const metricLegendEntries = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: pointCloudBodyXyzM.length,
|
||||
localSurfaceCount: localSurfaceBodyXyzM.length,
|
||||
obstacles,
|
||||
showCurrentIncrement,
|
||||
showLocalSurface,
|
||||
showRollingMap,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="laboratory-metric-evidence-scene">
|
||||
@@ -485,12 +546,9 @@ LaboratoryMetricEvidenceSceneHandle,
|
||||
{renderError ? <p>{renderError}</p> : null}
|
||||
</div>
|
||||
<div className="laboratory-metric-evidence-scene__legend">
|
||||
<span data-decision="threat">Угроза</span>
|
||||
<span data-decision="not-threat">Вне коридора</span>
|
||||
<span data-decision="unknown">Неизвестно</span>
|
||||
<span data-decision="context">Current increment</span>
|
||||
<span data-decision="local-surface">Local SLAM surface</span>
|
||||
<span data-decision="rolling">Rolling-map occupied</span>
|
||||
{metricLegendEntries.map((entry) => (
|
||||
<span key={entry.id} data-decision={entry.id}>{entry.label}</span>
|
||||
))}
|
||||
{semanticLegendEntries.map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Select, StatusBadge } from "@nodedc/ui-react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Icon, IconButton, Select, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
@@ -146,78 +146,94 @@ export function LaboratorySummary({
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const detailsId = useId();
|
||||
return (
|
||||
<section className="laboratory-summary">
|
||||
<section className="laboratory-summary" data-expanded={expanded ? "true" : undefined}>
|
||||
<header>
|
||||
<div>
|
||||
<div className="laboratory-summary__heading">
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
<div className="laboratory-summary__actions">
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
<IconButton
|
||||
label={expanded ? "Свернуть подробности лабораторной работы" : "Раскрыть подробности лабораторной работы"}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
<span className="laboratory-summary__toggle-glyph" aria-hidden="true">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
<div id={detailsId} className="laboratory-summary__details" hidden={!expanded}>
|
||||
<p className="laboratory-summary__description">{description}</p>
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<dl className="laboratory-summary__brief">
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{brief.question}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Как проверяли</dt>
|
||||
<dd>{brief.approach}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Главный результат</dt>
|
||||
<dd>{brief.principalResult}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ограничение</dt>
|
||||
<dd>{brief.limitation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { SplitPane } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
|
||||
export const LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT =
|
||||
"missioncore.laboratory-recorded-clip-viewer/v1" as const;
|
||||
|
||||
export interface LaboratoryRecordedClipFrame {
|
||||
sequence: number;
|
||||
sourceTimeNs: number;
|
||||
}
|
||||
|
||||
export function nearestLaboratoryRecordedClipFrame(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
sourceTimeNs: number,
|
||||
): LaboratoryRecordedClipFrame | null {
|
||||
if (!frames.length || !Number.isFinite(sourceTimeNs)) return null;
|
||||
let left = 0;
|
||||
let right = frames.length - 1;
|
||||
while (left < right) {
|
||||
const middle = Math.floor((left + right) / 2);
|
||||
if (frames[middle]!.sourceTimeNs < sourceTimeNs) left = middle + 1;
|
||||
else right = middle;
|
||||
}
|
||||
const next = frames[left]!;
|
||||
const previous = frames[Math.max(0, left - 1)]!;
|
||||
return Math.abs(previous.sourceTimeNs - sourceTimeNs)
|
||||
<= Math.abs(next.sourceTimeNs - sourceTimeNs)
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
|
||||
export function laboratoryRecordedClipEndExclusiveNs(
|
||||
frames: readonly LaboratoryRecordedClipFrame[],
|
||||
): number | null {
|
||||
const last = frames.at(-1);
|
||||
if (!last) return null;
|
||||
const deltas = frames.slice(1).flatMap((frame, index) => {
|
||||
const delta = frame.sourceTimeNs - frames[index]!.sourceTimeNs;
|
||||
return Number.isSafeInteger(delta) && delta > 0 ? [delta] : [];
|
||||
}).sort((left, right) => left - right);
|
||||
const typicalDelta = deltas.length
|
||||
? deltas[Math.floor(deltas.length / 2)]!
|
||||
: 100_000_000;
|
||||
return last.sourceTimeNs + typicalDelta;
|
||||
}
|
||||
|
||||
export function LaboratoryRecordedClipPlayer({
|
||||
source,
|
||||
segmentCount,
|
||||
frames,
|
||||
sequence,
|
||||
playing,
|
||||
playbackRate,
|
||||
cameraPresentation,
|
||||
continuousPlayback,
|
||||
sourceCount,
|
||||
cameraRef,
|
||||
cameraOverlay,
|
||||
alternativeScene,
|
||||
onSequenceChange,
|
||||
onPlayingChange,
|
||||
onPlaybackRateChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
segmentCount: number;
|
||||
frames: readonly LaboratoryRecordedClipFrame[];
|
||||
sequence: number;
|
||||
playing: boolean;
|
||||
playbackRate: number;
|
||||
cameraPresentation: "primary" | "companion" | "hidden";
|
||||
continuousPlayback: boolean;
|
||||
sourceCount: number;
|
||||
cameraRef?: RefObject<HTMLDivElement | null>;
|
||||
cameraOverlay?: ReactNode;
|
||||
alternativeScene?: ReactNode;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onPlayingChange: (playing: boolean) => void;
|
||||
onPlaybackRateChange: (rate: number) => void;
|
||||
}) {
|
||||
const [companionSpatialSize, setCompanionSpatialSize] = useState(69);
|
||||
const lastEmittedSequenceRef = useRef(sequence);
|
||||
lastEmittedSequenceRef.current = sequence;
|
||||
const frame = useMemo(
|
||||
() => frames.find((candidate) => candidate.sequence === sequence) ?? frames[0] ?? null,
|
||||
[frames, sequence],
|
||||
);
|
||||
const endExclusiveNs = useMemo(
|
||||
() => laboratoryRecordedClipEndExclusiveNs(frames),
|
||||
[frames],
|
||||
);
|
||||
const playback = useMemo<RecordedObservationPlayback | null>(() => frame ? ({
|
||||
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
|
||||
playing: continuousPlayback && playing,
|
||||
rate: playbackRate,
|
||||
}) : null, [continuousPlayback, frame, playbackRate, playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!continuousPlayback && playing) onPlayingChange(false);
|
||||
}, [continuousPlayback, onPlayingChange, playing]);
|
||||
|
||||
const emitSequence = useCallback((nextSequence: number) => {
|
||||
if (lastEmittedSequenceRef.current === nextSequence) return;
|
||||
lastEmittedSequenceRef.current = nextSequence;
|
||||
onSequenceChange(nextSequence);
|
||||
}, [onSequenceChange]);
|
||||
|
||||
const handlePlaybackChange = useCallback((next: RecordedObservationPlayback) => {
|
||||
const sourceTimeNs = Math.round(next.currentSeconds * 1_000_000_000);
|
||||
const first = frames[0];
|
||||
if (!first || endExclusiveNs === null) return;
|
||||
if (sourceTimeNs >= endExclusiveNs) {
|
||||
emitSequence(first.sequence);
|
||||
return;
|
||||
}
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, sourceTimeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}, [emitSequence, endExclusiveNs, frames]);
|
||||
|
||||
const timelineStart = frames[0]?.sourceTimeNs ?? 0;
|
||||
const timelineEnd = frames.at(-1)?.sourceTimeNs ?? timelineStart + 1;
|
||||
const companionVisible = cameraPresentation === "companion";
|
||||
const spatialSize = companionVisible
|
||||
? companionSpatialSize
|
||||
: cameraPresentation === "primary" ? 0 : 100;
|
||||
const spatialPane = (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player__spatial"
|
||||
aria-hidden={cameraPresentation === "primary"}
|
||||
>
|
||||
{cameraPresentation !== "primary" ? alternativeScene : null}
|
||||
</div>
|
||||
);
|
||||
const cameraPane = (
|
||||
<div
|
||||
ref={cameraRef}
|
||||
className="laboratory-recorded-clip-player__camera"
|
||||
aria-hidden={cameraPresentation === "hidden"}
|
||||
>
|
||||
{playback ? (
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
interactive={false}
|
||||
prepare
|
||||
segmentSequence={frame?.sequence}
|
||||
segmentCount={segmentCount}
|
||||
admissionKey={`${source.id}:${source.delivery?.id ?? "recorded"}`}
|
||||
onPlaybackChange={handlePlaybackChange}
|
||||
onPlayingRejected={() => onPlayingChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
{cameraPresentation !== "hidden" ? cameraOverlay : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="laboratory-recorded-clip-player"
|
||||
data-contract={LABORATORY_RECORDED_CLIP_VIEWER_CONTRACT}
|
||||
data-camera-presentation={cameraPresentation}
|
||||
>
|
||||
<div className="laboratory-recorded-clip-player__stage">
|
||||
<SplitPane
|
||||
className="laboratory-recorded-clip-player__split"
|
||||
primary={spatialPane}
|
||||
secondary={cameraPane}
|
||||
primarySize={spatialSize}
|
||||
onPrimarySizeChange={setCompanionSpatialSize}
|
||||
orientation="vertical"
|
||||
minPrimarySize={companionVisible ? 24 : 0}
|
||||
minSecondarySize={companionVisible ? 24 : 0}
|
||||
resizable={companionVisible}
|
||||
separatorLabel="Изменить размер 3D/ПЛАН и правой камеры"
|
||||
/>
|
||||
</div>
|
||||
<ObservationTimeline
|
||||
className="laboratory-recorded-clip-player__timeline"
|
||||
active
|
||||
sourceCount={sourceCount}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="frame-accurate"
|
||||
rangeNs={{ min: timelineStart, max: Math.max(timelineEnd, timelineStart + 1) }}
|
||||
currentNs={frame?.sourceTimeNs ?? timelineStart}
|
||||
playing={continuousPlayback && playing}
|
||||
playbackRate={continuousPlayback ? playbackRate : undefined}
|
||||
onPlaybackRateChange={continuousPlayback ? onPlaybackRateChange : undefined}
|
||||
onPlayingChange={continuousPlayback ? onPlayingChange : undefined}
|
||||
onSeek={(timeNs) => {
|
||||
const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs);
|
||||
if (nearest) emitSequence(nearest.sequence);
|
||||
}}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const FOCUSABLE = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
function keepFocusInside(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
frame: HTMLElement,
|
||||
): void {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(frame.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
frame.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function LaboratoryReviewWorkspaceFrame({
|
||||
ariaLabel,
|
||||
toolbar,
|
||||
stage,
|
||||
inspector,
|
||||
overlays,
|
||||
interactionEnabled = true,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
toolbar: ReactNode;
|
||||
stage: ReactNode;
|
||||
inspector?: ReactNode;
|
||||
overlays?: ReactNode;
|
||||
interactionEnabled?: boolean;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const frameRef = useRef<HTMLElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const animationFrame = window.requestAnimationFrame(() => {
|
||||
const firstFocusable = frameRef.current?.querySelector<HTMLElement>(FOCUSABLE);
|
||||
(firstFocusable ?? frameRef.current)?.focus();
|
||||
});
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
const target = returnFocusTarget?.isConnected ? returnFocusTarget : previousFocus;
|
||||
window.requestAnimationFrame(() => target?.focus());
|
||||
};
|
||||
}, [returnFocusTarget]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
ref={frameRef}
|
||||
className="laboratory-review-workspace"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
data-has-inspector={inspector ? "true" : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (!interactionEnabled) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
keepFocusInside(event, event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<header className="laboratory-review-workspace__toolbar">{toolbar}</header>
|
||||
<div className="laboratory-review-workspace__stage">{stage}</div>
|
||||
{inspector ? <footer className="laboratory-review-workspace__inspector">{inspector}</footer> : null}
|
||||
{overlays}
|
||||
</section>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -38,8 +38,14 @@ import { fetchE46JRawFisheyeRealtime } from "./e46jRawFisheyeRealtime";
|
||||
import { fetchE47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import { fetchM4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import { fetchM47ReferenceGraphLab } from "./m47ReferenceGraph";
|
||||
import {
|
||||
fetchM48LifecycleResult,
|
||||
} from "./m48ObjectCentricQuality";
|
||||
import { fetchM48SmallStaticRegression } from "./m48SmallStaticRegression";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "m48-object-centric-quality"
|
||||
| "m48-small-static-passage-regression"
|
||||
| "m47-reference-graph-shadow"
|
||||
| "m4-replay-threat"
|
||||
| "l3-pointpillars-visual-audit"
|
||||
@@ -82,6 +88,8 @@ export interface AdvancedLaboratoryIndexItem {
|
||||
}
|
||||
|
||||
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
"m47-reference-graph-shadow",
|
||||
"m4-replay-threat",
|
||||
"l3-pointpillars-visual-audit",
|
||||
@@ -119,6 +127,8 @@ const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||
];
|
||||
|
||||
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||
"m48-object-centric-quality": "m48-object-quality-(?:pack|result)",
|
||||
"m48-small-static-passage-regression": "m48-small-static-passage-regression",
|
||||
"m47-reference-graph-shadow": "m47-reference-graph-lab",
|
||||
"m4-replay-threat": "m4-threat-replay",
|
||||
"l3-pointpillars-visual-audit": "l3-pointpillars-visual-audit",
|
||||
@@ -164,6 +174,8 @@ export function isAdvancedLaboratoryWorkId(
|
||||
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: null,
|
||||
m48: null,
|
||||
m48SmallStatic: null,
|
||||
m4Threat: null,
|
||||
l3: null,
|
||||
l31: null,
|
||||
@@ -288,7 +300,9 @@ export function advancedLaboratoryResultAvailable(
|
||||
workId: AdvancedLaboratoryWorkId,
|
||||
results: AdvancedLaboratoryResults,
|
||||
): boolean {
|
||||
return workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
return workId === "m48-object-centric-quality" ? results.m48 !== null
|
||||
: workId === "m48-small-static-passage-regression" ? results.m48SmallStatic !== null
|
||||
: workId === "m47-reference-graph-shadow" ? results.m47Graph !== null
|
||||
: workId === "m4-replay-threat" ? results.m4Threat !== null
|
||||
: workId === "l3-pointpillars-visual-audit" ? results.l3 !== null
|
||||
: workId === "l31-pointpillars-ravnoves" ? results.l31 !== null
|
||||
@@ -337,7 +351,13 @@ export async function fetchAdvancedLaboratoryResult(
|
||||
} = {},
|
||||
): Promise<AdvancedLaboratoryResults> {
|
||||
const results = emptyAdvancedLaboratoryResults();
|
||||
if (workId === "m47-reference-graph-shadow") {
|
||||
if (workId === "m48-object-centric-quality") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8 lifecycle evidence identity не выбрана.");
|
||||
results.m48 = await fetchM48LifecycleResult(resultId, { fetcher, signal });
|
||||
} else if (workId === "m48-small-static-passage-regression") {
|
||||
if (!resultId) throw new AdvancedLaboratoryContractError("M4.8R1 regression identity не выбрана.");
|
||||
results.m48SmallStatic = await fetchM48SmallStaticRegression(resultId, { fetcher, signal });
|
||||
} else if (workId === "m47-reference-graph-shadow") {
|
||||
if (!resultId) {
|
||||
throw new AdvancedLaboratoryContractError("M4.7 LAB identity не выбрана.");
|
||||
}
|
||||
|
||||
@@ -34,9 +34,13 @@ import type { E46JRawFisheyeRealtimeResult } from "./e46jRawFisheyeRealtime";
|
||||
import type { E47SemanticSlamResult } from "./e47SemanticSlam";
|
||||
import type { M4ThreatReplayResult } from "./m4ReplayThreat";
|
||||
import type { M47ReferenceGraphLabResult } from "./m47ReferenceGraph";
|
||||
import type { M48AdvancedResult } from "./m48ObjectCentricQuality";
|
||||
import type { M48SmallStaticRegressionResult } from "./m48SmallStaticRegression";
|
||||
|
||||
export interface AdvancedLaboratoryResults {
|
||||
m47Graph: M47ReferenceGraphLabResult | null;
|
||||
m48: M48AdvancedResult | null;
|
||||
m48SmallStatic: M48SmallStaticRegressionResult | null;
|
||||
m4Threat: M4ThreatReplayResult | null;
|
||||
l3: L3PointPillarsVisualAuditResult | null;
|
||||
l31: L31PointPillarsRavnovesResult | null;
|
||||
|
||||
@@ -967,7 +967,7 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
const e39 = settledCatalogValue(settled[7]);
|
||||
const e40 = settledCatalogValue(settled[8]);
|
||||
return {
|
||||
m47Graph: null, m4Threat: null,
|
||||
m47Graph: null, m48: null, m48SmallStatic: null, m4Threat: null,
|
||||
l3: null, l31: null,
|
||||
l32: null,
|
||||
l33: null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,364 @@
|
||||
import type { LaboratoryFetch } from "./advancedResults";
|
||||
import type {
|
||||
M48Authority,
|
||||
M48Freshness,
|
||||
M48GeometryAssociation,
|
||||
M48Motion,
|
||||
M48Threat,
|
||||
M48Visibility,
|
||||
} from "./m48ObjectCentricQuality";
|
||||
|
||||
const RESULT_ID = /^m48-small-static-passage-regression-[a-f0-9]{64}$/;
|
||||
const PACK_ID = /^m48-object-quality-pack-[a-f0-9]{64}$/;
|
||||
const ANCHOR_ID = /^anchor-[a-f0-9]{24}$/;
|
||||
const SAFE_URL = /^\/api\/v1\/laboratory\/m48\/[A-Za-z0-9/_?&=.%:-]+$/;
|
||||
|
||||
export interface M48SmallStaticRegressionMetrics {
|
||||
assistedAnchorCount: number;
|
||||
assistedTrackletCount: number;
|
||||
anchorClipCount: number;
|
||||
requiresAvoidanceOrClearanceCount: number;
|
||||
workerRecalledAnchorCount: number;
|
||||
workerMissedAnchorCount: number;
|
||||
assistedAnchorRecall: number;
|
||||
extentIouThreshold: number;
|
||||
minimumAssistedAnchorRecall: number;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionResult {
|
||||
resultId: string;
|
||||
packId: string;
|
||||
createdAtUtc: string;
|
||||
runLabel: "M4.8R1";
|
||||
pipelineId: "m48-class-free-object-quality/v1";
|
||||
experimentId: "m48-small-static-passage-regression/v1";
|
||||
accepted: boolean;
|
||||
metrics: M48SmallStaticRegressionMetrics;
|
||||
gates: {
|
||||
anchorSetNonEmpty: boolean;
|
||||
developmentAnchorRecallTarget: boolean;
|
||||
independentTruthAvailable: false;
|
||||
};
|
||||
decision: {
|
||||
state: "accepted-development-regression-baseline" | "failed-development-regression-baseline";
|
||||
summary: string;
|
||||
nextAction: string;
|
||||
};
|
||||
groundTruth: false;
|
||||
independentTruth: false;
|
||||
authority: M48Authority;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionCaseSummary {
|
||||
anchorId: string;
|
||||
clipId: string;
|
||||
sequence: number;
|
||||
requiresAvoidanceOrClearance: boolean;
|
||||
workerCandidateCount: number;
|
||||
bestIou: number;
|
||||
matchedAtThreshold: boolean;
|
||||
outcome: "recalled" | "missed-assisted-anchor";
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionObject {
|
||||
predictionId: string;
|
||||
extentXyxy: readonly [number, number, number, number];
|
||||
geometryAssociation: M48GeometryAssociation;
|
||||
freshness: M48Freshness;
|
||||
motion: M48Motion;
|
||||
threat: M48Threat;
|
||||
}
|
||||
|
||||
export interface M48SmallStaticRegressionCase {
|
||||
resultId: string;
|
||||
packId: string;
|
||||
anchor: {
|
||||
anchorId: string;
|
||||
clipId: string;
|
||||
objectId: string;
|
||||
sequence: number;
|
||||
extentXyxy: readonly [number, number, number, number];
|
||||
visibility: M48Visibility;
|
||||
geometryAssociation: M48GeometryAssociation;
|
||||
freshness: M48Freshness;
|
||||
motion: M48Motion;
|
||||
threat: M48Threat;
|
||||
requiresAvoidanceOrClearance: boolean;
|
||||
};
|
||||
comparison: M48SmallStaticRegressionCaseSummary & {
|
||||
sourceTimeNs: number;
|
||||
anchorExtentXyxy: readonly [number, number, number, number];
|
||||
workerObjects: readonly M48SmallStaticRegressionObject[];
|
||||
bestPredictionId: string | null;
|
||||
extentIouThreshold: number;
|
||||
};
|
||||
cameraUrl: string | null;
|
||||
spatialUrl: string | null;
|
||||
groundTruth: false;
|
||||
authority: M48Authority;
|
||||
}
|
||||
|
||||
export class M48SmallStaticRegressionContractError extends Error {}
|
||||
|
||||
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался список.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exact(value: unknown, expected: string | boolean, label: string): void {
|
||||
if (value !== expected) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: нарушен контракт.`);
|
||||
}
|
||||
}
|
||||
|
||||
function textValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const result = numberValue(value, label);
|
||||
if (!Number.isInteger(result) || result < 0) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: ожидался флаг.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extentValue(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): readonly [number, number, number, number] {
|
||||
const rows = arrayValue(value, label).map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
if (
|
||||
rows.length !== 4
|
||||
|| rows.some((item) => item < 0 || item > 1)
|
||||
|| rows[0]! >= rows[2]!
|
||||
|| rows[1]! >= rows[3]!
|
||||
) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: рамка недопустима.`);
|
||||
}
|
||||
return rows as unknown as readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
function authorityValue(value: unknown): M48Authority {
|
||||
const authority = objectValue(value, "M4.8R1.authority");
|
||||
exact(authority.mode, "replay-simulated", "M4.8R1.authority.mode");
|
||||
exact(authority.physical_live, false, "M4.8R1.authority.physical_live");
|
||||
exact(authority.commands_enabled, false, "M4.8R1.authority.commands_enabled");
|
||||
exact(authority.actuation_allowed, false, "M4.8R1.authority.actuation_allowed");
|
||||
exact(authority.navigation_or_safety_accepted, false, "M4.8R1.authority.navigation_or_safety_accepted");
|
||||
return {
|
||||
mode: "replay-simulated",
|
||||
physicalLive: false,
|
||||
commandsEnabled: false,
|
||||
actuationAllowed: false,
|
||||
navigationOrSafetyAccepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(value: unknown, allowed: readonly T[], label: string): T {
|
||||
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
||||
throw new M48SmallStaticRegressionContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function parseSummary(value: unknown): M48SmallStaticRegressionCaseSummary {
|
||||
const row = objectValue(value, "M4.8R1.case");
|
||||
const anchorId = textValue(row.anchor_id, "M4.8R1.case.anchor_id");
|
||||
if (!ANCHOR_ID.test(anchorId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1.case.anchor_id: нарушена идентичность.");
|
||||
}
|
||||
return {
|
||||
anchorId,
|
||||
clipId: textValue(row.clip_id, "M4.8R1.case.clip_id"),
|
||||
sequence: integerValue(row.sequence, "M4.8R1.case.sequence"),
|
||||
requiresAvoidanceOrClearance: booleanValue(row.requires_avoidance_or_clearance, "M4.8R1.case.requires_avoidance_or_clearance"),
|
||||
workerCandidateCount: integerValue(row.worker_candidate_count, "M4.8R1.case.worker_candidate_count"),
|
||||
bestIou: numberValue(row.best_iou, "M4.8R1.case.best_iou"),
|
||||
matchedAtThreshold: booleanValue(row.matched_at_threshold, "M4.8R1.case.matched_at_threshold"),
|
||||
outcome: enumValue(row.outcome, ["recalled", "missed-assisted-anchor"] as const, "M4.8R1.case.outcome"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegression(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M48SmallStaticRegressionResult> {
|
||||
if (!RESULT_ID.test(resultId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 result identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 недоступен: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-result-view/v1", "M4.8R1.schema_version");
|
||||
const packId = textValue(payload.pack_id, "M4.8R1.pack_id");
|
||||
if (!PACK_ID.test(packId)) throw new M48SmallStaticRegressionContractError("M4.8R1.pack_id: нарушена идентичность.");
|
||||
const metrics = objectValue(payload.metrics, "M4.8R1.metrics");
|
||||
const gates = objectValue(payload.gates, "M4.8R1.gates");
|
||||
const decision = objectValue(payload.decision, "M4.8R1.decision");
|
||||
exact(payload.run_label, "M4.8R1", "M4.8R1.run_label");
|
||||
exact(payload.pipeline_id, "m48-class-free-object-quality/v1", "M4.8R1.pipeline_id");
|
||||
exact(payload.experiment_id, "m48-small-static-passage-regression/v1", "M4.8R1.experiment_id");
|
||||
exact(payload.ground_truth, false, "M4.8R1.ground_truth");
|
||||
exact(payload.independent_truth, false, "M4.8R1.independent_truth");
|
||||
exact(gates.independent_truth_available, false, "M4.8R1.gates.independent_truth_available");
|
||||
return {
|
||||
resultId,
|
||||
packId,
|
||||
createdAtUtc: textValue(payload.created_at_utc, "M4.8R1.created_at_utc"),
|
||||
runLabel: "M4.8R1",
|
||||
pipelineId: "m48-class-free-object-quality/v1",
|
||||
experimentId: "m48-small-static-passage-regression/v1",
|
||||
accepted: booleanValue(payload.accepted, "M4.8R1.accepted"),
|
||||
metrics: {
|
||||
assistedAnchorCount: integerValue(metrics.assisted_anchor_count, "M4.8R1.metrics.assisted_anchor_count"),
|
||||
assistedTrackletCount: integerValue(metrics.assisted_tracklet_count, "M4.8R1.metrics.assisted_tracklet_count"),
|
||||
anchorClipCount: integerValue(metrics.anchor_clip_count, "M4.8R1.metrics.anchor_clip_count"),
|
||||
requiresAvoidanceOrClearanceCount: integerValue(metrics.requires_avoidance_or_clearance_count, "M4.8R1.metrics.requires_avoidance_or_clearance_count"),
|
||||
workerRecalledAnchorCount: integerValue(metrics.worker_recalled_anchor_count, "M4.8R1.metrics.worker_recalled_anchor_count"),
|
||||
workerMissedAnchorCount: integerValue(metrics.worker_missed_anchor_count, "M4.8R1.metrics.worker_missed_anchor_count"),
|
||||
assistedAnchorRecall: numberValue(metrics.assisted_anchor_recall, "M4.8R1.metrics.assisted_anchor_recall"),
|
||||
extentIouThreshold: numberValue(metrics.extent_iou_threshold, "M4.8R1.metrics.extent_iou_threshold"),
|
||||
minimumAssistedAnchorRecall: numberValue(metrics.minimum_assisted_anchor_recall, "M4.8R1.metrics.minimum_assisted_anchor_recall"),
|
||||
},
|
||||
gates: {
|
||||
anchorSetNonEmpty: booleanValue(gates.anchor_set_non_empty, "M4.8R1.gates.anchor_set_non_empty"),
|
||||
developmentAnchorRecallTarget: booleanValue(gates.development_anchor_recall_target, "M4.8R1.gates.development_anchor_recall_target"),
|
||||
independentTruthAvailable: false,
|
||||
},
|
||||
decision: {
|
||||
state: enumValue(decision.state, ["accepted-development-regression-baseline", "failed-development-regression-baseline"] as const, "M4.8R1.decision.state"),
|
||||
summary: textValue(decision.summary, "M4.8R1.decision.summary"),
|
||||
nextAction: textValue(decision.next_action, "M4.8R1.decision.next_action"),
|
||||
},
|
||||
groundTruth: false,
|
||||
independentTruth: false,
|
||||
authority: authorityValue(payload.authority),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegressionCases(
|
||||
resultId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<readonly M48SmallStaticRegressionCaseSummary[]> {
|
||||
if (!RESULT_ID.test(resultId)) throw new M48SmallStaticRegressionContractError("M4.8R1 result identity недопустима.");
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}/cases`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 cases недоступны: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1 cases");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-case-catalog/v1", "M4.8R1 cases.schema_version");
|
||||
const cases = arrayValue(payload.cases, "M4.8R1 cases.items").map(parseSummary);
|
||||
if (integerValue(payload.case_count, "M4.8R1 cases.case_count") !== cases.length) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 cases: размер изменился.");
|
||||
}
|
||||
exact(payload.ground_truth, false, "M4.8R1 cases.ground_truth");
|
||||
authorityValue(payload.authority);
|
||||
return cases;
|
||||
}
|
||||
|
||||
export async function fetchM48SmallStaticRegressionCase(
|
||||
resultId: string,
|
||||
anchorId: string,
|
||||
{ fetcher = fetch, signal }: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<M48SmallStaticRegressionCase> {
|
||||
if (!RESULT_ID.test(resultId) || !ANCHOR_ID.test(anchorId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case identity недопустима.");
|
||||
}
|
||||
const response = await fetcher(`/api/v1/laboratory/m48/regressions/small-static/${encodeURIComponent(resultId)}/cases/${encodeURIComponent(anchorId)}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new M48SmallStaticRegressionContractError(`M4.8R1 case недоступен: HTTP ${response.status}.`);
|
||||
const payload = objectValue(await response.json(), "M4.8R1 case");
|
||||
exact(payload.schema_version, "missioncore.m48-small-static-passage-regression-case-view/v1", "M4.8R1 case.schema_version");
|
||||
const anchor = objectValue(payload.anchor, "M4.8R1 case.anchor");
|
||||
const comparison = objectValue(payload.comparison, "M4.8R1 case.comparison");
|
||||
const summary = parseSummary(comparison);
|
||||
const workerObjects = arrayValue(comparison.worker_objects, "M4.8R1 case.worker_objects").map((raw) => {
|
||||
const object = objectValue(raw, "M4.8R1 worker object");
|
||||
return {
|
||||
predictionId: textValue(object.prediction_id, "M4.8R1 worker object.prediction_id"),
|
||||
extentXyxy: extentValue(object.extent_xyxy, "M4.8R1 worker object.extent_xyxy"),
|
||||
geometryAssociation: enumValue(object.geometry_association, ["associated", "unavailable", "ineligible", "unknown"] as const, "M4.8R1 worker object.geometry_association"),
|
||||
freshness: enumValue(object.freshness, ["current", "held", "stale", "unavailable"] as const, "M4.8R1 worker object.freshness"),
|
||||
motion: enumValue(object.motion, ["moving", "static", "unknown", "unsupported"] as const, "M4.8R1 worker object.motion"),
|
||||
threat: enumValue(object.threat, ["threat", "not-threat", "unknown"] as const, "M4.8R1 worker object.threat"),
|
||||
};
|
||||
});
|
||||
const cameraUrl = payload.camera_url === null ? null : textValue(payload.camera_url, "M4.8R1 case.camera_url");
|
||||
const spatialUrl = payload.spatial_url === null ? null : textValue(payload.spatial_url, "M4.8R1 case.spatial_url");
|
||||
if ((cameraUrl && !SAFE_URL.test(cameraUrl)) || (spatialUrl && !SAFE_URL.test(spatialUrl))) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case evidence URL недопустим.");
|
||||
}
|
||||
exact(payload.ground_truth, false, "M4.8R1 case.ground_truth");
|
||||
const packId = textValue(payload.pack_id, "M4.8R1 case.pack_id");
|
||||
if (!PACK_ID.test(packId)) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case.pack_id: нарушена идентичность.");
|
||||
}
|
||||
if (textValue(anchor.anchor_id, "M4.8R1 case.anchor.anchor_id") !== anchorId) {
|
||||
throw new M48SmallStaticRegressionContractError("M4.8R1 case.anchor_id: ответ не соответствует запросу.");
|
||||
}
|
||||
return {
|
||||
resultId,
|
||||
packId,
|
||||
anchor: {
|
||||
anchorId: textValue(anchor.anchor_id, "M4.8R1 case.anchor.anchor_id"),
|
||||
clipId: textValue(anchor.clip_id, "M4.8R1 case.anchor.clip_id"),
|
||||
objectId: textValue(anchor.object_id, "M4.8R1 case.anchor.object_id"),
|
||||
sequence: integerValue(anchor.sequence, "M4.8R1 case.anchor.sequence"),
|
||||
extentXyxy: extentValue(anchor.extent_xyxy, "M4.8R1 case.anchor.extent_xyxy"),
|
||||
visibility: enumValue(anchor.visibility, ["visible", "partial", "occluded"] as const, "M4.8R1 case.anchor.visibility"),
|
||||
geometryAssociation: enumValue(anchor.geometry_association, ["associated", "unavailable", "ineligible", "unknown"] as const, "M4.8R1 case.anchor.geometry_association"),
|
||||
freshness: enumValue(anchor.freshness, ["current", "held", "stale", "unavailable"] as const, "M4.8R1 case.anchor.freshness"),
|
||||
motion: enumValue(anchor.motion, ["moving", "static", "unknown", "unsupported"] as const, "M4.8R1 case.anchor.motion"),
|
||||
threat: enumValue(anchor.threat, ["threat", "not-threat", "unknown"] as const, "M4.8R1 case.anchor.threat"),
|
||||
requiresAvoidanceOrClearance: booleanValue(anchor.requires_avoidance_or_clearance, "M4.8R1 case.anchor.requires_avoidance_or_clearance"),
|
||||
},
|
||||
comparison: {
|
||||
...summary,
|
||||
sourceTimeNs: integerValue(comparison.source_time_ns, "M4.8R1 case.comparison.source_time_ns"),
|
||||
anchorExtentXyxy: extentValue(comparison.anchor_extent_xyxy, "M4.8R1 case.comparison.anchor_extent_xyxy"),
|
||||
workerObjects,
|
||||
bestPredictionId: comparison.best_prediction_id === null ? null : textValue(comparison.best_prediction_id, "M4.8R1 case.comparison.best_prediction_id"),
|
||||
extentIouThreshold: numberValue(comparison.extent_iou_threshold, "M4.8R1 case.comparison.extent_iou_threshold"),
|
||||
},
|
||||
cameraUrl,
|
||||
spatialUrl,
|
||||
groundTruth: false,
|
||||
authority: authorityValue(payload.authority),
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
@import "./styles/shell.css";
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/laboratory-evidence-viewer.css";
|
||||
@import "./styles/laboratory-recorded-clip-player.css";
|
||||
@import "./styles/laboratory-review-workspace.css";
|
||||
@import "./styles/e40-case-review.css";
|
||||
@import "./styles/l3-pointpillars-visual-audit.css";
|
||||
@import "./styles/l34-annotation.css";
|
||||
@@ -10,6 +13,7 @@
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
@import "./styles/m48-object-centric-quality.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"] {
|
||||
display: grid;
|
||||
box-sizing: border-box;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header-context {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
position: static;
|
||||
z-index: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__stage {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__transport {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
.laboratory-recorded-clip-player {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__stage,
|
||||
.laboratory-recorded-clip-player__split,
|
||||
.laboratory-recorded-clip-player__spatial,
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__stage {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split,
|
||||
.laboratory-recorded-clip-player__split > .nodedc-split-pane__panel,
|
||||
.laboratory-recorded-clip-player__spatial,
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__camera {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator::before {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator:hover::before,
|
||||
.laboratory-recorded-clip-player__split
|
||||
> .nodedc-split-pane__separator:focus-visible::before,
|
||||
.laboratory-recorded-clip-player__split[data-dragging="true"]
|
||||
> .nodedc-split-pane__separator::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
box-shadow: 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player__camera > .recorded-media-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline.observation-timeline {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
padding: 0.45rem 0.7rem;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto auto auto minmax(12rem, 1fr) auto;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player {
|
||||
gap: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player__stage,
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-recorded-clip-player > .observation-timeline {
|
||||
box-sizing: border-box;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.laboratory-recorded-clip-player
|
||||
> .laboratory-recorded-clip-player__timeline
|
||||
.observation-timeline__playback {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
.laboratory-summary > header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.laboratory-summary__heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--nodedc-space-3);
|
||||
}
|
||||
|
||||
.laboratory-summary__toggle-glyph {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
transition: transform var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.laboratory-summary[data-expanded="true"] .laboratory-summary__toggle-glyph {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.laboratory-summary__details[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.laboratory-summary .laboratory-summary__brief {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
.laboratory-review-workspace {
|
||||
position: fixed;
|
||||
z-index: var(--nodedc-layer-overlay);
|
||||
inset: 0;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
background: var(--nodedc-canvas);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-review-workspace[data-has-inspector="true"] {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.laboratory-review-workspace__toolbar,
|
||||
.laboratory-review-workspace__inspector {
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.55rem 0.7rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.laboratory-review-workspace__stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-review-workspace__toolbar,
|
||||
.laboratory-review-workspace__inspector {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,14 @@
|
||||
.laboratory-summary > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary > header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-summary p,
|
||||
.laboratory-summary dl,
|
||||
@@ -115,10 +118,10 @@
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-summary > header p,
|
||||
.laboratory-summary__description,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
margin-top: 0.65rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
.m48-review-workspace__header {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--nodedc-space-3);
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar,
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__topbar-end,
|
||||
.m48-review-workspace__clip-navigation,
|
||||
.m48-review-workspace__source-heading,
|
||||
.m48-review-workspace__evidence-summary,
|
||||
.m48-review-workspace__object-tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__topbar-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-navigation {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-field {
|
||||
width: clamp(15rem, 19vw, 19rem);
|
||||
flex: 0 1 19rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-end {
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-end;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-reviewed {
|
||||
width: clamp(14rem, 16vw, 20rem);
|
||||
flex: 0 1 clamp(14rem, 16vw, 20rem);
|
||||
}
|
||||
|
||||
.m48-review-workspace__state,
|
||||
.m48-atlas-visual__state,
|
||||
.m48-clip-player__state {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.m48-review-workspace__evidence-summary {
|
||||
flex-wrap: wrap;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-tools {
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-field {
|
||||
width: 8rem;
|
||||
min-width: 7.25rem;
|
||||
max-width: 9.25rem;
|
||||
flex: 1 1 8rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__object-field--wide {
|
||||
width: 11.5rem;
|
||||
min-width: 10.5rem;
|
||||
max-width: 13rem;
|
||||
flex-basis: 11.5rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__passage-field {
|
||||
width: clamp(15rem, 18vw, 18rem);
|
||||
min-width: 15rem;
|
||||
flex: 1 1 15rem;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__passage-field > .nodedc-checker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m48-review-workspace__stage-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-stage > .laboratory-recorded-clip-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-evidence-mode-rail {
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 50%;
|
||||
left: var(--nodedc-space-4);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 3.15rem;
|
||||
left: 0.75rem;
|
||||
display: grid;
|
||||
width: min(31rem, calc(100% - 1.5rem));
|
||||
gap: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__source-sticker strong {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.m48-review-workspace__freeze-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m48-clip-player__spatial-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-clip-player__spatial-pane > .laboratory-metric-evidence-scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 0.6rem;
|
||||
border: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.38rem 0.52rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.52rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label[data-pane="spatial"] {
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__pane-label[data-pane="camera"] {
|
||||
right: 0.6rem;
|
||||
}
|
||||
|
||||
.laboratory-recorded-clip-player[data-camera-presentation="companion"]
|
||||
.m48-clip-player__pane-label[data-pane="camera"] {
|
||||
top: 4.35rem;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay[data-drawing="true"] {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay g rect,
|
||||
.m48-clip-player__draft-box {
|
||||
fill: transparent;
|
||||
stroke: rgb(var(--nodedc-accent-rgb));
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay g[data-selected="true"] rect {
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay:not([data-drawing="true"]) g rect {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle {
|
||||
fill: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: var(--nodedc-canvas);
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle[data-handle="nw"],
|
||||
.m48-clip-player__resize-handle[data-handle="se"] {
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.m48-clip-player__resize-handle[data-handle="ne"],
|
||||
.m48-clip-player__resize-handle[data-handle="sw"] {
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.m48-clip-player__draft-box {
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
|
||||
.m48-clip-player__overlay text {
|
||||
fill: var(--nodedc-text-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
paint-order: stroke;
|
||||
stroke: var(--nodedc-canvas);
|
||||
stroke-width: 3;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m48-evidence-mode-controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: var(--nodedc-space-2);
|
||||
}
|
||||
|
||||
.m48-evidence-mode-controls__text {
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
font-weight: var(--nodedc-font-weight-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene > img,
|
||||
.m48-atlas-visual__scene > canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__scene > img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case {
|
||||
display: grid;
|
||||
max-width: min(34rem, 65%);
|
||||
gap: 0.15rem;
|
||||
border-radius: var(--nodedc-radius-control);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.55rem 0.7rem;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header-context
|
||||
.m48-atlas-visual__case {
|
||||
max-width: 38rem;
|
||||
background: transparent;
|
||||
padding: 0 0.15rem;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case strong {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.m48-atlas-visual__case small {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.52rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.m48-review-workspace__topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-end {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-field {
|
||||
width: min(100%, 19rem);
|
||||
}
|
||||
|
||||
.m48-review-workspace__topbar-start,
|
||||
.m48-review-workspace__object-tools {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.m48-review-workspace__clip-reviewed,
|
||||
.m48-review-workspace__passage-field {
|
||||
width: min(100%, 28rem);
|
||||
flex-basis: min(100%, 28rem);
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m48-atlas-visual[data-chrome-layout="stacked"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -400,10 +400,17 @@ i[data-availability="error"] {
|
||||
}
|
||||
|
||||
.observation-timeline__playback > .nodedc-select-anchor {
|
||||
width: 7.5rem;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.observation-timeline__transport {
|
||||
width: var(--nodedc-control-height-compact);
|
||||
padding: 0;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
}
|
||||
|
||||
.observation-timeline__accumulation {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
||||
@@ -42,6 +42,8 @@ import { E46JRawFisheyeRealtimeResultView } from "./E46JRawFisheyeRealtimeResult
|
||||
import { E47SemanticSlamResultView } from "./E47SemanticSlamResult";
|
||||
import { M4ReplayThreatResultView } from "./M4ReplayThreatResult";
|
||||
import { M47ReferenceGraphResultView } from "./M47ReferenceGraphResult";
|
||||
import { M48ObjectCentricQualityResultView } from "./M48ObjectCentricQualityResult";
|
||||
import { M48SmallStaticPassageRegressionResultView } from "./M48SmallStaticPassageRegressionResult";
|
||||
|
||||
export { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
@@ -84,6 +86,12 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "m48-object-centric-quality" && results.m48) {
|
||||
return <M48ObjectCentricQualityResultView rigLabel={rigLabel} result={results.m48} />;
|
||||
}
|
||||
if (workId === "m48-small-static-passage-regression" && results.m48SmallStatic) {
|
||||
return <M48SmallStaticPassageRegressionResultView rigLabel={rigLabel} result={results.m48SmallStatic} />;
|
||||
}
|
||||
if (workId === "m47-reference-graph-shadow" && results.m47Graph) {
|
||||
return <M47ReferenceGraphResultView rigLabel={rigLabel} result={results.m47Graph} />;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex";
|
||||
import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport";
|
||||
import { useLaboratoryViewMode } from "./useLaboratoryViewMode";
|
||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||
import { useM48ReviewCapability } from "./annotation/useM48ReviewCapability";
|
||||
import {
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
@@ -526,6 +527,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
});
|
||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
const m48Review = useM48ReviewCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", initialGate: advancedResults.m48?.kind === "review" ? advancedResults.m48 : null, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
@@ -825,7 +827,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{!m48Review.active ? <div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
@@ -947,8 +949,9 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{annotationWorkspace}
|
||||
{m48Review.workspace}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "../../components/laboratory/RecordedEvidenceBoxOverlay";
|
||||
import {
|
||||
fetchM48FailureAtlas,
|
||||
fetchM48FailureCase,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48FailureCase,
|
||||
type M48FailureCaseSummary,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
|
||||
const ATLAS_MODES = [
|
||||
{ value: "source", label: "SOURCE" },
|
||||
{ value: "truth", label: "TRUTH" },
|
||||
{ value: "graph", label: "GRAPH" },
|
||||
{ value: "overlay", label: "OVERLAY" },
|
||||
] as const;
|
||||
type AtlasMode = typeof ATLAS_MODES[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 evidence недоступно.";
|
||||
}
|
||||
|
||||
function FailureAtlasScene({ item, mode }: { item: M48FailureCase; mode: AtlasMode }) {
|
||||
const [size, setSize] = useState({ width: 1440, height: 1080 });
|
||||
const boxes = useMemo<RecordedEvidenceBox[]>(() => {
|
||||
const truth = mode === "truth" || mode === "overlay"
|
||||
? item.truth.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `truth · ${object.objectId}`,
|
||||
tone: "success" as const,
|
||||
}))
|
||||
: [];
|
||||
const graph = mode === "graph" || mode === "overlay"
|
||||
? item.graph.map((object) => ({
|
||||
boxXyxy: [object.extentXyxy[0] * size.width, object.extentXyxy[1] * size.height, object.extentXyxy[2] * size.width, object.extentXyxy[3] * size.height] as const,
|
||||
label: `graph · ${object.objectId}`,
|
||||
tone: "danger" as const,
|
||||
dashed: mode === "overlay",
|
||||
}))
|
||||
: [];
|
||||
return [...truth, ...graph];
|
||||
}, [item.graph, item.truth, mode, size.height, size.width]);
|
||||
|
||||
if (!item.frame.cameraUrl) {
|
||||
return <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Точный camera-кадр failure case недоступен.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="m48-atlas-visual__scene">
|
||||
<img
|
||||
src={item.frame.cameraUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={(event) => setSize({
|
||||
width: event.currentTarget.naturalWidth || 1440,
|
||||
height: event.currentTarget.naturalHeight || 1080,
|
||||
})}
|
||||
/>
|
||||
<RecordedEvidenceBoxOverlay imageWidth={size.width} imageHeight={size.height} boxes={boxes} ariaLabel="M4.8 truth/graph failure overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48FailureAtlasVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48FailureCaseSummary[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48FailureCase | null>(null);
|
||||
const [mode, setMode] = useState<AtlasMode>("overlay");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureAtlas(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[index];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48FailureCase(resultId, selected.caseId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setItem(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [cases, index, resultId]);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 failure atlas"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={ATLAS_MODES}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<>
|
||||
<IconButton label="Предыдущий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current - 1 + cases.length) % cases.length)}><Icon name="chevron-left" size={16} /></IconButton>
|
||||
<IconButton label="Следующий failure case" disabled={!cases.length} onClick={() => setIndex((current) => (current + 1) % cases.length)}><Icon name="chevron-right" size={16} /></IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? <div className="m48-atlas-visual__case"><StatusBadge tone={item.split === "development" ? "neutral" : item.severity === "critical" ? "danger" : "warning"}>{item.split.toUpperCase()} · {item.severity}</StatusBadge><strong>{item.clipId} · frame {item.sequence}</strong><small>{item.split === "development" ? "Diagnostic only · " : "Validation acceptance evidence · "}{item.failures.join(" · ")}</small></div> : null}
|
||||
>
|
||||
{loading ? <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем bounded failure case</div>
|
||||
: error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: item ? <FailureAtlasScene item={item} mode={mode} />
|
||||
: <div className="m48-atlas-visual__state" role="status"><Icon name="check" size={18} />Failure atlas пуст: ни один bounded failure case не зафиксирован.</div>}
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ReviewPackVisual({ packId }: { packId: string }) {
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [clipIndex, setClipIndex] = useState(0);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchM48ReviewSourceCatalog(packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
if (next.clips[0]) setSequence(next.clips[0].startSequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [packId]);
|
||||
|
||||
const clip = catalog?.clips[clipIndex] ?? null;
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8 neutral review pack"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={<><IconButton label="Предыдущий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex - 1 + catalog.clips.length) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-left" size={16} /></IconButton><IconButton label="Следующий клип" disabled={!catalog} onClick={() => {
|
||||
if (!catalog) return;
|
||||
const next = (clipIndex + 1) % catalog.clips.length;
|
||||
setClipIndex(next);
|
||||
setSequence(catalog.clips[next]!.startSequence);
|
||||
}}><Icon name="chevron-right" size={16} /></IconButton></>}
|
||||
overlay={clip ? <div className="m48-atlas-visual__case"><StatusBadge tone="neutral">SOURCE ONLY</StatusBadge><strong>{clip.ordinal}/{catalog?.clipCount} · {clip.clipId}</strong><small>0 classes · 0 candidate identity · 0 model predictions</small></div> : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{error ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
: clip && catalog?.cameraPlayback ? <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={mode} cameraVisible={cameraVisible} tracklets={[]} selectedObjectId={null} editable={false} drawing={false} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={() => undefined} onSelectedObjectIdChange={() => undefined} onTrackletsChange={() => undefined} />
|
||||
: catalog ? <div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />Pack-bound camera playback недоступен.</div>
|
||||
: <div className="m48-atlas-visual__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only clip pack</div>}
|
||||
{catalog ? <M48EvidenceModeRail mode={mode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
M48AdvancedResult,
|
||||
M48GateStatus,
|
||||
M48QualityResult,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { M48FailureAtlasVisual, M48ReviewPackVisual } from "./M48FailureAtlasVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
function reviewStatus(result: M48GateStatus): { text: string; tone: "neutral" | "warning" | "success" } {
|
||||
if (result.evaluated) return { text: "Evaluation завершена · откройте финальный M4.8 result", tone: "success" };
|
||||
if (result.correctionState === "frozen") return { text: "24/24 клипов проверено · assisted evidence Worker 006 зафиксировано", tone: "success" };
|
||||
if (result.correctionState !== "not-started") return { text: `${result.correctionReviewedClipCount}/${result.clipCount} клипов проверено · исправляем авторазметку Worker 006`, tone: "warning" };
|
||||
if (result.adjudicationFrozen) return { text: "Truth seal зафиксирован · готово к evaluation", tone: "success" };
|
||||
if (result.adjudicationUnlocked) return { text: "2/2 независимых review · открыта adjudication", tone: "warning" };
|
||||
if (result.frozenReviewerCount > 0) return { text: `${result.frozenReviewerCount}/2 независимых review зафиксировано · quality verdict ещё отсутствует`, tone: "warning" };
|
||||
return { text: "Проверочный набор готов · корректность object graph ещё не измерена", tone: "warning" };
|
||||
}
|
||||
|
||||
function ReviewResult({ rigLabel, result }: { rigLabel: string; result: M48GateStatus }) {
|
||||
const status = reviewStatus(result);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · проверяем, видит ли система реальные объекты"
|
||||
description="Worker 006 уже поставил рамки на связанных кадрах RIGHT-камеры. M4.8 показывает его ответ поверх синхронных CAMERA + LiDAR: оператор подтверждает правильные рамки и исправляет только false positive, miss и неточную геометрию."
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Покрытие", value: `${result.clipCount} клипов · ${result.frameCount} кадров` },
|
||||
{ label: "Авторазметка", value: `Worker 006 · ${result.seedObjectCount.toLocaleString("ru-RU")} frozen-рамок` },
|
||||
{ label: "Correction", value: `${result.correctionReviewedClipCount}/${result.clipCount} клипов · ${result.correctionState}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Не пропускает ли система реальный объект, не придумывает ли лишний, не теряет ли его между кадрами и правильно ли понимает геометрию, свежесть, движение и опасность в коридоре движения?",
|
||||
approach: "RIGHT-камера, рамки Worker 006 и синхронный LiDAR идут на одном таймлайне. На каждом из 24 клипов оператор удаляет лишнее, добавляет пропущенное, двигает или ресайзит неточную рамку и подтверждает клип.",
|
||||
principalResult: result.correctionState === "frozen"
|
||||
? "Проверка зафиксирована: исходный seed и все ручные изменения связаны одной immutable дельтой."
|
||||
: `Готовы ${result.seedObjectCount.toLocaleString("ru-RU")} автоматических рамок на ${result.frameCount} кадрах; ручная работа начинается с результата системы, а не с пустого кадра.`,
|
||||
limitation: "Correction выполнен с видимым ответом Worker 006 и потому не является independent ground truth. Отдельный двухрецензентный truth seal всё ещё нужен для формального quality gate; physical live, навигация и команды моторам запрещены.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-class-free-object-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable connected clips", role: "camera/current-spatial evidence + frozen Worker 006 seed", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "frozen-candidate-seeded correction", version: "object-tracklet/v1", role: "editable assisted evidence without semantic classes", identitySha256: null },
|
||||
{ kind: "algorithm", name: "independent dual review + adjudication", version: "release-gate/v1", role: "separate formal truth-seal workflow", identitySha256: result.truthSealId?.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · CAMERA + 3D" title="Один объект в RIGHT-камере и LiDAR на общем времени" kind="recorded-replay" resizable>
|
||||
<M48ReviewPackVisual packId={result.packId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: Worker 006 поставил рамки; оператор проверяет только ошибки"
|
||||
status={status.text}
|
||||
statusTone={status.tone}
|
||||
metrics={[
|
||||
{ label: "Worker boxes", value: result.seedObjectCount.toLocaleString("ru-RU"), hint: `${result.frameCount} кадров · immutable seed` },
|
||||
{ label: "Clips checked", value: `${result.correctionReviewedClipCount}/${result.clipCount}`, hint: "operator correction progress" },
|
||||
{ label: "Assisted evidence", value: result.correctionState === "frozen" ? "FROZEN" : "OPEN", hint: "candidate-visible · not independent truth" },
|
||||
{ label: "Authority", value: "OFF", hint: "physical live · commands · actuation" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 воспроизводимо выдал ${result.seedObjectCount.toLocaleString("ru-RU")} рамок; CAMERA, 3D/PLAN и рамки связаны одним временем. После freeze будет сохранена точная дельта подтверждений, исправлений, false positive и miss.`,
|
||||
notProved: "Пока оператор не проверил 24/24 клипа, корректность этих рамок не подтверждена. Даже завершённый assisted correction не заменяет независимую ground truth и не даёт motor/planner authority.",
|
||||
decision: result.correctionState === "frozen" ? "Assisted regression evidence закрыто; для формального release gate отдельно выполнить два blind review и adjudication." : "Открыть проверку Worker 006, пройти все 24 клипа, исправить ошибки и зафиксировать evidence.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityResult({ rigLabel, result }: { rigLabel: string; result: M48QualityResult }) {
|
||||
const gateCount = Object.keys(result.gates).length;
|
||||
const passedGates = Object.values(result.gates).filter(Boolean).length;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8 · object-centric source quality gate"
|
||||
description="Frozen M4.7 graph сопоставлен с adjudicated class-free truth. Acceptance считается только на sealed validation split; development остаётся диагностическим и не может улучшить gate."
|
||||
status={result.accepted ? "Object-centric source quality принята" : "Quality gate не пройден · открыт bounded failure atlas"}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · frozen graph vs adjudicated truth` },
|
||||
{ label: "Pack", value: result.packId },
|
||||
{ label: "Truth seal", value: result.truthSealId },
|
||||
{ label: "Acceptance", value: "VALIDATION ONLY · development informational" },
|
||||
{ label: "Authority", value: "SOURCE-SCOPED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Проходит ли canonical graph минимальный object-centric quality gate на sealed validation split RAVNOVES00 source?",
|
||||
approach: "Per-frame class-free matching выполняется только после двух независимых reviews и adjudication. Gate берёт metrics только из validation; каждый отказ связан с конкретными клипами и кадрами в failure atlas.",
|
||||
principalResult: `${passedGates}/${gateCount} validation gates passed · presence P/R ${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)} · ${result.metrics.failureCaseCount} failure cases.`,
|
||||
limitation: "Результат ограничен recorded source и не доказывает realtime live, физическую collision safety, planning или motor control.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: "m48-object-centric-quality/v1",
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "frozen-before-label-reveal", role: "candidate graph and connected source clips", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: result.truthSealId, version: "two reviewers + adjudication", role: "class-free object truth", identitySha256: result.truthSealId.split("-").at(-1) ?? null },
|
||||
{ kind: "algorithm", name: "object-centric quality scorer", version: "v1", role: "per-frame matching, gates and failure atlas", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8 VISUAL EVIDENCE · FAILURE ATLAS" title="Точные bounded cases: SOURCE / TRUTH / GRAPH / OVERLAY" kind="diagnostic-model" resizable>
|
||||
<M48FailureAtlasVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title={result.accepted ? "Object-centric gate принят; можно готовить recorded realtime release candidate" : "Gate отклонён; исправления привязаны к bounded failure clusters"}
|
||||
status={`${passedGates}/${gateCount} validation gates · ${result.metrics.failureCaseCount} failure cases · ${result.metrics.unknownPredictionCount} unknown predictions`}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Presence P / R", value: `${percent(result.metrics.obstaclePresencePrecision)} / ${percent(result.metrics.obstaclePresenceRecall)}`, hint: "class-free object presence" },
|
||||
{ label: "Critical recall", value: percent(result.metrics.criticalCorridorObstacleRecall), hint: "objects intersecting the virtual corridor" },
|
||||
{ label: "Geometry / freshness", value: `${percent(result.metrics.geometryAssociationCorrectness)} / ${percent(result.metrics.freshnessCorrectness)}`, hint: "adjudicated state correctness" },
|
||||
{ label: "Motion / false not-threat", value: `${percent(result.metrics.motionDecisionCorrectness)} / ${result.metrics.criticalNotThreatCount}`, hint: "critical corridor violations · conservative unknown retained" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На sealed validation split граф прошёл ${passedGates}/${gateCount} object-centric gates; development metrics не участвовали в acceptance, каждый отказ и unknown имеет frame-level cause.`,
|
||||
notProved: "Не доказаны physical live, измеренная collision safety, navigation planner, motor commands или переносимость на другие маршруты.",
|
||||
decision: result.accepted ? "Открыть M4.9 recorded realtime release-candidate gate без расширения authority." : "Исправлять только кластеры из failure atlas, повторно заморозить candidate до label reveal и пересчитать M4.8.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48ObjectCentricQualityResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48AdvancedResult;
|
||||
}) {
|
||||
return result.kind === "review"
|
||||
? <ReviewResult rigLabel={rigLabel} result={result} />
|
||||
: <QualityResult rigLabel={rigLabel} result={result} />;
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { M48SmallStaticRegressionResult } from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import { M48SmallStaticRegressionVisual } from "./M48SmallStaticRegressionVisual";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
|
||||
}
|
||||
|
||||
export function M48SmallStaticPassageRegressionResultView({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: M48SmallStaticRegressionResult;
|
||||
}) {
|
||||
const status = result.accepted
|
||||
? "Development regression target пройден"
|
||||
: `Worker 006 пропустил ${result.metrics.workerMissedAnchorCount}/${result.metrics.assistedAnchorCount} assisted-якорей`;
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="M4.8R1 · мелкие статические ограничения проезда"
|
||||
description="Отдельный append-only прогон внутри M4.8 проверяет, покрывает ли замороженный Worker 006 вручную добавленные столбики, полусферы, урны и другие малые статические ограничения. Текущий M4.8 correction не изменяется."
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} RIGHT · camera + prediction-free current spatial evidence` },
|
||||
{ label: "Пайплайн", value: result.pipelineId },
|
||||
{ label: "Эксперимент", value: result.experimentId },
|
||||
{ label: "Прогон", value: `${result.runLabel} · immutable ${result.resultId}` },
|
||||
{ label: "Authority", value: "REPLAY-SIMULATED · commands OFF · actuation OFF" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Находит ли текущий Worker 006 малые статические ограничения прохода, которые оператору пришлось добавить вручную при assisted correction?",
|
||||
approach: `Зафиксирован отдельный снимок ${result.metrics.assistedAnchorCount} operator-added якорей на ${result.metrics.anchorClipCount} клипах. На точном исходном кадре каждый якорь сопоставлен с frozen-ответом Worker 006 по IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}.`,
|
||||
principalResult: `${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} якорей покрыты Worker 006; ${result.metrics.workerMissedAnchorCount} не имеют совпадающей frozen-рамки.`,
|
||||
limitation: "Это candidate-visible assisted seed, намеренно собранный из ручных добавлений, поэтому он полезен как regression baseline, но не является independent ground truth. Camera bbox не является 3D-коллайдером и не выдаёт planner/safety authority.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: result.pipelineId,
|
||||
components: [
|
||||
{ kind: "source", name: result.packId, version: "immutable Worker 006 pack", role: "frozen candidate output + exact camera/current-spatial evidence", identitySha256: result.packId.split("-").at(-1) ?? null },
|
||||
{ kind: "source", name: "M4.8 assisted correction snapshot", version: "revision-bound", role: "operator-added anchors · not independent truth", identitySha256: null },
|
||||
{ kind: "algorithm", name: "small-static assisted-anchor comparator", version: "v1", role: "exact-frame class-free IoU regression", identitySha256: result.resultId.split("-").at(-1) ?? null },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence eyebrow="M4.8R1 VISUAL EVIDENCE · ASSISTED ANCHOR + WORKER" title="Точный кадр: ручной якорь и frozen-ответ Worker 006" kind="recorded-replay" resizable>
|
||||
<M48SmallStaticRegressionVisual resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Что дал прогон: зафиксирован измеримый baseline пропусков малых ограничений"
|
||||
status={status}
|
||||
statusTone={result.accepted ? "success" : "warning"}
|
||||
metrics={[
|
||||
{ label: "Assisted recall", value: percent(result.metrics.assistedAnchorRecall), hint: `target ${percent(result.metrics.minimumAssistedAnchorRecall)} · diagnostic only` },
|
||||
{ label: "Recalled / missed", value: `${result.metrics.workerRecalledAnchorCount} / ${result.metrics.workerMissedAnchorCount}`, hint: `IoU ≥ ${result.metrics.extentIouThreshold.toFixed(2)}` },
|
||||
{ label: "Объезд или запас", value: result.metrics.requiresAvoidanceOrClearanceCount.toLocaleString("ru-RU"), hint: "operator-marked small static constraints" },
|
||||
{ label: "Independent truth", value: "НЕТ", hint: "assisted candidate-visible evidence" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `Worker 006 детерминированно покрывает ${result.metrics.workerRecalledAnchorCount}/${result.metrics.assistedAnchorCount} вручную добавленных якорей на точных кадрах; результат сохранён отдельно и не перезаписывает correction-сессию.`,
|
||||
notProved: "Не измерены unbiased precision/recall, 3D clearance, проезжаемость конкретного шасси, realtime live или collision safety.",
|
||||
decision: result.accepted
|
||||
? "Сохранить прогон как development baseline и отдельно открыть independent truth evaluation."
|
||||
: "Использовать пропуски как bounded regression set для следующей версии Worker 006; после обновления выполнить новый append-only run на том же снимке и только затем — independent truth gate.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Icon, IconButton, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
fetchM48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../core/laboratory/m48ObjectCentricQuality";
|
||||
import {
|
||||
fetchM48SmallStaticRegressionCase,
|
||||
fetchM48SmallStaticRegressionCases,
|
||||
type M48SmallStaticRegressionCase,
|
||||
type M48SmallStaticRegressionCaseSummary,
|
||||
} from "../../core/laboratory/m48SmallStaticRegression";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./annotation/M48BlindClipPlayer";
|
||||
import { M48EvidenceModeRail } from "./annotation/M48EvidenceModeControls";
|
||||
import { useM48SpatialClipPlayback } from "./annotation/useM48SpatialClipPlayback";
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "M4.8R1 evidence недоступно.";
|
||||
}
|
||||
|
||||
function exactFrameTracklets(item: M48SmallStaticRegressionCase): readonly M48ReviewTracklet[] {
|
||||
const sequence = item.anchor.sequence;
|
||||
const state = (
|
||||
objectId: string,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
geometryAssociation: M48ReviewTracklet["stateSegments"][number]["geometryAssociation"],
|
||||
freshness: M48ReviewTracklet["stateSegments"][number]["freshness"],
|
||||
motion: M48ReviewTracklet["stateSegments"][number]["motion"],
|
||||
threat: M48ReviewTracklet["stateSegments"][number]["threat"],
|
||||
criticalCorridorObstacle: boolean,
|
||||
): M48ReviewTracklet => ({
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation,
|
||||
freshness,
|
||||
motion,
|
||||
threat,
|
||||
criticalCorridorObstacle,
|
||||
}],
|
||||
notes: null,
|
||||
});
|
||||
|
||||
return [
|
||||
state(
|
||||
`ASSISTED · ${item.anchor.objectId}`,
|
||||
item.anchor.extentXyxy,
|
||||
item.anchor.geometryAssociation,
|
||||
item.anchor.freshness,
|
||||
item.anchor.motion,
|
||||
item.anchor.threat,
|
||||
item.anchor.requiresAvoidanceOrClearance,
|
||||
),
|
||||
...item.comparison.workerObjects.map((object) => state(
|
||||
`WORKER · ${object.predictionId}`,
|
||||
object.extentXyxy,
|
||||
object.geometryAssociation,
|
||||
object.freshness,
|
||||
object.motion,
|
||||
object.threat,
|
||||
false,
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
export function M48SmallStaticRegressionVisual({ resultId }: { resultId: string }) {
|
||||
const [cases, setCases] = useState<readonly M48SmallStaticRegressionCaseSummary[]>([]);
|
||||
const [caseIndex, setCaseIndex] = useState(0);
|
||||
const [item, setItem] = useState<M48SmallStaticRegressionCase | null>(null);
|
||||
const [catalog, setCatalog] = useState<Awaited<ReturnType<typeof fetchM48ReviewSourceCatalog>> | null>(null);
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCases(resultId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (!controller.signal.aborted) setCases(next);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const selected = cases[caseIndex];
|
||||
if (!selected) {
|
||||
setItem(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void fetchM48SmallStaticRegressionCase(resultId, selected.anchorId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItem(next);
|
||||
setSequence(next.anchor.sequence);
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [caseIndex, cases, resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return;
|
||||
const controller = new AbortController();
|
||||
setCatalog(null);
|
||||
void fetchM48ReviewSourceCatalog(item.packId, { signal: controller.signal })
|
||||
.then((next) => !controller.signal.aborted && setCatalog(next))
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)));
|
||||
return () => controller.abort();
|
||||
}, [item?.packId]);
|
||||
|
||||
const clip = item && catalog
|
||||
? catalog.clips.find((candidate) => candidate.clipId === item.anchor.clipId) ?? null
|
||||
: null;
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const spatial = useM48SpatialClipPlayback({
|
||||
packId: item?.packId ?? "",
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
const tracklets = useMemo(
|
||||
() => item ? exactFrameTracklets(item) : [],
|
||||
[item],
|
||||
);
|
||||
|
||||
return (
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.8R1 assisted-anchor regression"
|
||||
className="m48-atlas-visual"
|
||||
mode={mode}
|
||||
modes={[]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
modeControlsVisible={false}
|
||||
chromeLayout="stacked"
|
||||
actions={(
|
||||
<>
|
||||
<IconButton
|
||||
label="Предыдущий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current - 1 + cases.length) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий assisted-якорь"
|
||||
disabled={!cases.length}
|
||||
onClick={() => setCaseIndex((current) => (current + 1) % cases.length)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
overlay={item ? (
|
||||
<div className="m48-atlas-visual__case">
|
||||
<StatusBadge tone={item.comparison.matchedAtThreshold ? "success" : "warning"}>
|
||||
{item.comparison.matchedAtThreshold ? "WORKER RECALL" : "WORKER MISS"}
|
||||
</StatusBadge>
|
||||
<strong>{caseIndex + 1}/{cases.length} · {item.anchor.clipId} · кадр {item.anchor.sequence}</strong>
|
||||
<small>ASSISTED-якорь, не independent truth · best IoU {item.comparison.bestIou.toFixed(3)}</small>
|
||||
</div>
|
||||
) : null}
|
||||
>
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
Загружаем M4.8R1 bounded case
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="m48-atlas-visual__state" role="alert"><Icon name="alert" size={18} />{error}</div>
|
||||
) : clip && catalog?.cameraPlayback && item ? (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={tracklets}
|
||||
selectedObjectId={`ASSISTED · ${item.anchor.objectId}`}
|
||||
editable={false}
|
||||
drawing={false}
|
||||
spatialFrame={spatial.frame}
|
||||
spatialLoading={spatial.loading}
|
||||
spatialError={spatial.error}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={() => undefined}
|
||||
onSelectedObjectIdChange={() => undefined}
|
||||
onTrackletsChange={() => undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-atlas-visual__state" role="status">
|
||||
<Icon name="alert" size={18} />Точный источник M4.8R1 недоступен.
|
||||
</div>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</LaboratoryEvidenceViewer>
|
||||
);
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48AdjudicationSession,
|
||||
evaluateM48Adjudication,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48AdjudicationSession,
|
||||
saveM48AdjudicationSession,
|
||||
type M48AdjudicationSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import { M48BlindClipPlayer, type M48BlindEvidenceMode } from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
const REVIEW_LAYERS = [
|
||||
{ value: "reviewer-a", label: "REVIEWER A" },
|
||||
{ value: "reviewer-b", label: "REVIEWER B" },
|
||||
{ value: "decision", label: "РЕШЕНИЕ" },
|
||||
] as const;
|
||||
type ReviewLayer = typeof REVIEW_LAYERS[number]["value"];
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim() ? error.message : "M4.8 adjudication не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const storageKey = `missioncore:m48:${packId}:adjudication-operation-key`;
|
||||
const current = localStorage.getItem(storageKey);
|
||||
if (current) return current;
|
||||
const created = `adjudication-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(storageKey, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function decisionCopy(clip: M48ReviewClipDraft): M48ReviewClipDraft {
|
||||
return { ...clip, reviewState: "adjudicated", tracklets: clip.tracklets.map((tracklet) => ({ ...tracklet, keyframes: tracklet.keyframes.map((keyframe) => ({ ...keyframe })), stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment })) })) };
|
||||
}
|
||||
|
||||
export function M48AdjudicationWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48AdjudicationSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [clipId, setClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [reviewLayer, setReviewLayer] = useState<ReviewLayer>("reviewer-a");
|
||||
const [evidenceMode, setEvidenceMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [adjudicatorId, setAdjudicatorId] = useState("");
|
||||
const [resolvedAttested, setResolvedAttested] = useState(false);
|
||||
const [blindAttested, setBlindAttested] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => !controller.signal.aborted && setError(message(caught)))
|
||||
.finally(() => !controller.signal.aborted && setLoading(false));
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(() => catalog?.clips.find((item) => item.clipId === clipId) ?? null, [catalog, clipId]);
|
||||
const reviewerA = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 1)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const reviewerB = session?.reviewInputs.find(({ reviewerSlot }) => reviewerSlot === 2)?.clips.find((item) => item.clipId === clipId) ?? null;
|
||||
const decision = drafts.get(clipId) ?? null;
|
||||
const visibleDraft = reviewLayer === "reviewer-a" ? reviewerA : reviewLayer === "reviewer-b" ? reviewerB : decision;
|
||||
const selectedDecisionTracklet = decision?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
const editable = Boolean(session && reviewLayer === "decision" && !["adjudication-frozen", "evaluated"].includes(session.state));
|
||||
const spatialEnabled = Boolean(catalog?.evidenceCapabilities.currentPointCloudBodyXyzM && catalog.evidenceCapabilities.rig && catalog.evidenceCapabilities.virtualCorridor);
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: evidenceMode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const createSession = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await createM48AdjudicationSession(gate.packId, operationKey(gate.packId));
|
||||
setSession(next);
|
||||
setDrafts(new Map(next.clips.map((item) => [item.clipId, item])));
|
||||
notify({ tone: "success", title: "Adjudication открыта", description: "Reviewer A/B видны без model predictions." });
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setDecision = (next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => new Map(current).set(next.clipId, next));
|
||||
setDirty(true);
|
||||
setReviewLayer("decision");
|
||||
};
|
||||
|
||||
const updateDecisionTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!decision || !selectedDecisionTracklet) return;
|
||||
setDecision({
|
||||
...decision,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: decision.tracklets.map((tracklet) => tracklet.objectId === selectedDecisionTracklet.objectId
|
||||
? { ...tracklet, stateSegments: tracklet.stateSegments.map((segment) => ({ ...segment, ...patch })) }
|
||||
: tracklet),
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!session) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const clips = session.clips.map((item) => drafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48AdjudicationSession(session, session.title, clips, `save-${session.revision + 1}-${crypto.randomUUID()}`);
|
||||
setSession(saved);
|
||||
setDrafts(new Map(saved.clips.map((item) => [item.clipId, item])));
|
||||
setDirty(false);
|
||||
notify({ tone: "success", title: "Решения сохранены", description: `${saved.resolvedClipCount}/${saved.clipCount} клипов согласовано.` });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || dirty || !session.complete || !adjudicatorId.trim() || !resolvedAttested || !blindAttested) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const frozen = await freezeM48AdjudicationSession(session, adjudicatorId.trim());
|
||||
setSession(frozen);
|
||||
setFreezeOpen(false);
|
||||
notify({ tone: "success", title: "Truth seal создан", description: "Frozen adjudication готова к одноразовой оценке." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluate = async () => {
|
||||
if (!session || session.state !== "adjudication-frozen") return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const evaluated = await evaluateM48Adjudication(session, `evaluate-${crypto.randomUUID()}`);
|
||||
setSession(evaluated);
|
||||
notify({ tone: "success", title: "M4.8 рассчитана", description: evaluated.qualityResultId ?? "Результат зарегистрирован." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(message(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (!dirty || window.confirm("Закрыть без сохранения?")) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 adjudication"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="plus" size={16} />} disabled={busy || Boolean(session)} onClick={() => void createSession()}>Открыть adjudication</Button>
|
||||
<Button size="compact" variant={drawing ? "accent" : "secondary"} icon={<Icon name="edit" size={16} />} disabled={!editable} onClick={() => setDrawing((value) => !value)}>Объект</Button>
|
||||
<Button size="compact" variant="primary" icon={<Icon name="save" size={16} />} disabled={!editable || !dirty || busy} onClick={() => void save()}>Сохранить</Button>
|
||||
<Button size="compact" variant="accent" icon={<Icon name="check" size={16} />} disabled={!session?.complete || dirty || busy || session.state !== "saved"} onClick={() => setFreezeOpen(true)}>Truth seal</Button>
|
||||
<Button size="compact" variant="accent" disabled={session?.state !== "adjudication-frozen" || busy} onClick={() => void evaluate()}>Рассчитать gate</Button>
|
||||
</div>
|
||||
<div className="m48-review-workspace__toolbar-group">
|
||||
<Select label="Клип" value={clipId} options={(catalog?.clips ?? []).map((item) => ({ value: item.clipId, label: `${item.ordinal}/${catalog?.clipCount ?? 0} · ${item.clipId} · ${drafts.get(item.clipId)?.reviewState ?? "pending"}` }))} disabled={!catalog} searchable menuWidth={380} onChange={(value) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === value);
|
||||
if (!next) return;
|
||||
setClipId(value);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
}} />
|
||||
<StatusBadge tone={session?.state === "evaluated" ? "success" : dirty ? "warning" : session ? "neutral" : "warning"}>{session ? `${session.state} · ${session.resolvedClipCount}/${session.clipCount}` : "Adjudication не создана"}</StatusBadge>
|
||||
<IconButton label="Закрыть M4.8 adjudication" onClick={requestClose}><Icon name="close" size={16} /></IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-evidence-stage">
|
||||
{loading ? <div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем source-only клипы</div>
|
||||
: !catalog || !catalog.cameraPlayback || !clip ? <div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "Источник недоступен."}</div>
|
||||
: <M48BlindClipPlayer cameraPlayback={catalog.cameraPlayback} clip={clip} sequence={sequence} mode={evidenceMode} cameraVisible={cameraVisible} tracklets={visibleDraft?.tracklets ?? []} selectedObjectId={selectedObjectId} editable={editable} drawing={drawing} spatialFrame={spatial} spatialLoading={spatialLoading} spatialError={spatialError} spatialEvidenceAvailable={spatialEnabled} onSequenceChange={setSequence} onDrawingChange={setDrawing} onSelectedObjectIdChange={setSelectedObjectId} onTrackletsChange={(tracklets) => {
|
||||
if (!decision) return;
|
||||
setDecision({ ...decision, reviewState: "pending", noObject: null, tracklets });
|
||||
}} />}
|
||||
{catalog ? <M48EvidenceModeRail mode={evidenceMode} cameraVisible={cameraVisible} spatialAvailable={spatialEnabled} onModeChange={setEvidenceMode} onCameraVisibleChange={setCameraVisible} /> : null}
|
||||
</div>
|
||||
)}
|
||||
inspector={(
|
||||
<>
|
||||
<div className="m48-review-workspace__source-state"><span>M4.8 · reviewer disagreement</span><strong>{clip ? `${clip.clipId} · frame ${sequence}` : "Источник проверяется"}</strong><small>Два независимых class-free review; frozen graph output остаётся скрыт.</small></div>
|
||||
{(error || spatialError) && catalog ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
<SegmentedControl value={reviewLayer} items={[...REVIEW_LAYERS]} label="Reviewer inputs" onChange={setReviewLayer} />
|
||||
{session && reviewerA && reviewerB && !["adjudication-frozen", "evaluated"].includes(session.state) ? (
|
||||
<div className="m48-review-workspace__review-actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerA))}>Принять A</Button>
|
||||
<Button size="compact" variant="secondary" onClick={() => setDecision(decisionCopy(reviewerB))}>Принять B</Button>
|
||||
{decision ? <Checker checked={decision.reviewState === "adjudicated"} label={decision.noObject ? "Согласовано: объектов нет" : `Согласовано: ${decision.tracklets.length} tracklet`} onChange={(checked) => setDecision({ ...decision, reviewState: checked ? "adjudicated" : "pending", noObject: checked ? decision.tracklets.length === 0 : null })} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{editable && selectedDecisionTracklet ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<strong>{selectedDecisionTracklet.objectId}</strong>
|
||||
<Select disabled={!catalog?.evidenceCapabilities.geometryAssociation} label="Геометрия" value={selectedDecisionTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Неприменима" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.freshness} label="Актуальность" value={selectedDecisionTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateDecisionTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.motion} label="Движение" value={selectedDecisionTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Неизвестно" }, { value: "unsupported", label: "Не поддержано" }]} onChange={(value) => updateDecisionTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
<Select disabled={!catalog?.evidenceCapabilities.threat} label="Угроза" value={selectedDecisionTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Угроза" }, { value: "not-threat", label: "Не угроза" }, { value: "unknown", label: "Неизвестно" }]} onChange={(value) => updateDecisionTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
<Checker disabled={!catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedDecisionTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Критический объект" onChange={(criticalCorridorObstacle) => updateDecisionTrackState({ criticalCorridorObstacle })} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window open={freezeOpen} title="Создать M4.8 truth seal" subtitle="После freeze Reviewer A/B и adjudication станут immutable входом quality gate." size="md" closeOnBackdrop={!busy} closeOnEscape={!busy} onClose={() => !busy && setFreezeOpen(false)} footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !adjudicatorId.trim() || !resolvedAttested || !blindAttested} onClick={() => void freeze()}>Freeze adjudication</Button></WindowFooterActions>}>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Opaque adjudicator ID" value={adjudicatorId} maxLength={96} placeholder="adjudicator-1" onChange={(event) => setAdjudicatorId(event.target.value)} />
|
||||
<Checker checked={resolvedAttested} label="Все 20–30 клипов согласованы" onChange={setResolvedAttested} />
|
||||
<Checker checked={blindAttested} label="Model predictions до truth seal не просматривались" onChange={setBlindAttested} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { ActivityIndicator, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryRecordedClipPlayer } from "../../../components/laboratory/LaboratoryRecordedClipPlayer";
|
||||
import { LaboratoryMetricEvidenceScene } from "../../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import type {
|
||||
M48RecordedCameraPlayback,
|
||||
M48ReviewClipSource,
|
||||
M48ReviewSpatialFrame,
|
||||
M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { m48RecordedCameraSourceDescriptor } from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
export type { M48BlindEvidenceMode } from "./M48EvidenceModeControls";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
interface BoxInteraction {
|
||||
kind: "move" | "resize";
|
||||
pointerId: number;
|
||||
objectId: string;
|
||||
start: readonly [number, number];
|
||||
startClient: readonly [number, number];
|
||||
current: readonly [number, number];
|
||||
originalExtent: readonly [number, number, number, number];
|
||||
handle?: ResizeHandle;
|
||||
}
|
||||
|
||||
function interpolate(left: number, right: number, progress: number): number {
|
||||
return left + (right - left) * progress;
|
||||
}
|
||||
|
||||
export function interpolateM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
): readonly [number, number, number, number] | null {
|
||||
if (sequence < tracklet.firstSequence || sequence > tracklet.lastSequence) return null;
|
||||
const rightIndex = tracklet.keyframes.findIndex((keyframe) => keyframe.sequence >= sequence);
|
||||
const right = tracklet.keyframes[rightIndex < 0 ? tracklet.keyframes.length - 1 : rightIndex];
|
||||
if (!right) return null;
|
||||
const left = tracklet.keyframes[Math.max(0, (rightIndex < 0 ? tracklet.keyframes.length : rightIndex) - 1)] ?? right;
|
||||
if (left.sequence === right.sequence) return right.extentXyxy;
|
||||
const progress = (sequence - left.sequence) / (right.sequence - left.sequence);
|
||||
return right.extentXyxy.map((value, index) => interpolate(left.extentXyxy[index]!, value, progress)) as unknown as readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
export function createM48Tracklet(
|
||||
objectId: string,
|
||||
clip: M48ReviewClipSource,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
spatialEvidenceAvailable = true,
|
||||
sequence = clip.startSequence,
|
||||
): M48ReviewTracklet {
|
||||
return {
|
||||
objectId,
|
||||
firstSequence: sequence,
|
||||
lastSequence: sequence,
|
||||
keyframes: [{ sequence, extentXyxy, visibility: "visible" as const }],
|
||||
stateSegments: [{
|
||||
startSequence: sequence,
|
||||
endSequence: sequence,
|
||||
geometryAssociation: spatialEvidenceAvailable ? "unknown" : "unavailable",
|
||||
freshness: "unavailable",
|
||||
motion: spatialEvidenceAvailable ? "unknown" : "unsupported",
|
||||
threat: "unknown",
|
||||
criticalCorridorObstacle: false,
|
||||
}],
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function nextM48ObjectId(tracklets: readonly M48ReviewTracklet[]): string {
|
||||
const occupied = new Set(tracklets.map(({ objectId }) => objectId));
|
||||
let ordinal = 1;
|
||||
while (occupied.has(`object-${String(ordinal).padStart(2, "0")}`)) ordinal += 1;
|
||||
return `object-${String(ordinal).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function upsertM48Extent(
|
||||
tracklet: M48ReviewTracklet,
|
||||
sequence: number,
|
||||
extentXyxy: readonly [number, number, number, number],
|
||||
): M48ReviewTracklet {
|
||||
const visibility = tracklet.keyframes.find((keyframe) => keyframe.sequence === sequence)?.visibility
|
||||
?? tracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility
|
||||
?? "visible";
|
||||
return {
|
||||
...tracklet,
|
||||
keyframes: [
|
||||
...tracklet.keyframes.filter((keyframe) => keyframe.sequence !== sequence),
|
||||
{ sequence, extentXyxy, visibility },
|
||||
].sort((left, right) => left.sequence - right.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
function useHostSize(ref: RefObject<HTMLDivElement | null>): Rect {
|
||||
const [rect, setRect] = useState<Rect>({ x: 0, y: 0, width: 1, height: 1 });
|
||||
useEffect(() => {
|
||||
const host = ref.current;
|
||||
if (!host) return;
|
||||
const update = () => setRect({ x: 0, y: 0, width: Math.max(host.clientWidth, 1), height: Math.max(host.clientHeight, 1) });
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return rect;
|
||||
}
|
||||
|
||||
function imagePlane(host: Rect, naturalWidth: number, naturalHeight: number): Rect {
|
||||
const scale = Math.min(host.width / Math.max(naturalWidth, 1), host.height / Math.max(naturalHeight, 1));
|
||||
const width = naturalWidth * scale;
|
||||
const height = naturalHeight * scale;
|
||||
return { x: (host.width - width) / 2, y: (host.height - height) / 2, width, height };
|
||||
}
|
||||
|
||||
function normalizedPoint(event: ReactPointerEvent<SVGSVGElement>, plane: Rect): readonly [number, number] | null {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const x = (event.clientX - bounds.left - plane.x) / Math.max(plane.width, 1);
|
||||
const y = (event.clientY - bounds.top - plane.y) / Math.max(plane.height, 1);
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return null;
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
function boundedNormalizedPoint(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
svg: SVGSVGElement,
|
||||
plane: Rect,
|
||||
): readonly [number, number] {
|
||||
const bounds = svg.getBoundingClientRect();
|
||||
return [
|
||||
Math.max(0, Math.min(1, (clientX - bounds.left - plane.x) / Math.max(plane.width, 1))),
|
||||
Math.max(0, Math.min(1, (clientY - bounds.top - plane.y) / Math.max(plane.height, 1))),
|
||||
];
|
||||
}
|
||||
|
||||
function movedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const width = original[2] - original[0];
|
||||
const height = original[3] - original[1];
|
||||
const left = Math.max(0, Math.min(1 - width, original[0] + current[0] - start[0]));
|
||||
const top = Math.max(0, Math.min(1 - height, original[1] + current[1] - start[1]));
|
||||
return [left, top, left + width, top + height];
|
||||
}
|
||||
|
||||
function resizedExtent(
|
||||
original: readonly [number, number, number, number],
|
||||
handle: ResizeHandle,
|
||||
current: readonly [number, number],
|
||||
): readonly [number, number, number, number] {
|
||||
const minimum = 0.005;
|
||||
let [left, top, right, bottom] = original;
|
||||
if (handle.includes("n")) top = Math.min(current[1], bottom - minimum);
|
||||
if (handle.includes("s")) bottom = Math.max(current[1], top + minimum);
|
||||
if (handle.includes("w")) left = Math.min(current[0], right - minimum);
|
||||
if (handle.includes("e")) right = Math.max(current[0], left + minimum);
|
||||
return [left, top, right, bottom];
|
||||
}
|
||||
|
||||
function interactionExtent(interaction: BoxInteraction) {
|
||||
return interaction.kind === "move"
|
||||
? movedExtent(interaction.originalExtent, interaction.start, interaction.current)
|
||||
: resizedExtent(
|
||||
interaction.originalExtent,
|
||||
interaction.handle ?? "se",
|
||||
interaction.current,
|
||||
);
|
||||
}
|
||||
|
||||
function extentsDiffer(
|
||||
left: readonly [number, number, number, number],
|
||||
right: readonly [number, number, number, number],
|
||||
): boolean {
|
||||
return left.some((value, index) => Math.abs(value - right[index]!) > 1e-6);
|
||||
}
|
||||
|
||||
function interactionMoved(
|
||||
start: readonly [number, number],
|
||||
current: readonly [number, number],
|
||||
): boolean {
|
||||
return Math.hypot(current[0] - start[0], current[1] - start[1]) >= 3;
|
||||
}
|
||||
|
||||
export function M48BlindClipPlayer({
|
||||
cameraPlayback,
|
||||
clip,
|
||||
sequence,
|
||||
mode,
|
||||
cameraVisible,
|
||||
tracklets,
|
||||
selectedObjectId,
|
||||
editable,
|
||||
drawing,
|
||||
spatialFrame,
|
||||
spatialLoading,
|
||||
spatialError,
|
||||
spatialEvidenceAvailable = true,
|
||||
onSequenceChange,
|
||||
onDrawingChange,
|
||||
onSelectedObjectIdChange,
|
||||
onTrackletsChange,
|
||||
}: {
|
||||
cameraPlayback: M48RecordedCameraPlayback;
|
||||
clip: M48ReviewClipSource;
|
||||
sequence: number;
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
tracklets: readonly M48ReviewTracklet[];
|
||||
selectedObjectId: string | null;
|
||||
editable: boolean;
|
||||
drawing: boolean;
|
||||
spatialFrame: M48ReviewSpatialFrame | null;
|
||||
spatialLoading: boolean;
|
||||
spatialError?: string | null;
|
||||
spatialEvidenceAvailable?: boolean;
|
||||
onSequenceChange: (sequence: number) => void;
|
||||
onDrawingChange: (drawing: boolean) => void;
|
||||
onSelectedObjectIdChange: (objectId: string | null) => void;
|
||||
onTrackletsChange: (tracklets: readonly M48ReviewTracklet[]) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const host = useHostSize(hostRef);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [drawStart, setDrawStart] = useState<readonly [number, number] | null>(null);
|
||||
const [drawCurrent, setDrawCurrent] = useState<readonly [number, number] | null>(null);
|
||||
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null);
|
||||
const cameraSource = useMemo(
|
||||
() => m48RecordedCameraSourceDescriptor(cameraPlayback),
|
||||
[cameraPlayback],
|
||||
);
|
||||
const spatialReady = Boolean(
|
||||
spatialFrame?.sequence === sequence
|
||||
&& spatialFrame.sourceAvailable
|
||||
&& spatialFrame.bodyFrameAvailable
|
||||
&& spatialFrame.pointCloudBodyXyzM.length > 0,
|
||||
);
|
||||
const spatialVisible = mode !== "camera" && spatialEvidenceAvailable;
|
||||
const effectiveCameraVisible = cameraVisible || !spatialVisible;
|
||||
const plane = imagePlane(host, 1440, 1080);
|
||||
|
||||
useEffect(() => setPlaying(false), [clip.clipId]);
|
||||
useEffect(() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}, [clip.clipId, sequence]);
|
||||
|
||||
const boxes = useMemo(() => tracklets.flatMap((tracklet) => {
|
||||
const extent = interpolateM48Extent(tracklet, sequence);
|
||||
return extent ? [{ tracklet, extent }] : [];
|
||||
}), [sequence, tracklets]);
|
||||
|
||||
const finishDrawing = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!editable || !drawing || !drawStart) return;
|
||||
const end = normalizedPoint(event, plane) ?? drawCurrent;
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
if (!end) return;
|
||||
const extent = [
|
||||
Math.min(drawStart[0], end[0]),
|
||||
Math.min(drawStart[1], end[1]),
|
||||
Math.max(drawStart[0], end[0]),
|
||||
Math.max(drawStart[1], end[1]),
|
||||
] as const;
|
||||
if (extent[2] - extent[0] < 0.01 || extent[3] - extent[1] < 0.01) return;
|
||||
const selected = selectedObjectId
|
||||
? tracklets.find((tracklet) => (
|
||||
tracklet.objectId === selectedObjectId
|
||||
&& sequence >= tracklet.firstSequence
|
||||
&& sequence <= tracklet.lastSequence
|
||||
))
|
||||
: null;
|
||||
if (selected) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selected.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
onDrawingChange(false);
|
||||
return;
|
||||
}
|
||||
const objectId = nextM48ObjectId(tracklets);
|
||||
onTrackletsChange([...tracklets, createM48Tracklet(objectId, clip, extent, spatialEvidenceAvailable, sequence)]);
|
||||
onSelectedObjectIdChange(objectId);
|
||||
onDrawingChange(false);
|
||||
};
|
||||
|
||||
const finishBoxInteraction = (event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (!boxInteraction || boxInteraction.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const current = boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
);
|
||||
const extent = interactionExtent({ ...boxInteraction, current });
|
||||
if (
|
||||
interactionMoved(
|
||||
boxInteraction.startClient,
|
||||
[event.clientX, event.clientY],
|
||||
)
|
||||
&& extentsDiffer(boxInteraction.originalExtent, extent)
|
||||
) {
|
||||
onTrackletsChange(tracklets.map((tracklet) => (
|
||||
tracklet.objectId === boxInteraction.objectId
|
||||
? upsertM48Extent(tracklet, sequence, extent)
|
||||
: tracklet
|
||||
)));
|
||||
}
|
||||
setBoxInteraction(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryRecordedClipPlayer
|
||||
source={cameraSource}
|
||||
segmentCount={cameraPlayback.segmentCount}
|
||||
frames={clip.frames}
|
||||
sequence={sequence}
|
||||
playing={playing}
|
||||
playbackRate={playbackRate}
|
||||
cameraPresentation={spatialVisible
|
||||
? effectiveCameraVisible ? "companion" : "hidden"
|
||||
: "primary"}
|
||||
continuousPlayback
|
||||
sourceCount={Number(effectiveCameraVisible) + Number(spatialVisible)}
|
||||
cameraRef={hostRef}
|
||||
onSequenceChange={onSequenceChange}
|
||||
onPlayingChange={setPlaying}
|
||||
onPlaybackRateChange={setPlaybackRate}
|
||||
cameraOverlay={(<>
|
||||
<div className="m48-clip-player__pane-label" data-pane="camera">
|
||||
ПРАВАЯ КАМЕРА · СИНХРОННО · КАДР {sequence}
|
||||
</div>
|
||||
<svg
|
||||
className="m48-clip-player__overlay"
|
||||
viewBox={`0 0 ${host.width} ${host.height}`}
|
||||
aria-label="Объектные tracklet-рамки без классов"
|
||||
data-drawing={editable && drawing ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (!drawing) {
|
||||
onSelectedObjectIdChange(null);
|
||||
return;
|
||||
}
|
||||
if (!editable) return;
|
||||
setPlaying(false);
|
||||
const point = normalizedPoint(event, plane);
|
||||
if (point) {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDrawStart(point);
|
||||
setDrawCurrent(point);
|
||||
}
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (drawStart) setDrawCurrent(normalizedPoint(event, plane));
|
||||
if (boxInteraction?.pointerId === event.pointerId) {
|
||||
setBoxInteraction({
|
||||
...boxInteraction,
|
||||
current: boundedNormalizedPoint(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
event.currentTarget,
|
||||
plane,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (boxInteraction) finishBoxInteraction(event);
|
||||
else finishDrawing(event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDrawStart(null);
|
||||
setDrawCurrent(null);
|
||||
setBoxInteraction(null);
|
||||
}}
|
||||
>
|
||||
{boxes.map(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return (
|
||||
<g
|
||||
key={tracklet.objectId}
|
||||
data-selected={tracklet.objectId === selectedObjectId ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (drawing) return;
|
||||
event.stopPropagation();
|
||||
onSelectedObjectIdChange(tracklet.objectId);
|
||||
if (!editable || event.button !== 0) return;
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "move",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<rect x={plane.x + left * plane.width} y={plane.y + top * plane.height} width={(right - left) * plane.width} height={(bottom - top) * plane.height} />
|
||||
<text x={plane.x + left * plane.width} y={Math.max(14, plane.y + top * plane.height - 6)}>{tracklet.objectId}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{drawStart && drawCurrent ? (
|
||||
<rect
|
||||
className="m48-clip-player__draft-box"
|
||||
x={plane.x + Math.min(drawStart[0], drawCurrent[0]) * plane.width}
|
||||
y={plane.y + Math.min(drawStart[1], drawCurrent[1]) * plane.height}
|
||||
width={Math.abs(drawCurrent[0] - drawStart[0]) * plane.width}
|
||||
height={Math.abs(drawCurrent[1] - drawStart[1]) * plane.height}
|
||||
/>
|
||||
) : null}
|
||||
{editable && !drawing && selectedObjectId ? boxes
|
||||
.filter(({ tracklet }) => tracklet.objectId === selectedObjectId)
|
||||
.flatMap(({ tracklet, extent }) => {
|
||||
const displayedExtent = boxInteraction?.objectId === tracklet.objectId
|
||||
? interactionExtent(boxInteraction)
|
||||
: extent;
|
||||
const [left, top, right, bottom] = displayedExtent;
|
||||
return ([
|
||||
["nw", left, top],
|
||||
["ne", right, top],
|
||||
["sw", left, bottom],
|
||||
["se", right, bottom],
|
||||
] as const).map(([handle, x, y]) => (
|
||||
<circle
|
||||
className="m48-clip-player__resize-handle"
|
||||
data-handle={handle}
|
||||
key={`${tracklet.objectId}-${handle}`}
|
||||
cx={plane.x + x * plane.width}
|
||||
cy={plane.y + y * plane.height}
|
||||
r={6}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.stopPropagation();
|
||||
setPlaying(false);
|
||||
const svg = event.currentTarget.ownerSVGElement;
|
||||
if (!svg) return;
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
const point = boundedNormalizedPoint(event.clientX, event.clientY, svg, plane);
|
||||
setBoxInteraction({
|
||||
kind: "resize",
|
||||
pointerId: event.pointerId,
|
||||
objectId: tracklet.objectId,
|
||||
start: point,
|
||||
startClient: [event.clientX, event.clientY],
|
||||
current: point,
|
||||
originalExtent: extent,
|
||||
handle,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}) : null}
|
||||
</svg>
|
||||
</>)}
|
||||
alternativeScene={(
|
||||
<div
|
||||
className="m48-clip-player__spatial-pane"
|
||||
data-spatial-sequence={spatialReady ? sequence : undefined}
|
||||
>
|
||||
<div className="m48-clip-player__pane-label" data-pane="spatial">
|
||||
{mode === "3d" ? "3D LIDAR" : "ПЛАН LIDAR"} · КАДР {sequence}
|
||||
</div>
|
||||
{spatialReady && spatialFrame ? (
|
||||
<LaboratoryMetricEvidenceScene
|
||||
pointCloudBodyXyzM={spatialFrame.pointCloudBodyXyzM}
|
||||
localSurfaceBodyXyzM={[]}
|
||||
obstacles={[]}
|
||||
rig={spatialFrame.rig}
|
||||
corridor={spatialFrame.corridor}
|
||||
occupiedVoxelSizeM={spatialFrame.occupiedVoxelSizeM}
|
||||
mode={mode === "3d" ? "3d" : "plan"}
|
||||
label={`M4.8 пространственные данные источника · кадр ${sequence}`}
|
||||
showCurrentIncrement
|
||||
showLocalSurface={false}
|
||||
showRollingMap={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="m48-clip-player__state" role={spatialLoading ? "status" : "alert"}>
|
||||
{spatialLoading ? <ActivityIndicator size="compact" /> : <Icon name="alert" size={18} />}
|
||||
{spatialLoading
|
||||
? "Подготавливаем синхронный LiDAR-кадр"
|
||||
: spatialError
|
||||
? spatialError
|
||||
: spatialFrame && !spatialFrame.sourceAvailable
|
||||
? "Текущий LiDAR-кадр недоступен"
|
||||
: spatialFrame && !spatialFrame.bodyFrameAvailable
|
||||
? "LiDAR в системе координат корпуса для этого кадра недоступен"
|
||||
: "Исходные пространственные данные для этого кадра недоступны"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
FieldFrame,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createM48CorrectionSession,
|
||||
fetchM48ReviewSourceCatalog,
|
||||
freezeM48CorrectionSession,
|
||||
saveM48CorrectionSession,
|
||||
type M48CorrectionSession,
|
||||
type M48GateStatus,
|
||||
type M48ReviewClipDraft,
|
||||
type M48ReviewSourceCatalog,
|
||||
type M48ReviewTracklet,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import { LaboratoryReviewWorkspaceFrame } from "../../../components/laboratory/LaboratoryReviewWorkspaceFrame";
|
||||
import {
|
||||
M48BlindClipPlayer,
|
||||
interpolateM48Extent,
|
||||
upsertM48Extent,
|
||||
type M48BlindEvidenceMode,
|
||||
} from "./M48BlindClipPlayer";
|
||||
import { useM48SpatialClipPlayback } from "./useM48SpatialClipPlayback";
|
||||
import { M48EvidenceModeRail } from "./M48EvidenceModeControls";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Операция M4.8 не выполнена.";
|
||||
}
|
||||
|
||||
function operationKey(packId: string): string {
|
||||
const key = `missioncore:m48:${packId}:correction-operation-key`;
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) return stored;
|
||||
const created = `correction-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function draftMap(session: M48CorrectionSession): Map<string, M48ReviewClipDraft> {
|
||||
return new Map(session.clips.map((clip) => [clip.clipId, clip]));
|
||||
}
|
||||
|
||||
function stateLabel(session: M48CorrectionSession | null): string {
|
||||
if (!session) return "Проверка загружается";
|
||||
if (session.state === "frozen") return "Проверка завершена";
|
||||
return `Проверено клипов: ${session.reviewedClipCount} из ${session.clipCount}`;
|
||||
}
|
||||
|
||||
function clipOptionLabel(
|
||||
ordinal: number,
|
||||
clipCount: number,
|
||||
clipId: string,
|
||||
reviewState: M48ReviewClipDraft["reviewState"] | undefined,
|
||||
): string {
|
||||
const progress = `${String(ordinal).padStart(2, "0")}/${String(clipCount).padStart(2, "0")}`;
|
||||
return `${progress} · ${clipId} · ${reviewState === "reviewed" ? "проверен" : "не проверен"}`;
|
||||
}
|
||||
|
||||
type M48CorrectionSaveReason = "clip-status" | "object-edit";
|
||||
|
||||
interface M48CorrectionSaveRollback {
|
||||
drafts: ReadonlyMap<string, M48ReviewClipDraft>;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export function M48CorrectionWorkspace({
|
||||
gate,
|
||||
returnFocusTarget,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
gate: M48GateStatus;
|
||||
returnFocusTarget?: HTMLElement | null;
|
||||
onClose: () => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<M48ReviewSourceCatalog | null>(null);
|
||||
const [session, setSession] = useState<M48CorrectionSession | null>(null);
|
||||
const [drafts, setDrafts] = useState<ReadonlyMap<string, M48ReviewClipDraft>>(new Map());
|
||||
const [selectedClipId, setSelectedClipId] = useState("");
|
||||
const [sequence, setSequence] = useState(1);
|
||||
const [mode, setMode] = useState<M48BlindEvidenceMode>("camera");
|
||||
const [cameraVisible, setCameraVisible] = useState(true);
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [freezeOpen, setFreezeOpen] = useState(false);
|
||||
const [reviewerId, setReviewerId] = useState("");
|
||||
const [candidateVisible, setCandidateVisible] = useState(false);
|
||||
const [classFree, setClassFree] = useState(false);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
const notify = useCallback((toast: Omit<ToastItem, "id">) => {
|
||||
setToasts((current) => [...current, { ...toast, id: crypto.randomUUID() }]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void Promise.all([
|
||||
fetchM48ReviewSourceCatalog(gate.packId, { signal: controller.signal }),
|
||||
createM48CorrectionSession(gate.packId, operationKey(gate.packId), { signal: controller.signal }),
|
||||
])
|
||||
.then(([next, correction]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(next);
|
||||
setSession(correction);
|
||||
setDrafts(draftMap(correction));
|
||||
const first = next.clips[0];
|
||||
if (first) {
|
||||
setSelectedClipId(first.clipId);
|
||||
setSequence(first.startSequence);
|
||||
}
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(caught));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [gate.packId]);
|
||||
|
||||
const clip = useMemo(
|
||||
() => catalog?.clips.find((item) => item.clipId === selectedClipId) ?? null,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const selectedClipIndex = useMemo(
|
||||
() => catalog?.clips.findIndex((item) => item.clipId === selectedClipId) ?? -1,
|
||||
[catalog, selectedClipId],
|
||||
);
|
||||
const currentDraft = clip ? drafts.get(clip.clipId) ?? null : null;
|
||||
const selectedTracklet = currentDraft?.tracklets.find(({ objectId }) => objectId === selectedObjectId) ?? null;
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTracklet
|
||||
&& (sequence < selectedTracklet.firstSequence || sequence > selectedTracklet.lastSequence)
|
||||
) {
|
||||
setSelectedObjectId(null);
|
||||
}
|
||||
}, [selectedTracklet, sequence]);
|
||||
const spatialEnabled = Boolean(
|
||||
catalog?.evidenceCapabilities.currentPointCloudBodyXyzM
|
||||
&& catalog.evidenceCapabilities.rig
|
||||
&& catalog.evidenceCapabilities.virtualCorridor,
|
||||
);
|
||||
const extentEnabled = Boolean(catalog?.evidenceCapabilities.obstaclePresenceAndExtent);
|
||||
const editable = Boolean(session && session.state !== "frozen");
|
||||
const editingEnabled = editable && !busy;
|
||||
const {
|
||||
frame: spatial,
|
||||
loading: spatialLoading,
|
||||
error: spatialError,
|
||||
} = useM48SpatialClipPlayback({
|
||||
packId: gate.packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled: mode !== "camera" && spatialEnabled,
|
||||
});
|
||||
|
||||
const setCurrentDraft = useCallback((next: M48ReviewClipDraft) => {
|
||||
setDrafts((current) => {
|
||||
const updated = new Map(current);
|
||||
updated.set(next.clipId, next);
|
||||
return updated;
|
||||
});
|
||||
setDirty(true);
|
||||
}, []);
|
||||
|
||||
const save = async (
|
||||
nextDrafts: ReadonlyMap<string, M48ReviewClipDraft> = drafts,
|
||||
reason: M48CorrectionSaveReason = "object-edit",
|
||||
rollback?: M48CorrectionSaveRollback,
|
||||
) => {
|
||||
if (!session || nextDrafts.size !== session.clipCount || savingRef.current) return false;
|
||||
savingRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const clips = session.clips.map((item) => nextDrafts.get(item.clipId) ?? item);
|
||||
const saved = await saveM48CorrectionSession(
|
||||
session,
|
||||
session.title,
|
||||
clips,
|
||||
`save-${session.revision + 1}-${crypto.randomUUID()}`,
|
||||
);
|
||||
setSession(saved);
|
||||
setDrafts(draftMap(saved));
|
||||
setDirty(false);
|
||||
notify({
|
||||
tone: "success",
|
||||
title: reason === "clip-status" ? "Статус клипа сохранён" : "Изменения объекта сохранены",
|
||||
description: `${saved.reviewedClipCount}/${saved.clipCount} клипов проверено.`,
|
||||
});
|
||||
onChanged?.();
|
||||
return true;
|
||||
} catch (caught) {
|
||||
const message = errorMessage(caught);
|
||||
setError(message);
|
||||
if (rollback) {
|
||||
setDrafts(rollback.drafts);
|
||||
setDirty(rollback.dirty);
|
||||
} else {
|
||||
setDirty(true);
|
||||
}
|
||||
notify({
|
||||
tone: "error",
|
||||
title: reason === "clip-status" ? "Статус клипа не сохранён" : "Изменения объекта не сохранены",
|
||||
description: message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const freeze = async () => {
|
||||
if (!session || !reviewerId.trim() || dirty || !session.complete || !candidateVisible || !classFree) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const frozen = await freezeM48CorrectionSession(session, reviewerId.trim());
|
||||
setSession(frozen);
|
||||
setDrafts(draftMap(frozen));
|
||||
setFreezeOpen(false);
|
||||
setDrawing(false);
|
||||
notify({ tone: "success", title: "Проверка Worker 006 зафиксирована", description: "Дельта correction сохранена как assisted evidence, не independent truth." });
|
||||
onChanged?.();
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSelectedTracklet = (patch: Partial<M48ReviewTracklet>) => {
|
||||
if (!currentDraft || !selectedTracklet) return;
|
||||
setCurrentDraft({
|
||||
...currentDraft,
|
||||
reviewState: "pending",
|
||||
noObject: null,
|
||||
tracklets: currentDraft.tracklets.map((tracklet) => (
|
||||
tracklet.objectId === selectedTracklet.objectId ? { ...tracklet, ...patch } : tracklet
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrackState = (patch: Partial<M48ReviewTracklet["stateSegments"][number]>) => {
|
||||
if (!selectedTracklet) return;
|
||||
updateSelectedTracklet({
|
||||
stateSegments: selectedTracklet.stateSegments.map((segment) => ({ ...segment, ...patch })),
|
||||
});
|
||||
};
|
||||
|
||||
const updateVisibility = (visibility: M48ReviewTracklet["keyframes"][number]["visibility"]) => {
|
||||
if (!selectedTracklet) return;
|
||||
const extent = interpolateM48Extent(selectedTracklet, sequence);
|
||||
if (!extent) return;
|
||||
const withKeyframe = upsertM48Extent(selectedTracklet, sequence, extent);
|
||||
updateSelectedTracklet({
|
||||
keyframes: withKeyframe.keyframes.map((keyframe) => (
|
||||
keyframe.sequence === sequence ? { ...keyframe, visibility } : keyframe
|
||||
)),
|
||||
});
|
||||
};
|
||||
|
||||
const markReviewed = (reviewed: boolean) => {
|
||||
if (!currentDraft || !editingEnabled) return;
|
||||
const nextDraft: M48ReviewClipDraft = reviewed
|
||||
? {
|
||||
...currentDraft,
|
||||
reviewState: "reviewed",
|
||||
noObject: currentDraft.tracklets.length === 0,
|
||||
}
|
||||
: { ...currentDraft, reviewState: "pending", noObject: null };
|
||||
const nextDrafts = new Map(drafts);
|
||||
nextDrafts.set(nextDraft.clipId, nextDraft);
|
||||
setDrafts(nextDrafts);
|
||||
setDirty(true);
|
||||
void save(nextDrafts, "clip-status", { drafts, dirty });
|
||||
};
|
||||
|
||||
const selectClip = (clipId: string) => {
|
||||
const next = catalog?.clips.find((item) => item.clipId === clipId);
|
||||
if (!next) return;
|
||||
setSelectedClipId(next.clipId);
|
||||
setSequence(next.startSequence);
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
};
|
||||
|
||||
const selectAdjacentClip = (offset: -1 | 1) => {
|
||||
const next = catalog?.clips[selectedClipIndex + offset];
|
||||
if (next) selectClip(next.clipId);
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (dirty) {
|
||||
if (!window.confirm("Закрыть рабочую область без сохранения черновика?")) return;
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (session?.complete && session.state !== "frozen") {
|
||||
setFreezeOpen(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<LaboratoryReviewWorkspaceFrame
|
||||
ariaLabel="M4.8 проверка авторазметки Worker 006"
|
||||
onClose={requestClose}
|
||||
interactionEnabled={!freezeOpen}
|
||||
returnFocusTarget={returnFocusTarget}
|
||||
toolbar={(
|
||||
<div className="m48-review-workspace__header">
|
||||
<div className="m48-review-workspace__topbar">
|
||||
<div className="m48-review-workspace__topbar-start">
|
||||
<div className="m48-review-workspace__clip-navigation">
|
||||
<IconButton
|
||||
label="Предыдущий клип"
|
||||
disabled={selectedClipIndex <= 0 || busy}
|
||||
onClick={() => selectAdjacentClip(-1)}
|
||||
>
|
||||
<Icon name="chevron-left" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Следующий клип"
|
||||
disabled={!catalog || selectedClipIndex < 0 || selectedClipIndex >= catalog.clips.length - 1 || busy}
|
||||
onClick={() => selectAdjacentClip(1)}
|
||||
>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<FieldFrame label="Выбор клипа" className="m48-review-workspace__clip-field">
|
||||
<Select
|
||||
label="Клип для проверки"
|
||||
value={selectedClipId}
|
||||
options={(catalog?.clips ?? []).map((item) => ({
|
||||
value: item.clipId,
|
||||
label: clipOptionLabel(
|
||||
item.ordinal,
|
||||
catalog?.clipCount ?? 0,
|
||||
item.clipId,
|
||||
drafts.get(item.clipId)?.reviewState,
|
||||
),
|
||||
}))}
|
||||
disabled={!catalog || busy}
|
||||
searchable
|
||||
menuWidth={380}
|
||||
onChange={selectClip}
|
||||
/>
|
||||
</FieldFrame>
|
||||
{currentDraft && editable ? (
|
||||
<Checker
|
||||
className="m48-review-workspace__clip-reviewed"
|
||||
checked={currentDraft.reviewState === "reviewed"}
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
aria-label={currentDraft.tracklets.length
|
||||
? `Клип проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Клип проверен · без объектов"}
|
||||
label={currentDraft.tracklets.length
|
||||
? `Проверен · ${currentDraft.tracklets.length} объектов`
|
||||
: "Проверен · без объектов"}
|
||||
onChange={markReviewed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="m48-review-workspace__topbar-end">
|
||||
<IconButton
|
||||
label="Добавить объект"
|
||||
aria-pressed={drawing}
|
||||
disabled={!editingEnabled || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label="Закрыть проверку M4.8" disabled={busy} onClick={requestClose}>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{selectedTracklet && editable ? (
|
||||
<div className="m48-review-workspace__object-tools">
|
||||
<FieldFrame label="Видимость" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy} label="Видимость объекта" value={selectedTracklet.keyframes.filter((keyframe) => keyframe.sequence <= sequence).at(-1)?.visibility ?? "visible"} options={[{ value: "visible", label: "Виден" }, { value: "partial", label: "Виден частично" }, { value: "occluded", label: "Перекрыт" }]} onChange={(value) => updateVisibility(value as M48ReviewTracklet["keyframes"][number]["visibility"])} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Связь с LiDAR" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.geometryAssociation} label="Связь объекта с LiDAR" value={selectedTracklet.stateSegments[0]?.geometryAssociation ?? "unknown"} options={[{ value: "associated", label: "Связана" }, { value: "unavailable", label: "Недоступна" }, { value: "ineligible", label: "Не применяется" }, { value: "unknown", label: "Не определена" }]} onChange={(value) => updateTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Актуальность" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.freshness} label="Актуальность объекта" value={selectedTracklet.stateSegments[0]?.freshness ?? "unavailable"} options={[{ value: "current", label: "Актуальна" }, { value: "held", label: "Удержана" }, { value: "stale", label: "Устарела" }, { value: "unavailable", label: "Недоступна" }]} onChange={(value) => updateTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Движение" className="m48-review-workspace__object-field">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.motion} label="Движение объекта" value={selectedTracklet.stateSegments[0]?.motion ?? "unknown"} options={[{ value: "moving", label: "Движется" }, { value: "static", label: "Стоит" }, { value: "unknown", label: "Не определено" }, { value: "unsupported", label: "Не поддерживается" }]} onChange={(value) => updateTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Непосредственная опасность" className="m48-review-workspace__object-field m48-review-workspace__object-field--wide">
|
||||
<Select disabled={busy || !catalog?.evidenceCapabilities.threat} label="Непосредственная опасность объекта" value={selectedTracklet.stateSegments[0]?.threat ?? "unknown"} options={[{ value: "threat", label: "Опасен сейчас" }, { value: "not-threat", label: "Не опасен сейчас" }, { value: "unknown", label: "Не определено" }]} onChange={(value) => updateTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} />
|
||||
</FieldFrame>
|
||||
<FieldFrame label="Проезд" className="m48-review-workspace__passage-field">
|
||||
<Checker disabled={busy || !catalog?.evidenceCapabilities.criticalCorridorObstacle} checked={selectedTracklet.stateSegments[0]?.criticalCorridorObstacle ?? false} label="Объезд или запас" onChange={(criticalCorridorObstacle) => updateTrackState({ criticalCorridorObstacle })} />
|
||||
</FieldFrame>
|
||||
<IconButton
|
||||
label="Добавить ещё один объект"
|
||||
disabled={busy || !extentEnabled}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Сохранить изменения объекта"
|
||||
disabled={!dirty || busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void save(drafts, "object-edit")}
|
||||
>
|
||||
<Icon name="save" size={16} />
|
||||
</IconButton>
|
||||
<IconButton disabled={busy} label="Удалить объект" onClick={() => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets: currentDraft.tracklets.filter(({ objectId }) => objectId !== selectedTracklet.objectId) });
|
||||
setSelectedObjectId(null);
|
||||
}}><Icon name="trash" size={16} /></IconButton>
|
||||
<IconButton
|
||||
label="Закрыть редактор объекта"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setSelectedObjectId(null);
|
||||
setDrawing(false);
|
||||
}}
|
||||
>
|
||||
<Icon name="close" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
stage={(
|
||||
<div className="m48-review-workspace__stage-shell">
|
||||
{loading ? (
|
||||
<div className="m48-review-workspace__state" role="status"><span className="busy-indicator" aria-hidden="true" />Загружаем клипы и frozen-candidate seed Worker 006</div>
|
||||
) : !catalog || !catalog.cameraPlayback || !clip ? (
|
||||
<div className="m48-review-workspace__state" role="alert"><Icon name="alert" size={18} />{error ?? "M4.8 источник недоступен."}</div>
|
||||
) : (
|
||||
<M48BlindClipPlayer
|
||||
cameraPlayback={catalog.cameraPlayback}
|
||||
clip={clip}
|
||||
sequence={sequence}
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
tracklets={currentDraft?.tracklets ?? []}
|
||||
selectedObjectId={selectedObjectId}
|
||||
editable={editingEnabled && extentEnabled}
|
||||
drawing={drawing}
|
||||
spatialFrame={spatial}
|
||||
spatialLoading={spatialLoading}
|
||||
spatialError={spatialError}
|
||||
spatialEvidenceAvailable={spatialEnabled}
|
||||
onSequenceChange={setSequence}
|
||||
onDrawingChange={setDrawing}
|
||||
onSelectedObjectIdChange={setSelectedObjectId}
|
||||
onTrackletsChange={(tracklets) => {
|
||||
if (!currentDraft) return;
|
||||
setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{catalog ? (
|
||||
<M48EvidenceModeRail
|
||||
mode={mode}
|
||||
cameraVisible={cameraVisible}
|
||||
spatialAvailable={spatialEnabled}
|
||||
onModeChange={setMode}
|
||||
onCameraVisibleChange={setCameraVisible}
|
||||
/>
|
||||
) : null}
|
||||
{catalog ? (
|
||||
<GlassSurface
|
||||
className="m48-review-workspace__source-sticker"
|
||||
tone="strong"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
>
|
||||
<div className="m48-review-workspace__source-heading">
|
||||
<StatusBadge tone={session ? "success" : "warning"}>
|
||||
{session
|
||||
? `Worker 006 · ${session.seedObjectCount.toLocaleString("ru-RU")} авторамок`
|
||||
: "Загружаем авторазметку"}
|
||||
</StatusBadge>
|
||||
<strong>{clip ? `${clip.clipId} · кадр ${sequence}` : "Источник проверяется"}</strong>
|
||||
</div>
|
||||
<small>Исправьте авторамки; новая ручная рамка относится только к текущему кадру и не имитирует трекинг.</small>
|
||||
<small>«Опасность» — немедленная угроза. «Проезд» — статическое ограничение, которое требует объезда или геометрического запаса.</small>
|
||||
<StatusBadge tone={dirty ? "warning" : session?.state === "frozen" ? "success" : session ? "neutral" : "warning"}>
|
||||
{busy ? "Сохраняем изменения" : dirty ? "Есть несохранённые изменения" : stateLabel(session)}
|
||||
</StatusBadge>
|
||||
{(error || spatialError) ? <StatusBadge tone="danger">{error ?? spatialError}</StatusBadge> : null}
|
||||
{session?.evidenceSummary ? (
|
||||
<div className="m48-review-workspace__evidence-summary">
|
||||
<strong>Результат проверки Worker 006</strong>
|
||||
<span>Подтверждено: {session.evidenceSummary.confirmedCandidateCount}/{session.evidenceSummary.seedObjectCount}</span>
|
||||
<span>Исправлено: {session.evidenceSummary.modifiedCandidateCount}</span>
|
||||
<span>Удалено лишних: {session.evidenceSummary.falsePositiveRemovedCount}</span>
|
||||
<span>Добавлено пропущенных: {session.evidenceSummary.missedObjectAddedCount}</span>
|
||||
<small>Это проверка авторазметки, а не независимая контрольная разметка.</small>
|
||||
</div>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
overlays={(
|
||||
<>
|
||||
<Window
|
||||
open={freezeOpen}
|
||||
title="Завершить проверку Worker 006"
|
||||
subtitle="Будут сохранены исходные авторамки, ваши исправления и итоговая разница. Результат не является независимой контрольной разметкой."
|
||||
size="md"
|
||||
closeOnBackdrop={!busy}
|
||||
closeOnEscape={!busy}
|
||||
onClose={() => !busy && setFreezeOpen(false)}
|
||||
footer={<WindowFooterActions><Button disabled={busy} onClick={() => setFreezeOpen(false)}>Отмена</Button><Button variant="accent" disabled={busy || !reviewerId.trim() || !candidateVisible || !classFree} onClick={() => void freeze()}>{busy ? "Завершаем" : "Завершить проверку"}</Button></WindowFooterActions>}
|
||||
>
|
||||
<div className="m48-review-workspace__freeze-form">
|
||||
<TextField label="Кто проверил" value={reviewerId} maxLength={96} placeholder="Имя или ID проверяющего" onChange={(event) => setReviewerId(event.target.value)} />
|
||||
<Checker checked={candidateVisible} label="Все авторамки Worker 006 просмотрены, найденные ошибки исправлены" onChange={setCandidateVisible} />
|
||||
<Checker checked={classFree} label="Проверка не назначает объектам семантические классы" onChange={setClassFree} />
|
||||
</div>
|
||||
</Window>
|
||||
<ToastStack items={toasts} onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export type M48BlindEvidenceMode = "camera" | "3d" | "plan";
|
||||
|
||||
interface M48EvidenceModeControlProps {
|
||||
mode: M48BlindEvidenceMode;
|
||||
cameraVisible: boolean;
|
||||
spatialAvailable: boolean;
|
||||
onModeChange: (mode: M48BlindEvidenceMode) => void;
|
||||
onCameraVisibleChange: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export function nextM48CameraVisibility(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
): boolean {
|
||||
return mode === "camera" ? true : !cameraVisible;
|
||||
}
|
||||
|
||||
export function nextM48SpatialMode(
|
||||
mode: M48BlindEvidenceMode,
|
||||
cameraVisible: boolean,
|
||||
selected: Exclude<M48BlindEvidenceMode, "camera">,
|
||||
): M48BlindEvidenceMode {
|
||||
if (mode !== selected) return selected;
|
||||
return cameraVisible ? "camera" : mode;
|
||||
}
|
||||
|
||||
export function M48EvidenceModeControls({
|
||||
mode,
|
||||
cameraVisible,
|
||||
spatialAvailable,
|
||||
onModeChange,
|
||||
onCameraVisibleChange,
|
||||
}: M48EvidenceModeControlProps) {
|
||||
const spatialMode = mode === "camera" ? null : mode;
|
||||
return (
|
||||
<div
|
||||
className="m48-evidence-mode-controls"
|
||||
role="group"
|
||||
aria-label="Каналы доказательства"
|
||||
>
|
||||
<IconButton
|
||||
label={cameraVisible ? "Скрыть правую камеру" : "Показать правую камеру"}
|
||||
aria-pressed={cameraVisible}
|
||||
disabled={spatialMode === null}
|
||||
onClick={() => onCameraVisibleChange(
|
||||
nextM48CameraVisibility(mode, cameraVisible),
|
||||
)}
|
||||
>
|
||||
<Icon name="video" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "3d" ? "Скрыть 3D" : "Показать 3D"}
|
||||
aria-pressed={spatialMode === "3d"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "3d")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "3d"));
|
||||
}}
|
||||
>
|
||||
<span className="m48-evidence-mode-controls__text" aria-hidden="true">3D</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={spatialMode === "plan" ? "Скрыть план" : "Показать план"}
|
||||
aria-pressed={spatialMode === "plan"}
|
||||
disabled={!spatialAvailable || (!cameraVisible && spatialMode === "plan")}
|
||||
onClick={() => {
|
||||
if (!spatialAvailable) return;
|
||||
onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan"));
|
||||
}}
|
||||
>
|
||||
<Icon name="plan" size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function M48EvidenceModeRail(props: M48EvidenceModeControlProps) {
|
||||
return (
|
||||
<GlassSurface
|
||||
className="m48-evidence-mode-rail"
|
||||
tone="strong"
|
||||
radius="pill"
|
||||
padding="sm"
|
||||
materialRim={false}
|
||||
role="toolbar"
|
||||
aria-label="Режимы CAMERA, 3D и план"
|
||||
>
|
||||
<M48EvidenceModeControls {...props} />
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
fetchM48GateStatus,
|
||||
type M48GateStatus,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
import type { LaboratoryAnnotationAction } from "../../contracts";
|
||||
import { M48CorrectionWorkspace } from "./M48BlindReviewWorkspace";
|
||||
|
||||
export function useM48ReviewCapability({
|
||||
selectedWorkId,
|
||||
initialGate,
|
||||
onActionChange,
|
||||
}: {
|
||||
selectedWorkId: string;
|
||||
initialGate: M48GateStatus | null;
|
||||
onActionChange: (action: LaboratoryAnnotationAction | null) => void;
|
||||
}): { workspace: ReactNode; active: boolean } {
|
||||
const [gate, setGate] = useState(initialGate);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ownsAction = useRef(false);
|
||||
const actionTrigger = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => setGate(initialGate), [initialGate]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!gate) return;
|
||||
void fetchM48GateStatus(gate.packId).then(setGate).catch(() => undefined);
|
||||
}, [gate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedWorkId !== "m48-object-centric-quality" || !gate) {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
ownsAction.current = true;
|
||||
onActionChange({
|
||||
label: open
|
||||
? "Рабочая область открыта"
|
||||
: "Проверить Worker 006",
|
||||
disabled: Boolean(open) || gate.evaluated,
|
||||
onClick: () => {
|
||||
actionTrigger.current = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
setOpen(true);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
if (ownsAction.current) {
|
||||
onActionChange(null);
|
||||
ownsAction.current = false;
|
||||
}
|
||||
};
|
||||
}, [gate, onActionChange, open, selectedWorkId]);
|
||||
|
||||
if (!gate || !open) return { workspace: null, active: false };
|
||||
return {
|
||||
active: true,
|
||||
workspace: (
|
||||
<M48CorrectionWorkspace
|
||||
gate={gate}
|
||||
returnFocusTarget={actionTrigger.current}
|
||||
onClose={() => setOpen(false)}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchM48ReviewSpatialFrame,
|
||||
type M48ReviewClipSource,
|
||||
type M48ReviewSpatialFrame,
|
||||
} from "../../../core/laboratory/m48ObjectCentricQuality";
|
||||
|
||||
export const M48_SPATIAL_PREFETCH_FRAME_COUNT = 14;
|
||||
export const M48_SPATIAL_CACHE_FRAME_LIMIT = 24;
|
||||
|
||||
export function m48SpatialPlaybackWindow(
|
||||
frames: readonly { sequence: number }[],
|
||||
sequence: number,
|
||||
frameCount = M48_SPATIAL_PREFETCH_FRAME_COUNT,
|
||||
): readonly number[] {
|
||||
if (!frames.length || frameCount <= 0) return [];
|
||||
const currentIndex = Math.max(0, frames.findIndex((frame) => frame.sequence === sequence));
|
||||
const count = Math.min(frameCount, frames.length);
|
||||
return Array.from({ length: count }, (_, offset) => (
|
||||
frames[(currentIndex + offset) % frames.length]!.sequence
|
||||
));
|
||||
}
|
||||
|
||||
export function trimM48SpatialPlaybackCache<T>(
|
||||
cache: Map<number, T>,
|
||||
protectedSequences: readonly number[],
|
||||
limit = M48_SPATIAL_CACHE_FRAME_LIMIT,
|
||||
): void {
|
||||
const protectedSet = new Set(protectedSequences);
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
if (!protectedSet.has(key)) cache.delete(key);
|
||||
}
|
||||
for (const key of cache.keys()) {
|
||||
if (cache.size <= limit) return;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Spatial evidence для текущего кадра недоступно.";
|
||||
}
|
||||
|
||||
function aborted(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useM48SpatialClipPlayback({
|
||||
packId,
|
||||
clip,
|
||||
sequence,
|
||||
enabled,
|
||||
}: {
|
||||
packId: string;
|
||||
clip: M48ReviewClipSource | null;
|
||||
sequence: number;
|
||||
enabled: boolean;
|
||||
}): {
|
||||
frame: M48ReviewSpatialFrame | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
} {
|
||||
const cacheRef = useRef(new Map<number, M48ReviewSpatialFrame>());
|
||||
const errorsRef = useRef(new Map<number, string>());
|
||||
const inFlightRef = useRef(new Map<number, Promise<void>>());
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [, setRevision] = useState(0);
|
||||
const sourceKey = enabled && clip ? `${packId}:${clip.clipId}` : null;
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
controllerRef.current?.abort();
|
||||
controllerRef.current = sourceKey ? new AbortController() : null;
|
||||
cacheRef.current.clear();
|
||||
errorsRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
setRevision((value) => value + 1);
|
||||
return () => controllerRef.current?.abort();
|
||||
}, [sourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = controllerRef.current;
|
||||
if (!sourceKey || !clip || !controller || controller.signal.aborted) return;
|
||||
const generation = generationRef.current;
|
||||
const wanted = m48SpatialPlaybackWindow(clip.frames, sequence);
|
||||
|
||||
const load = (nextSequence: number): Promise<void> => {
|
||||
const existing = inFlightRef.current.get(nextSequence);
|
||||
if (existing) return existing;
|
||||
if (cacheRef.current.has(nextSequence)) return Promise.resolve();
|
||||
const request = fetchM48ReviewSpatialFrame(
|
||||
packId,
|
||||
clip.clipId,
|
||||
nextSequence,
|
||||
{ signal: controller.signal },
|
||||
).then((next) => {
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return;
|
||||
cacheRef.current.set(nextSequence, next);
|
||||
errorsRef.current.delete(nextSequence);
|
||||
trimM48SpatialPlaybackCache(cacheRef.current, wanted);
|
||||
setRevision((value) => value + 1);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted || aborted(caught) || generation !== generationRef.current) return;
|
||||
errorsRef.current.set(nextSequence, errorMessage(caught));
|
||||
setRevision((value) => value + 1);
|
||||
}).finally(() => {
|
||||
if (generation === generationRef.current) inFlightRef.current.delete(nextSequence);
|
||||
});
|
||||
inFlightRef.current.set(nextSequence, request);
|
||||
return request;
|
||||
};
|
||||
|
||||
for (const nextSequence of wanted) void load(nextSequence);
|
||||
}, [clip, packId, sequence, sourceKey]);
|
||||
|
||||
const frame = sourceKey ? cacheRef.current.get(sequence) ?? null : null;
|
||||
return {
|
||||
frame,
|
||||
loading: Boolean(sourceKey && !frame && !errorsRef.current.has(sequence)),
|
||||
error: sourceKey ? errorsRef.current.get(sequence) ?? null : null,
|
||||
};
|
||||
}
|
||||
@@ -63,6 +63,20 @@ interface KnownWorkDefinition {
|
||||
const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг";
|
||||
|
||||
const KNOWN_WORKS: Readonly<Record<Exclude<LaboratoryWorkId, `session:${string}`>, KnownWorkDefinition>> = {
|
||||
"m48-object-centric-quality": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-object-centric-source-quality",
|
||||
experimentName: "RAVNOVES00 class-free object-centric source quality",
|
||||
variantName: "M4.8 · Worker 006 assisted correction → evidence delta",
|
||||
},
|
||||
"m48-small-static-passage-regression": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + prediction-free spatial evidence`,
|
||||
experimentId: "m48-small-static-passage-regression",
|
||||
experimentName: "M4.8 · small static passage regression",
|
||||
variantName: "M4.8R1 · Worker 006 small-static assisted baseline",
|
||||
},
|
||||
"m47-reference-graph-shadow": {
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
profileName: (rigLabel) => `${rig(rigLabel)} RIGHT · Camera + LiDAR dual evidence`,
|
||||
|
||||
@@ -19,6 +19,8 @@ function mergeResults(
|
||||
): AdvancedLaboratoryResults {
|
||||
return {
|
||||
m47Graph: next.m47Graph ?? current.m47Graph,
|
||||
m48: next.m48 ?? current.m48,
|
||||
m48SmallStatic: next.m48SmallStatic ?? current.m48SmallStatic,
|
||||
m4Threat: next.m4Threat ?? current.m4Threat,
|
||||
l3: next.l3 ?? current.l3,
|
||||
l31: next.l31 ?? current.l31,
|
||||
@@ -111,7 +113,14 @@ export function useAdvancedLaboratoryCatalog({
|
||||
|| advancedLaboratoryResultAvailable(selectedWorkId, results)
|
||||
) return;
|
||||
const indexedResultId = index.find((item) => item.workId === selectedWorkId)?.resultId;
|
||||
if (selectedWorkId === "m47-reference-graph-shadow" && !indexedResultId) return;
|
||||
if (
|
||||
[
|
||||
"m47-reference-graph-shadow",
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
].includes(selectedWorkId)
|
||||
&& !indexedResultId
|
||||
) return;
|
||||
const controller = new AbortController();
|
||||
setLoadingWorkId(selectedWorkId);
|
||||
setFailedWorkId(null);
|
||||
|
||||
@@ -963,6 +963,46 @@ test("LAB entry defaults atomically to the freshest pipeline, experiment and run
|
||||
});
|
||||
});
|
||||
|
||||
test("M4.8R1 stays in the current pipeline as a separate experiment and run", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
knownWorks: [],
|
||||
advancedIndex: [
|
||||
{
|
||||
workId: "m48-object-centric-quality",
|
||||
resultId: `m48-object-quality-pack-${"8".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-24T12:00:00Z",
|
||||
},
|
||||
{
|
||||
workId: "m48-small-static-passage-regression",
|
||||
resultId: `m48-small-static-passage-regression-${"9".repeat(64)}`,
|
||||
createdAtUtc: "2026-08-24T18:30:00Z",
|
||||
},
|
||||
],
|
||||
publishedWorks: [],
|
||||
});
|
||||
|
||||
const profiles = buildLaboratoryProfiles(catalog);
|
||||
assert.deepEqual(profiles.map(({ id }) => id), [
|
||||
"rig-dual-evidence-virtual-corridor-v1",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
experimentOptionsForProfile(profiles[0].id, catalog).map(({ id }) => id),
|
||||
[
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-object-centric-source-quality",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
freshestLaboratorySelection(catalog),
|
||||
{
|
||||
profileId: "rig-dual-evidence-virtual-corridor-v1",
|
||||
experimentId: "m48-small-static-passage-regression",
|
||||
workId: "m48-small-static-passage-regression",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("E46E is exposed as the newest independent NVIDIA pipeline", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -264,6 +264,11 @@ test("LAB product surface has a compact canonical summary and no roadmap footer"
|
||||
]);
|
||||
|
||||
assert.match(presentationSource, /export function LaboratorySummary/);
|
||||
assert.match(presentationSource, /const \[expanded, setExpanded\] = useState\(false\)/);
|
||||
assert.match(presentationSource, /aria-expanded=\{expanded\}/);
|
||||
assert.match(presentationSource, /className="laboratory-summary__details" hidden=\{!expanded\}/);
|
||||
assert.match(presentationSource, /className="laboratory-summary__actions"/);
|
||||
assert.match(presentationSource, /name="chevron-down"/);
|
||||
assert.match(presentationSource, /export function LaboratoryWorkTemplate/);
|
||||
assert.match(
|
||||
presentationSource,
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let decodeM48GateStatus;
|
||||
let decodeM48ReviewSourceCatalog;
|
||||
let decodeM48CorrectionSession;
|
||||
let decodeM48SpatialFrame;
|
||||
let decodeM48QualityResult;
|
||||
let decodeM48FailureAtlas;
|
||||
let decodeM48FailureCase;
|
||||
let assertM48BlindPayload;
|
||||
let interpolateM48Extent;
|
||||
let createM48Tracklet;
|
||||
let nextM48ObjectId;
|
||||
let laboratoryMetricLegendEntries;
|
||||
let nearestLaboratoryRecordedClipFrame;
|
||||
let laboratoryRecordedClipEndExclusiveNs;
|
||||
let m48SpatialPlaybackWindow;
|
||||
let trimM48SpatialPlaybackCache;
|
||||
let nextM48CameraVisibility;
|
||||
let nextM48SpatialMode;
|
||||
|
||||
const packId = `m48-object-quality-pack-${"a".repeat(64)}`;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({
|
||||
decodeM48GateStatus,
|
||||
decodeM48ReviewSourceCatalog,
|
||||
decodeM48CorrectionSession,
|
||||
decodeM48SpatialFrame,
|
||||
decodeM48QualityResult,
|
||||
decodeM48FailureAtlas,
|
||||
decodeM48FailureCase,
|
||||
assertM48BlindPayload,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m48ObjectCentricQuality.ts"));
|
||||
({
|
||||
interpolateM48Extent,
|
||||
createM48Tracklet,
|
||||
nextM48ObjectId,
|
||||
} = await server.ssrLoadModule("/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx"));
|
||||
({
|
||||
nearestLaboratoryRecordedClipFrame,
|
||||
laboratoryRecordedClipEndExclusiveNs,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx",
|
||||
));
|
||||
({ laboratoryMetricLegendEntries } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx",
|
||||
));
|
||||
({
|
||||
m48SpatialPlaybackWindow,
|
||||
trimM48SpatialPlaybackCache,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts",
|
||||
));
|
||||
({
|
||||
nextM48CameraVisibility,
|
||||
nextM48SpatialMode,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => server?.close());
|
||||
|
||||
function authority() {
|
||||
return {
|
||||
mode: "replay-simulated",
|
||||
physical_live: false,
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function source() {
|
||||
const clips = Array.from({ length: 20 }, (_, index) => {
|
||||
const start = index * 2 + 1;
|
||||
return {
|
||||
clip_id: `clip-${String(index + 1).padStart(2, "0")}`,
|
||||
start_sequence: start,
|
||||
end_sequence: start + 1,
|
||||
frames: [start, start + 1].map((sequence) => ({
|
||||
sequence,
|
||||
source_time_ns: (sequence - 1) * 100_000_000,
|
||||
camera_fragment_sha256: String(index % 10).repeat(64),
|
||||
camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/camera`,
|
||||
spatial_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-${String(index + 1).padStart(2, "0")}/frames/${sequence}/spatial`,
|
||||
})),
|
||||
};
|
||||
});
|
||||
return {
|
||||
schema_version: "missioncore.m48-neutral-object-review-source/v2",
|
||||
pack_id: packId,
|
||||
state: "prediction-blind-neutral-source-projection",
|
||||
contract: {
|
||||
contract_id: "m48-class-free-object-tracklet/v1",
|
||||
label_unit: "clip-local-object-tracklet",
|
||||
semantic_classes_allowed: false,
|
||||
extent: "normalized-xyxy-sparse-keyframes",
|
||||
extent_interpolation: "linear-between-bounding-keyframes",
|
||||
visibility: ["occluded", "partial", "visible"],
|
||||
state_segments: {
|
||||
coverage: "contiguous-full-tracklet-lifetime",
|
||||
geometry_association: ["associated", "ineligible", "unavailable", "unknown"],
|
||||
freshness: ["current", "held", "stale", "unavailable"],
|
||||
motion: ["moving", "static", "unknown", "unsupported"],
|
||||
threat: ["not-threat", "threat", "unknown"],
|
||||
critical_corridor_obstacle: "boolean",
|
||||
},
|
||||
},
|
||||
camera_playback: {
|
||||
schema_version: "missioncore.laboratory-recorded-clip-camera/v1",
|
||||
source_id: "recorded.camera.test",
|
||||
label: "Записанная RIGHT камера",
|
||||
manifest_url: "/api/v1/observation-sessions/recorded-session/media/recorded-video-test/manifest",
|
||||
manifest_generation_sha256: "f".repeat(64),
|
||||
byte_length: 123456,
|
||||
media_type: "video/mp4",
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 4,
|
||||
segment_count: 40,
|
||||
seekable: true,
|
||||
synchronization: "host-arrival-best-effort",
|
||||
transport: "recorded-fmp4-manifest",
|
||||
fragment_binding: "pack-frozen-sha256-verified",
|
||||
},
|
||||
clips,
|
||||
clip_count: clips.length,
|
||||
frame_count: 40,
|
||||
strata_included: false,
|
||||
split_included: false,
|
||||
candidate_identity_included: false,
|
||||
frozen_predictions_included: false,
|
||||
model_scores_included: false,
|
||||
semantic_class_task_included: false,
|
||||
evidence_capabilities: {
|
||||
state: "prediction-free-spatial-evidence-available",
|
||||
camera_epoch_time: true,
|
||||
current_point_cloud_body_xyz_m: true,
|
||||
rig: true,
|
||||
virtual_corridor: true,
|
||||
raw_lidar: false,
|
||||
graph_output: false,
|
||||
graph_boxes_ids_scores: false,
|
||||
label_authority: {
|
||||
obstacle_presence_and_extent: true,
|
||||
geometry_association: true,
|
||||
freshness: true,
|
||||
motion: true,
|
||||
threat: true,
|
||||
critical_corridor_obstacle: true,
|
||||
},
|
||||
fail_closed_reason: null,
|
||||
},
|
||||
access: "prediction-free-strata-free-source-read-only",
|
||||
};
|
||||
}
|
||||
|
||||
test("M4.8 pack status stays fail-closed and command-free", () => {
|
||||
const result = decodeM48GateStatus({
|
||||
schema_version: "missioncore.m48-object-quality-pack-status/v1",
|
||||
pack_id: packId,
|
||||
created_at_utc: "2026-08-24T10:00:00Z",
|
||||
state: "prepared",
|
||||
metrics: { clip_count: 20, frame_count: 1020, seed_object_count: 5236, correction_state: "not-started", correction_reviewed_clip_count: 0, correction_complete: false, review_slot_count: 0, frozen_reviewer_count: 0, required_frozen_reviewer_count: 2 },
|
||||
decision: { review_collection_ready: true, two_distinct_reviews_frozen: false, adjudication_unlocked: false, adjudication_frozen: false, evaluated: false, next_action: "freeze two reviews" },
|
||||
truth_seal_id: null,
|
||||
quality_result_id: null,
|
||||
blindness: { candidate_identity_included: false, frozen_predictions_included: false, model_scores_included: false, semantic_class_task_included: false, strata_included: false },
|
||||
authority: authority(),
|
||||
access: "neutral-workflow-status-read-only",
|
||||
});
|
||||
assert.equal(result.packId, packId);
|
||||
assert.equal(result.authority.commandsEnabled, false);
|
||||
assert.equal(result.correctionState, "not-started");
|
||||
assert.equal(result.seedObjectCount, 5236);
|
||||
assert.equal(result.requiredFrozenReviewerCount, 2);
|
||||
});
|
||||
|
||||
test("blind source admits fragment identity and structurally rejects candidate material", () => {
|
||||
const result = decodeM48ReviewSourceCatalog(source());
|
||||
assert.equal(result.clips.length, 20);
|
||||
assert.equal(result.cameraPlayback.segmentCount, 40);
|
||||
assert.equal(result.cameraPlayback.manifestGenerationSha256, "f".repeat(64));
|
||||
assert.equal(result.clips[0].frames[0].cameraFragmentSha256.length, 64);
|
||||
assert.equal(result.evidenceCapabilities.currentPointCloudBodyXyzM, true);
|
||||
|
||||
for (const leak of [
|
||||
{ predictions: [] },
|
||||
{ strata: ["no-object"] },
|
||||
{ model: { id: "candidate" } },
|
||||
{ graph: [] },
|
||||
{ split: "validation" },
|
||||
{ semantic_class: "car" },
|
||||
]) assert.throws(() => assertM48BlindPayload(leak), /blind|candidate|stratum/i);
|
||||
|
||||
const staleName = source();
|
||||
staleName.clips[0].frames[0].camera_frame_sha256 = staleName.clips[0].frames[0].camera_fragment_sha256;
|
||||
delete staleName.clips[0].frames[0].camera_fragment_sha256;
|
||||
assert.throws(() => decodeM48ReviewSourceCatalog(staleName), /состав полей/);
|
||||
});
|
||||
|
||||
test("candidate-assisted correction admits frozen boxes without claiming independent truth", () => {
|
||||
const candidate = {
|
||||
schema_version: "missioncore.m48-assisted-object-correction-session/v1",
|
||||
pack_id: packId,
|
||||
session_id: `m48-correction-session-${"b".repeat(64)}`,
|
||||
title: "Worker 006 correction",
|
||||
revision: 0,
|
||||
state: "draft",
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
updated_at_utc: "2026-08-24T12:00:00Z",
|
||||
clips: [{
|
||||
clip_id: "clip-01",
|
||||
start_sequence: 1,
|
||||
end_sequence: 2,
|
||||
review_state: "pending",
|
||||
no_object: null,
|
||||
tracklets: [{
|
||||
object_id: "proposal-0-0",
|
||||
first_sequence: 1,
|
||||
last_sequence: 1,
|
||||
keyframes: [{ sequence: 1, extent_xyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" }],
|
||||
state_segments: [{ start_sequence: 1, end_sequence: 1, geometry_association: "associated", freshness: "current", motion: "unknown", threat: "unknown", critical_corridor_obstacle: false }],
|
||||
notes: null,
|
||||
}],
|
||||
notes: null,
|
||||
}],
|
||||
progress: { reviewed_clip_count: 0, clip_count: 1, complete: false },
|
||||
seed_summary: { worker_id: "006", clip_count: 1, frame_count: 2, object_count: 1, prediction_rows_sha256: "c".repeat(64) },
|
||||
evidence_summary: null,
|
||||
reviewer_id: null,
|
||||
submitted_at_utc: null,
|
||||
submission_sha256: null,
|
||||
assistance: { mode: "frozen-candidate-seeded", candidate_predictions_seen: true, model_scores_seen: false, semantic_class_task_seen: false, independent_truth_eligible: false },
|
||||
authority: authority(),
|
||||
access: "capability-protected-candidate-assisted-correction",
|
||||
};
|
||||
const decoded = decodeM48CorrectionSession(candidate);
|
||||
assert.equal(decoded.seedWorkerId, "006");
|
||||
assert.equal(decoded.seedObjectCount, 1);
|
||||
assert.equal(decoded.clips[0].tracklets[0].objectId, "proposal-0-0");
|
||||
|
||||
candidate.assistance.independent_truth_eligible = true;
|
||||
assert.throws(() => decodeM48CorrectionSession(candidate), /assistance|truth/i);
|
||||
});
|
||||
|
||||
test("spatial frame accepts only current points, virtual rig and corridor", () => {
|
||||
const result = decodeM48SpatialFrame({
|
||||
schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1",
|
||||
pack_id: packId,
|
||||
clip_id: "clip-01",
|
||||
sequence: 1,
|
||||
source_time_ns: 0,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.25]],
|
||||
rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false },
|
||||
corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 },
|
||||
occupied_voxel_size_m: 0.2,
|
||||
source_available: true,
|
||||
body_frame_available: true,
|
||||
candidate_identity_included: false,
|
||||
graph_boxes_ids_scores_included: false,
|
||||
frozen_predictions_included: false,
|
||||
strata_included: false,
|
||||
authority: authority(),
|
||||
access: "prediction-free-current-spatial-evidence-read-only",
|
||||
});
|
||||
assert.deepEqual(result.pointCloudBodyXyzM, [[1, 0, 0.25]]);
|
||||
assert.equal(result.sourceAvailable, true);
|
||||
assert.equal(result.bodyFrameAvailable, true);
|
||||
assert.equal(result.corridor.forwardLengthM, 8);
|
||||
|
||||
const unavailable = {
|
||||
schema_version: "missioncore.m48-neutral-object-review-spatial-frame/v1",
|
||||
pack_id: packId,
|
||||
clip_id: "clip-01",
|
||||
sequence: 1,
|
||||
source_time_ns: 0,
|
||||
point_cloud_body_xyz_m: [[1, 0, 0.25]],
|
||||
rig: { profile_id: "virtual-rig", length_m: 1, width_m: 0.6, lidar_reference: "rear", nominal_sensor_height_m: 1.25, physical_mount_claimed: false },
|
||||
corridor: { profile_id: "corridor", forward_length_m: 8, rear_margin_m: 0.2, lateral_clearance_m: 0.25, half_width_m: 0.55, prediction_horizon_seconds: 5 },
|
||||
occupied_voxel_size_m: 0.2,
|
||||
source_available: true,
|
||||
body_frame_available: false,
|
||||
candidate_identity_included: false,
|
||||
graph_boxes_ids_scores_included: false,
|
||||
frozen_predictions_included: false,
|
||||
strata_included: false,
|
||||
authority: authority(),
|
||||
access: "prediction-free-current-spatial-evidence-read-only",
|
||||
};
|
||||
assert.throws(() => decodeM48SpatialFrame(unavailable), /unavailable spatial frame/);
|
||||
});
|
||||
|
||||
test("tracklet extents interpolate on the shared clip timeline", () => {
|
||||
const tracklet = {
|
||||
objectId: "object-01",
|
||||
firstSequence: 10,
|
||||
lastSequence: 20,
|
||||
keyframes: [
|
||||
{ sequence: 10, extentXyxy: [0.1, 0.2, 0.3, 0.4], visibility: "visible" },
|
||||
{ sequence: 20, extentXyxy: [0.2, 0.3, 0.4, 0.5], visibility: "visible" },
|
||||
],
|
||||
stateSegments: [],
|
||||
notes: null,
|
||||
};
|
||||
const midpoint = interpolateM48Extent(tracklet, 15);
|
||||
assert.ok(midpoint.every((value, index) => Math.abs(value - [0.15, 0.25, 0.35, 0.45][index]) < 1e-12));
|
||||
assert.equal(interpolateM48Extent(tracklet, 9), null);
|
||||
});
|
||||
|
||||
test("manual correction is frame-local, fails closed without spatial authority and never reuses a deleted id", () => {
|
||||
const clip = decodeM48ReviewSourceCatalog(source()).clips[0];
|
||||
const tracklet = createM48Tracklet("object-01", clip, [0.1, 0.2, 0.3, 0.4], false, clip.endSequence);
|
||||
assert.equal(tracklet.firstSequence, clip.endSequence);
|
||||
assert.equal(tracklet.lastSequence, clip.endSequence);
|
||||
assert.equal(tracklet.stateSegments[0].startSequence, clip.endSequence);
|
||||
assert.equal(tracklet.stateSegments[0].endSequence, clip.endSequence);
|
||||
assert.equal(interpolateM48Extent(tracklet, clip.startSequence), null);
|
||||
assert.deepEqual(interpolateM48Extent(tracklet, clip.endSequence), [0.1, 0.2, 0.3, 0.4]);
|
||||
assert.equal(tracklet.stateSegments[0].geometryAssociation, "unavailable");
|
||||
assert.equal(tracklet.stateSegments[0].motion, "unsupported");
|
||||
assert.equal(nextM48ObjectId([
|
||||
tracklet,
|
||||
{ ...tracklet, objectId: "object-03" },
|
||||
]), "object-02");
|
||||
});
|
||||
|
||||
test("shared recorded clip clock selects exact frames and one stable loop boundary", () => {
|
||||
const frames = [
|
||||
{ sequence: 11, sourceTimeNs: 1_000_000_000 },
|
||||
{ sequence: 12, sourceTimeNs: 1_100_000_000 },
|
||||
{ sequence: 13, sourceTimeNs: 1_200_000_000 },
|
||||
];
|
||||
assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_049_000_000).sequence, 11);
|
||||
assert.equal(nearestLaboratoryRecordedClipFrame(frames, 1_051_000_000).sequence, 12);
|
||||
assert.equal(laboratoryRecordedClipEndExclusiveNs(frames), 1_300_000_000);
|
||||
});
|
||||
|
||||
test("M4.8 camera and spatial visibility are independent without an empty viewer", () => {
|
||||
assert.equal(nextM48CameraVisibility("camera", true), true);
|
||||
assert.equal(nextM48CameraVisibility("3d", true), false);
|
||||
assert.equal(nextM48CameraVisibility("3d", false), true);
|
||||
assert.equal(nextM48SpatialMode("camera", true, "3d"), "3d");
|
||||
assert.equal(nextM48SpatialMode("3d", true, "3d"), "camera");
|
||||
assert.equal(nextM48SpatialMode("3d", false, "3d"), "3d");
|
||||
assert.equal(nextM48SpatialMode("3d", false, "plan"), "plan");
|
||||
});
|
||||
|
||||
test("M4.8 spatial playback prefetches across the loop and remains bounded", () => {
|
||||
const frames = [11, 12, 13, 14, 15].map((sequence) => ({ sequence }));
|
||||
assert.deepEqual(m48SpatialPlaybackWindow(frames, 14, 4), [14, 15, 11, 12]);
|
||||
|
||||
const cache = new Map(Array.from({ length: 30 }, (_, index) => [index + 1, index]));
|
||||
trimM48SpatialPlaybackCache(cache, [28, 29, 30, 1], 24);
|
||||
assert.equal(cache.size, 24);
|
||||
for (const sequence of [28, 29, 30, 1]) assert.equal(cache.has(sequence), true);
|
||||
});
|
||||
|
||||
test("post-seal result and atlas reveal bounded graph material only after evaluation", () => {
|
||||
const resultId = `m48-object-quality-result-${"b".repeat(64)}`;
|
||||
const truthId = `m48-object-truth-seal-${"c".repeat(64)}`;
|
||||
const metricNames = [
|
||||
"terminal_outcome_accounting",
|
||||
"false_free_space_claims",
|
||||
"obstacle_presence_precision",
|
||||
"obstacle_presence_recall",
|
||||
"critical_corridor_obstacle_recall",
|
||||
"geometry_association_correctness",
|
||||
"freshness_correctness",
|
||||
"motion_decision_correctness",
|
||||
"critical_threat_not_threat",
|
||||
"unknown_prediction_count",
|
||||
"failure_case_count",
|
||||
];
|
||||
const metrics = Object.fromEntries(metricNames.map((name) => [name, name.endsWith("_count") || name === "false_free_space_claims" ? 0 : 1]));
|
||||
const quality = decodeM48QualityResult({
|
||||
schema_version: "missioncore.m48-object-centric-quality-result-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
truth_seal_id: truthId,
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
status: "accepted-object-centric-source-quality",
|
||||
accepted: true,
|
||||
metrics,
|
||||
gates: { obstacle_presence_precision: true },
|
||||
unknown_causes: {},
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
ground_truth: false,
|
||||
access: "evaluated-object-quality-summary-read-only",
|
||||
});
|
||||
assert.equal(quality.accepted, true);
|
||||
|
||||
const caseId = `m48-failure-${"d".repeat(64)}`;
|
||||
const atlas = decodeM48FailureAtlas({
|
||||
schema_version: "missioncore.m48-object-quality-failure-atlas-view/v1",
|
||||
result_id: resultId,
|
||||
cases: [{ schema_version: "missioncore.m48-object-quality-failure/v1", failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high", terminal_outcome: "delivered", unmatched_prediction_ids: [], unmatched_truth_object_ids: ["object-01"] }],
|
||||
case_count: 1,
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
access: "evaluated-bounded-failure-atlas-read-only",
|
||||
});
|
||||
assert.equal(atlas[0].caseId, caseId);
|
||||
assert.equal(atlas[0].split, "validation");
|
||||
|
||||
const failure = decodeM48FailureCase({
|
||||
schema_version: "missioncore.m48-object-quality-failure-case-view/v1",
|
||||
result_id: resultId,
|
||||
case: { failure_case_id: caseId, clip_id: "clip-01", split: "validation", sequence: 1, causes: ["presence-false-negative"], severity: "high" },
|
||||
frame: { sequence: 1, source_time_ns: 0, camera_fragment_sha256: "e".repeat(64), camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/clip-01/frames/1/camera`, spatial_url: null },
|
||||
truth: [{ object_id: "object-01", extent_xyxy: [0.1, 0.1, 0.3, 0.4], geometry_association: "associated", freshness: "current", motion: "static", threat: "threat" }],
|
||||
graph: [],
|
||||
prediction_material_release: "post-adjudication-seal-evaluation-only",
|
||||
authority: authority(),
|
||||
access: "evaluated-failure-case-read-only",
|
||||
});
|
||||
assert.equal(failure.truth[0].objectId, "object-01");
|
||||
assert.equal(failure.frame.cameraFragmentSha256, "e".repeat(64));
|
||||
});
|
||||
|
||||
test("M4.8 evidence keeps the shared viewer stage stretched over the visual frame", () => {
|
||||
const stylesheet = readFileSync(
|
||||
new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const player = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const shared = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryRecordedClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const visual = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/M48FailureAtlasVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const evidenceViewer = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryEvidenceViewer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const laboratoryStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-evidence-viewer.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const modeControls = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/\.laboratory-recorded-clip-player__camera\s*>\s*\.recorded-media-player\s*\{[^}]*position:\s*absolute/s,
|
||||
);
|
||||
assert.match(player, /<LaboratoryRecordedClipPlayer/);
|
||||
assert.doesNotMatch(player, /fetch\(|image\/jpeg|createM48LatestFrameLoader|Следующий camera frame/);
|
||||
assert.match(shared, /missioncore\.laboratory-recorded-clip-viewer\/v1/);
|
||||
assert.match(shared, /<RecordedFmp4Player/);
|
||||
assert.match(shared, /<SplitPane/);
|
||||
assert.equal((shared.match(/<RecordedFmp4Player/g) ?? []).length, 1);
|
||||
assert.match(shared, /orientation="vertical"/);
|
||||
assert.match(shared, /resizable=\{companionVisible\}/);
|
||||
assert.match(shared, /separatorLabel="Изменить размер 3D\/ПЛАН и правой камеры"/);
|
||||
assert.match(shared, /segmentSequence=\{frame\?\.sequence\}/);
|
||||
assert.match(player, /cameraPresentation=\{spatialVisible/);
|
||||
assert.match(player, /effectiveCameraVisible \? "companion" : "hidden"/);
|
||||
assert.match(player, /cameraVisible: boolean/);
|
||||
assert.match(player, /continuousPlayback/);
|
||||
assert.doesNotMatch(player, /continuousPlayback=\{mode === "camera"\}/);
|
||||
assert.match(visual, /chromeLayout="stacked"/);
|
||||
assert.match(visual, /modeControlsVisible=\{false\}/);
|
||||
assert.match(visual, /<M48EvidenceModeRail/);
|
||||
assert.match(modeControls, /<GlassSurface/);
|
||||
assert.match(modeControls, /className="m48-evidence-mode-rail"/);
|
||||
assert.match(modeControls, /radius="pill"/);
|
||||
assert.match(modeControls, /padding="sm"/);
|
||||
assert.match(modeControls, /materialRim=\{false\}/);
|
||||
assert.match(modeControls, /<Icon name="video" size=\{16\}/);
|
||||
assert.match(modeControls, /<Icon name="plan" size=\{16\}/);
|
||||
assert.match(modeControls, />3D<\/span>/);
|
||||
assert.doesNotMatch(modeControls, /<SegmentedControl|<Button/);
|
||||
assert.doesNotMatch(modeControls, /value: "camera"[\s\S]*value: "3d"/);
|
||||
assert.match(evidenceViewer, /chromeLayout\?: "overlay" \| "stacked"/);
|
||||
assert.match(evidenceViewer, /laboratory-evidence-viewer__header/);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/data-chrome-layout="stacked"[\s\S]*?grid-template-rows:\s*auto minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/data-chrome-layout="stacked"[\s\S]*?laboratory-recorded-clip-player\s*\{[\s\S]*?gap:\s*0/,
|
||||
);
|
||||
assert.doesNotMatch(stylesheet, /grid-template-columns:[^;]*31%/);
|
||||
assert.doesNotMatch(
|
||||
stylesheet,
|
||||
/border-(?:left|top):\s*1px solid var\(--nodedc-glass-outline\)/,
|
||||
);
|
||||
assert.match(
|
||||
stylesheet,
|
||||
/nodedc-split-pane__separator::before\s*\{[^}]*background:\s*transparent/s,
|
||||
);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/laboratory-evidence-viewer__header\s*\{[^}]*border:\s*0/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("LAB workspaces cannot fork the frozen recorded clip transport", () => {
|
||||
const root = new URL("../src/workspaces/laboratory/", import.meta.url);
|
||||
const sourceFiles = readdirSync(root, { recursive: true })
|
||||
.filter((name) => /\.(?:ts|tsx)$/.test(String(name)));
|
||||
for (const name of sourceFiles) {
|
||||
const sourceText = readFileSync(new URL(String(name), root), "utf8");
|
||||
assert.doesNotMatch(
|
||||
sourceText,
|
||||
/new\s+MediaSource|requestVideoFrameCallback|URL\.createObjectURL|image\/jpeg|setTimeout\s*\(|<RecordedFmp4Player/,
|
||||
`${name} forks missioncore.laboratory-recorded-clip-viewer/v1`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("M4.8 review and adjudication share one focus-owning workspace frame", () => {
|
||||
const review = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const adjudication = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const frame = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const frameStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-review-workspace.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const m48Styles = readFileSync(
|
||||
new URL("../src/styles/m48-object-centric-quality.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const recordedStyles = readFileSync(
|
||||
new URL("../src/styles/laboratory-recorded-clip-player.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
for (const workspace of [review, adjudication]) {
|
||||
assert.match(workspace, /LaboratoryReviewWorkspaceFrame/);
|
||||
assert.doesNotMatch(workspace, /createPortal|className="m48-review-workspace"/);
|
||||
assert.doesNotMatch(
|
||||
workspace,
|
||||
/@rerun-io|RerunViewer|Blueprint|view_id|blueprint_id/,
|
||||
"M4.8 must reuse shared viewers instead of defining a per-LAB Rerun layout",
|
||||
);
|
||||
}
|
||||
assert.match(frame, /document\.body\.style\.overflow = "hidden"/);
|
||||
assert.match(frame, /event\.key === "Escape"/);
|
||||
assert.match(frame, /keepFocusInside/);
|
||||
assert.match(frame, /returnFocusTarget\?\.isConnected/);
|
||||
assert.match(frame, /requestAnimationFrame\(\(\) => target\?\.focus\(\)\)/);
|
||||
|
||||
const player = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(player, /spatialFrame\.bodyFrameAvailable/);
|
||||
assert.match(player, /LiDAR в системе координат корпуса для этого кадра недоступен/);
|
||||
assert.match(player, /Number\(effectiveCameraVisible\) \+ Number\(spatialVisible\)/);
|
||||
assert.match(player, /data-spatial-sequence=\{spatialReady \? sequence : undefined\}/);
|
||||
assert.match(review, /<M48EvidenceModeRail/);
|
||||
assert.match(adjudication, /<M48EvidenceModeRail/);
|
||||
assert.match(review, /<FieldFrame label="Выбор клипа"/);
|
||||
assert.match(review, /label="Предыдущий клип"/);
|
||||
assert.match(review, /label="Следующий клип"/);
|
||||
assert.match(review, /<IconButton\s+label="Добавить объект"/);
|
||||
assert.doesNotMatch(review, />\s*Добавить объект\s*<\/Button>/);
|
||||
assert.ok(review.indexOf('label="Предыдущий клип"') < review.indexOf('<FieldFrame label="Выбор клипа"'));
|
||||
assert.ok(review.indexOf('className="m48-review-workspace__clip-reviewed"') < review.indexOf('className="m48-review-workspace__topbar-end"'));
|
||||
assert.doesNotMatch(review, /m48-review-workspace__topbar-center/);
|
||||
assert.ok(review.indexOf('label="Добавить объект"') < review.indexOf('label="Закрыть проверку M4.8"'));
|
||||
assert.doesNotMatch(review, /label="Сохранить всю проверку"/);
|
||||
assert.match(review, /void save\(nextDrafts, "clip-status", \{ drafts, dirty \}\)/);
|
||||
assert.match(review, /title: reason === "clip-status" \? "Статус клипа сохранён"/);
|
||||
assert.match(review, /setDrafts\(rollback\.drafts\);\s*setDirty\(rollback\.dirty\);/);
|
||||
assert.match(review, /className="m48-review-workspace__stage-shell"/);
|
||||
assert.match(review, /className="m48-review-workspace__source-sticker"/);
|
||||
assert.match(review, /materialRim=\{false\}/);
|
||||
assert.match(review, /label="Добавить ещё один объект"/);
|
||||
assert.match(review, /label="Сохранить изменения объекта"/);
|
||||
assert.match(review, /<FieldFrame label="Проезд"/);
|
||||
assert.match(review, /label="Объезд или запас"/);
|
||||
assert.match(review, /<FieldFrame label="Непосредственная опасность"/);
|
||||
assert.match(review, /label="Закрыть редактор объекта"/);
|
||||
assert.doesNotMatch(review, /className="m48-review-workspace__(?:reviewbar|review-actions|source-state)"/);
|
||||
assert.doesNotMatch(review, />\s*Сохранить изменения\s*<\/Button>/);
|
||||
assert.doesNotMatch(review, /Выбранный объект|Рамка здесь|Начало здесь|Конец здесь|trimM48Tracklet|рамка переносится по клипу/);
|
||||
assert.match(review, /sequence < selectedTracklet\.firstSequence \|\| sequence > selectedTracklet\.lastSequence/);
|
||||
assert.match(player, /interactionMoved\([\s\S]*?boxInteraction\.startClient,[\s\S]*?event\.clientX/);
|
||||
assert.match(player, /extentsDiffer\(boxInteraction\.originalExtent, extent\)/);
|
||||
assert.match(player, /firstSequence: sequence/);
|
||||
assert.match(player, /lastSequence: sequence/);
|
||||
assert.doesNotMatch(review, /inspector=\{/);
|
||||
assert.match(frame, /\{inspector \? <footer/);
|
||||
assert.doesNotMatch(frameStyles, /border-(?:top|bottom):/);
|
||||
assert.match(m48Styles, /\.m48-clip-player__pane-label\s*\{[^}]*border:\s*0/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__object-tools\s*\{[^}]*justify-content:\s*flex-start/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__topbar\s*\{[^}]*display:\s*grid[^}]*grid-template-columns:\s*minmax\(0, 1fr\) auto/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-rail\s*\{[^}]*position:\s*absolute[^}]*left:\s*var\(--nodedc-space-4\)[^}]*translateY\(-50%\)/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-controls\s*\{[^}]*flex-direction:\s*column[^}]*gap:\s*var\(--nodedc-space-2\)/s);
|
||||
assert.match(m48Styles, /\.m48-evidence-mode-controls__text\s*\{[^}]*font-size:\s*var\(--nodedc-font-size-xs\)/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__clip-field\s*\{[^}]*19vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__clip-reviewed\s*\{[^}]*16vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__passage-field\s*\{[^}]*18vw/s);
|
||||
assert.match(m48Styles, /\.m48-review-workspace__source-sticker\s*\{[^}]*position:\s*absolute[^}]*pointer-events:\s*none/s);
|
||||
assert.match(
|
||||
recordedStyles,
|
||||
/\.laboratory-recorded-clip-player__timeline\.observation-timeline\s*\{[^}]*border:\s*0/s,
|
||||
);
|
||||
assert.match(
|
||||
recordedStyles,
|
||||
/grid-template-columns:\s*auto auto auto minmax\(12rem, 1fr\) auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test("spatial legend exposes only layers present in the current evidence contract", () => {
|
||||
const sourceOnly = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: 128,
|
||||
localSurfaceCount: 0,
|
||||
obstacles: [],
|
||||
showCurrentIncrement: true,
|
||||
showLocalSurface: false,
|
||||
showRollingMap: false,
|
||||
});
|
||||
assert.deepEqual(sourceOnly, [{ id: "context", label: "Текущий кадр" }]);
|
||||
|
||||
const threat = laboratoryMetricLegendEntries({
|
||||
pointCloudCount: 128,
|
||||
localSurfaceCount: 64,
|
||||
obstacles: [{
|
||||
id: "obstacle-1",
|
||||
decision: "threat",
|
||||
state: "retained",
|
||||
centroidBodyXyzM: [1, 0, 0],
|
||||
cellCentersBodyXyzM: [[1, 0, 0]],
|
||||
}],
|
||||
showCurrentIncrement: true,
|
||||
showLocalSurface: true,
|
||||
showRollingMap: true,
|
||||
});
|
||||
assert.deepEqual(threat.map(({ id }) => id), [
|
||||
"threat",
|
||||
"context",
|
||||
"local-surface",
|
||||
"rolling",
|
||||
]);
|
||||
});
|
||||
|
||||
test("M4.8 full-screen workflow suspends the background evidence owner", () => {
|
||||
const capability = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const archive = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(capability, /\{ workspace: ReactNode; active: boolean \}/);
|
||||
assert.match(archive, /!m48Review\.active \? <div className="laboratory-work-output">/);
|
||||
assert.match(archive, /\{m48Review\.workspace\}/);
|
||||
});
|
||||
|
||||
test("metric evidence keeps one WebGL renderer while frame labels advance", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../src/components/laboratory/LaboratoryMetricEvidenceScene.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.doesNotMatch(source, /renderer\.domElement\.setAttribute\("aria-label", label\)/);
|
||||
assert.match(source, /renderer\.dispose\(\);[\s\S]{0,600}?\}, \[\]\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/querySelector\("canvas"\)[\s\S]*?setAttribute\("aria-label", label\)[\s\S]*?\}, \[label\]\);/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchM48SmallStaticRegression;
|
||||
let fetchM48SmallStaticRegressionCases;
|
||||
let fetchM48SmallStaticRegressionCase;
|
||||
|
||||
const resultId = `m48-small-static-passage-regression-${"a".repeat(64)}`;
|
||||
const packId = `m48-object-quality-pack-${"b".repeat(64)}`;
|
||||
const anchorId = `anchor-${"c".repeat(24)}`;
|
||||
const authority = {
|
||||
mode: "replay-simulated",
|
||||
physical_live: false,
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
};
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent", server: { middlewareMode: true } });
|
||||
({
|
||||
fetchM48SmallStaticRegression,
|
||||
fetchM48SmallStaticRegressionCases,
|
||||
fetchM48SmallStaticRegressionCase,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/m48SmallStaticRegression.ts"));
|
||||
});
|
||||
|
||||
after(async () => server?.close());
|
||||
|
||||
function response(value) {
|
||||
return { ok: true, status: 200, json: async () => value };
|
||||
}
|
||||
|
||||
test("M4.8R1 keeps pipeline identity and assisted evidence authority explicit", async () => {
|
||||
const summary = await fetchM48SmallStaticRegression(resultId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-result-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
created_at_utc: "2026-08-24T12:00:00Z",
|
||||
run_label: "M4.8R1",
|
||||
pipeline_id: "m48-class-free-object-quality/v1",
|
||||
experiment_id: "m48-small-static-passage-regression/v1",
|
||||
accepted: false,
|
||||
metrics: {
|
||||
assisted_anchor_count: 14,
|
||||
assisted_tracklet_count: 14,
|
||||
anchor_clip_count: 8,
|
||||
requires_avoidance_or_clearance_count: 12,
|
||||
worker_recalled_anchor_count: 0,
|
||||
worker_missed_anchor_count: 14,
|
||||
assisted_anchor_recall: 0,
|
||||
extent_iou_threshold: 0.5,
|
||||
minimum_assisted_anchor_recall: 0.9,
|
||||
},
|
||||
gates: {
|
||||
anchor_set_non_empty: true,
|
||||
development_anchor_recall_target: false,
|
||||
independent_truth_available: false,
|
||||
},
|
||||
decision: {
|
||||
state: "failed-development-regression-baseline",
|
||||
summary: "Worker 006 matched 0/14.",
|
||||
next_action: "Publish another immutable run.",
|
||||
},
|
||||
ground_truth: false,
|
||||
independent_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(summary.pipelineId, "m48-class-free-object-quality/v1");
|
||||
assert.equal(summary.experimentId, "m48-small-static-passage-regression/v1");
|
||||
assert.equal(summary.metrics.workerMissedAnchorCount, 14);
|
||||
assert.equal(summary.independentTruth, false);
|
||||
});
|
||||
|
||||
test("M4.8R1 bounded case binds one exact assisted anchor and frozen objects", async () => {
|
||||
const cases = await fetchM48SmallStaticRegressionCases(resultId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-case-catalog/v1",
|
||||
result_id: resultId,
|
||||
cases: [{
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
sequence: 256,
|
||||
requires_avoidance_or_clearance: true,
|
||||
worker_candidate_count: 1,
|
||||
best_iou: 0.01,
|
||||
matched_at_threshold: false,
|
||||
outcome: "missed-assisted-anchor",
|
||||
}],
|
||||
case_count: 1,
|
||||
ground_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(cases[0].outcome, "missed-assisted-anchor");
|
||||
|
||||
const item = await fetchM48SmallStaticRegressionCase(resultId, anchorId, {
|
||||
fetcher: async () => response({
|
||||
schema_version: "missioncore.m48-small-static-passage-regression-case-view/v1",
|
||||
result_id: resultId,
|
||||
pack_id: packId,
|
||||
anchor: {
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
object_id: "object-01",
|
||||
sequence: 256,
|
||||
extent_xyxy: [0.2, 0.3, 0.4, 0.7],
|
||||
visibility: "visible",
|
||||
geometry_association: "unknown",
|
||||
freshness: "current",
|
||||
motion: "static",
|
||||
threat: "not-threat",
|
||||
requires_avoidance_or_clearance: true,
|
||||
},
|
||||
comparison: {
|
||||
anchor_id: anchorId,
|
||||
clip_id: "m48-clip-03",
|
||||
sequence: 256,
|
||||
requires_avoidance_or_clearance: true,
|
||||
worker_candidate_count: 1,
|
||||
best_iou: 0.01,
|
||||
matched_at_threshold: false,
|
||||
outcome: "missed-assisted-anchor",
|
||||
source_time_ns: 25_600_000_000,
|
||||
anchor_extent_xyxy: [0.2, 0.3, 0.4, 0.7],
|
||||
worker_objects: [{
|
||||
prediction_id: "proposal-256-0",
|
||||
extent_xyxy: [0.7, 0.2, 0.9, 0.6],
|
||||
geometry_association: "associated",
|
||||
freshness: "current",
|
||||
motion: "static",
|
||||
threat: "not-threat",
|
||||
}],
|
||||
best_prediction_id: "proposal-256-0",
|
||||
extent_iou_threshold: 0.5,
|
||||
},
|
||||
camera_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/m48-clip-03/frames/256/camera`,
|
||||
spatial_url: `/api/v1/laboratory/m48/packs/${packId}/source/clips/m48-clip-03/frames/256/spatial`,
|
||||
ground_truth: false,
|
||||
authority,
|
||||
}),
|
||||
});
|
||||
assert.equal(item.anchor.sequence, 256);
|
||||
assert.equal(item.comparison.workerObjects[0].predictionId, "proposal-256-0");
|
||||
assert.equal(item.groundTruth, false);
|
||||
});
|
||||
|
||||
test("M4.8R1 visual reuses the held viewer and keeps manual boxes exact-frame only", () => {
|
||||
const visual = readFileSync(
|
||||
new URL("../src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(visual, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(visual, /<M48BlindClipPlayer/);
|
||||
assert.match(visual, /<M48EvidenceModeRail/);
|
||||
assert.match(visual, /firstSequence: sequence,[\s\S]*lastSequence: sequence/);
|
||||
assert.doesNotMatch(visual, /Rerun|MediaSource|setInterval/);
|
||||
});
|
||||
@@ -508,7 +508,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(visual, /LOCAL SLAM/);
|
||||
assert.match(visual, /showLocalSurface/);
|
||||
assert.match(visual, /pointCloudBodyXyzM=\{spatialFrame\.pointCloudBodyXyzM\}/);
|
||||
assert.match(metricScene, /Local SLAM surface/);
|
||||
assert.match(metricScene, /Локальная SLAM-поверхность/);
|
||||
assert.match(visual, /showJumpToEnd=\{false\}/);
|
||||
assert.doesNotMatch(visual, /Назад на 5 секунд/);
|
||||
assert.doesNotMatch(visual, /Вперёд на 5 секунд/);
|
||||
|
||||
@@ -326,6 +326,47 @@ test("spatial timeline renders synchronized accumulation and playback controls",
|
||||
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test("recorded observation timeline uses compact icon transport and one inline rate trigger", () => {
|
||||
const idleMarkup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
playing: false,
|
||||
playbackRate: 1,
|
||||
onSeek: () => undefined,
|
||||
onPlayingChange: () => undefined,
|
||||
onPlaybackRateChange: () => undefined,
|
||||
}));
|
||||
const playingMarkup = renderToStaticMarkup(createElement(ObservationTimeline, {
|
||||
active: true,
|
||||
sourceCount: 2,
|
||||
mode: "recorded",
|
||||
seekable: true,
|
||||
rangeNs: { min: 0, max: 20_000_000_000 },
|
||||
currentNs: 5_000_000_000,
|
||||
playing: true,
|
||||
playbackRate: 1,
|
||||
onSeek: () => undefined,
|
||||
onPlayingChange: () => undefined,
|
||||
onPlaybackRateChange: () => undefined,
|
||||
}));
|
||||
|
||||
assert.match(idleMarkup, /aria-label="Воспроизвести"/);
|
||||
assert.match(idleMarkup, /lucide-play/);
|
||||
assert.match(idleMarkup, /fill="currentColor"/);
|
||||
assert.match(idleMarkup, /stroke-width="0"/);
|
||||
assert.match(playingMarkup, /aria-label="Пауза"/);
|
||||
assert.match(playingMarkup, /lucide-square/);
|
||||
assert.match(playingMarkup, /fill="currentColor"/);
|
||||
assert.match(idleMarkup, /nodedc-select-inline/);
|
||||
assert.match(idleMarkup, />1×<\/span>/);
|
||||
assert.doesNotMatch(idleMarkup, /nodedc-select-trigger__chevron/);
|
||||
assert.doesNotMatch(idleMarkup, />Воспроизвести<|>Пауза</);
|
||||
});
|
||||
|
||||
test("Rerun expands only root-relative session recordings onto the current origin", () => {
|
||||
assert.equal(
|
||||
resolveRerunSourceUrl(
|
||||
|
||||
@@ -11,6 +11,7 @@ let recordedMediaSeekableCoverage;
|
||||
let recordedMediaFragmentUrl;
|
||||
let recordedMediaDecodeStartSequence;
|
||||
let recordedMediaSegmentAppendOrder;
|
||||
let recordedMediaCanRollTarget;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -25,6 +26,7 @@ before(async () => {
|
||||
recordedMediaFragmentUrl,
|
||||
recordedMediaDecodeStartSequence,
|
||||
recordedMediaSegmentAppendOrder,
|
||||
recordedMediaCanRollTarget,
|
||||
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
|
||||
});
|
||||
|
||||
@@ -204,6 +206,14 @@ test("recorded player keeps full-archive range fallback and uses bounded generat
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded player preserves forward rolling playback but seeks backward clip loops", () => {
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, true), true);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 20, true, true), true);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 1, true, true), false);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, false, true), false);
|
||||
assert.equal(recordedMediaCanRollTarget(20, 21, true, false), false);
|
||||
});
|
||||
|
||||
test("loading and error overlays fully conceal recorded camera pixels", async () => {
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/observation.css", import.meta.url),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v2",
|
||||
"work_id": "m48-object-centric-quality",
|
||||
"evidence_lifecycle": [
|
||||
{
|
||||
"phase": "review",
|
||||
"runtime_relative_root": "m48/object-quality-packs",
|
||||
"result_id_prefix": "m48-object-quality-pack",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.m48-object-centric-quality-pack/v1"
|
||||
},
|
||||
{
|
||||
"phase": "result",
|
||||
"runtime_relative_root": "m48/object-quality-results",
|
||||
"result_id_prefix": "m48-object-quality-result",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.m48-object-centric-quality-result/v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||
"work_id": "m48-small-static-passage-regression",
|
||||
"evidence": {
|
||||
"runtime_relative_root": "m48/small-static-passage-regression-results",
|
||||
"result_id_prefix": "m48-small-static-passage-regression",
|
||||
"document_name": "manifest.json",
|
||||
"schema_version": "missioncore.m48-small-static-passage-regression-result/v1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,34 @@
|
||||
{
|
||||
"schema_version": "missioncore.laboratory-execution-registry/v1",
|
||||
"definitions": [
|
||||
{
|
||||
"work_id": "m48-small-static-passage-regression",
|
||||
"lifecycle": "canonical",
|
||||
"isolation": "core-adapter",
|
||||
"adapter_id": "canonical.m48-small-static-passage-regression/v1",
|
||||
"input_roles": ["pack_root", "correction_session_path", "profile_path"],
|
||||
"contracts": {
|
||||
"source": "missioncore.m48-object-centric-quality-pack/v1",
|
||||
"provider": "missioncore.m48-assisted-object-correction-session/v1",
|
||||
"graph": "missioncore.m48-assisted-anchor-comparison/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.m48-small-static-passage-regression-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "m48-object-centric-quality",
|
||||
"lifecycle": "canonical",
|
||||
"isolation": "core-adapter",
|
||||
"adapter_id": "canonical.m48-object-centric-quality/v1",
|
||||
"input_roles": ["pack_root", "truth_seal_root"],
|
||||
"contracts": {
|
||||
"source": "missioncore.m48-object-centric-quality-pack/v1",
|
||||
"provider": "missioncore.m48-object-truth-seal/v1",
|
||||
"graph": "missioncore.m48-object-centric-quality-graph/v1",
|
||||
"run": "missioncore.laboratory-run/v1",
|
||||
"evidence": "missioncore.m48-object-centric-quality-result/v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"work_id": "m4-replay-threat",
|
||||
"lifecycle": "canonical",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"schema_version": "missioncore.m48-object-quality-selection/v1",
|
||||
"selection_id": "m48-ravnoves00-balanced-connected-clips/v1",
|
||||
"source_id": "RAVNOVES00",
|
||||
"source_session_id": "20260720T065719Z_viewer_live",
|
||||
"selection_basis": "prediction-frozen-source-curation-before-independent-truth",
|
||||
"camera_frame_size": {
|
||||
"width": 800,
|
||||
"height": 600
|
||||
},
|
||||
"selection_hypothesis_profile": {
|
||||
"derivation": "exact-frozen-prediction-rows-before-independent-truth",
|
||||
"small_obstacle_max_normalized_area": 0.001,
|
||||
"fisheye_edge_margin_normalized": 0.08,
|
||||
"sparse_scene_max_median_prediction_count": 2.0
|
||||
},
|
||||
"clips": [
|
||||
{
|
||||
"clip_id": "m48-clip-01",
|
||||
"component_id": "m48-component-development-01",
|
||||
"route_block": "route-block-01",
|
||||
"time_block": "time-block-01",
|
||||
"split": "development",
|
||||
"start_sequence": 1,
|
||||
"end_sequence": 61
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-02",
|
||||
"component_id": "m48-component-development-01",
|
||||
"route_block": "route-block-01",
|
||||
"time_block": "time-block-01",
|
||||
"split": "development",
|
||||
"start_sequence": 121,
|
||||
"end_sequence": 181
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-03",
|
||||
"component_id": "m48-component-development-01",
|
||||
"route_block": "route-block-01",
|
||||
"time_block": "time-block-01",
|
||||
"split": "development",
|
||||
"start_sequence": 241,
|
||||
"end_sequence": 301
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-04",
|
||||
"component_id": "m48-component-development-02",
|
||||
"route_block": "route-block-01",
|
||||
"time_block": "time-block-02",
|
||||
"split": "development",
|
||||
"start_sequence": 421,
|
||||
"end_sequence": 481
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-05",
|
||||
"component_id": "m48-component-development-02",
|
||||
"route_block": "route-block-01",
|
||||
"time_block": "time-block-02",
|
||||
"split": "development",
|
||||
"start_sequence": 581,
|
||||
"end_sequence": 641
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-06",
|
||||
"component_id": "m48-component-development-02",
|
||||
"route_block": "route-block-02",
|
||||
"time_block": "time-block-02",
|
||||
"split": "development",
|
||||
"start_sequence": 821,
|
||||
"end_sequence": 881
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-07",
|
||||
"component_id": "m48-component-development-03",
|
||||
"route_block": "route-block-02",
|
||||
"time_block": "time-block-03",
|
||||
"split": "development",
|
||||
"start_sequence": 1041,
|
||||
"end_sequence": 1101
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-08",
|
||||
"component_id": "m48-component-development-03",
|
||||
"route_block": "route-block-02",
|
||||
"time_block": "time-block-03",
|
||||
"split": "development",
|
||||
"start_sequence": 1221,
|
||||
"end_sequence": 1281
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-09",
|
||||
"component_id": "m48-component-development-03",
|
||||
"route_block": "route-block-02",
|
||||
"time_block": "time-block-03",
|
||||
"split": "development",
|
||||
"start_sequence": 1421,
|
||||
"end_sequence": 1481
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-10",
|
||||
"component_id": "m48-component-development-04",
|
||||
"route_block": "route-block-03-development",
|
||||
"time_block": "time-block-04",
|
||||
"split": "development",
|
||||
"start_sequence": 1681,
|
||||
"end_sequence": 1741
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-11",
|
||||
"component_id": "m48-component-development-04",
|
||||
"route_block": "route-block-03-development",
|
||||
"time_block": "time-block-04",
|
||||
"split": "development",
|
||||
"start_sequence": 1830,
|
||||
"end_sequence": 1890
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-12",
|
||||
"component_id": "m48-component-development-04",
|
||||
"route_block": "route-block-03-development",
|
||||
"time_block": "time-block-04",
|
||||
"split": "development",
|
||||
"start_sequence": 2041,
|
||||
"end_sequence": 2101
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-13",
|
||||
"component_id": "m48-component-validation-01",
|
||||
"route_block": "route-block-03-validation",
|
||||
"time_block": "time-block-05",
|
||||
"split": "validation",
|
||||
"start_sequence": 2191,
|
||||
"end_sequence": 2251
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-14",
|
||||
"component_id": "m48-component-validation-01",
|
||||
"route_block": "route-block-03-validation",
|
||||
"time_block": "time-block-05",
|
||||
"split": "validation",
|
||||
"start_sequence": 2371,
|
||||
"end_sequence": 2431
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-15",
|
||||
"component_id": "m48-component-validation-01",
|
||||
"route_block": "route-block-03-validation",
|
||||
"time_block": "time-block-05",
|
||||
"split": "validation",
|
||||
"start_sequence": 2551,
|
||||
"end_sequence": 2611
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-16",
|
||||
"component_id": "m48-component-validation-02",
|
||||
"route_block": "route-block-04",
|
||||
"time_block": "time-block-06",
|
||||
"split": "validation",
|
||||
"start_sequence": 2731,
|
||||
"end_sequence": 2791
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-17",
|
||||
"component_id": "m48-component-validation-02",
|
||||
"route_block": "route-block-04",
|
||||
"time_block": "time-block-06",
|
||||
"split": "validation",
|
||||
"start_sequence": 2911,
|
||||
"end_sequence": 2971
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-18",
|
||||
"component_id": "m48-component-validation-02",
|
||||
"route_block": "route-block-04",
|
||||
"time_block": "time-block-06",
|
||||
"split": "validation",
|
||||
"start_sequence": 3111,
|
||||
"end_sequence": 3171
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-19",
|
||||
"component_id": "m48-component-validation-03",
|
||||
"route_block": "route-block-04",
|
||||
"time_block": "time-block-07",
|
||||
"split": "validation",
|
||||
"start_sequence": 3291,
|
||||
"end_sequence": 3351
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-20",
|
||||
"component_id": "m48-component-validation-03",
|
||||
"route_block": "route-block-04",
|
||||
"time_block": "time-block-07",
|
||||
"split": "validation",
|
||||
"start_sequence": 3471,
|
||||
"end_sequence": 3531
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-21",
|
||||
"component_id": "m48-component-validation-03",
|
||||
"route_block": "route-block-05",
|
||||
"time_block": "time-block-07",
|
||||
"split": "validation",
|
||||
"start_sequence": 3651,
|
||||
"end_sequence": 3711
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-22",
|
||||
"component_id": "m48-component-validation-04",
|
||||
"route_block": "route-block-05",
|
||||
"time_block": "time-block-08",
|
||||
"split": "validation",
|
||||
"start_sequence": 3831,
|
||||
"end_sequence": 3891
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-23",
|
||||
"component_id": "m48-component-validation-04",
|
||||
"route_block": "route-block-05",
|
||||
"time_block": "time-block-08",
|
||||
"split": "validation",
|
||||
"start_sequence": 4051,
|
||||
"end_sequence": 4111
|
||||
},
|
||||
{
|
||||
"clip_id": "m48-clip-24",
|
||||
"component_id": "m48-component-validation-04",
|
||||
"route_block": "route-block-05",
|
||||
"time_block": "time-block-08",
|
||||
"split": "validation",
|
||||
"start_sequence": 4429,
|
||||
"end_sequence": 4489
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": "missioncore.m48-object-quality-profile/v1",
|
||||
"profile_id": "m48-ravnoves00-object-quality/v1",
|
||||
"source_graph_id": "reference-perception-graph/v2",
|
||||
"source_profile_id": "m4-ravnoves00-recorded-realtime/v1",
|
||||
"clip_contract": {
|
||||
"minimum_clip_count": 20,
|
||||
"maximum_clip_count": 30,
|
||||
"minimum_duration_seconds": 5.0,
|
||||
"maximum_duration_seconds": 10.0,
|
||||
"required_validation_hypotheses": [
|
||||
"prediction-associated",
|
||||
"prediction-fisheye-edge",
|
||||
"prediction-moving",
|
||||
"prediction-small-obstacle",
|
||||
"prediction-sparse-scene",
|
||||
"prediction-static",
|
||||
"prediction-threat",
|
||||
"prediction-unassociated"
|
||||
],
|
||||
"selection_hypothesis_profile": {
|
||||
"derivation": "exact-frozen-prediction-rows-before-independent-truth",
|
||||
"small_obstacle_max_normalized_area": 0.001,
|
||||
"fisheye_edge_margin_normalized": 0.08,
|
||||
"sparse_scene_max_median_prediction_count": 2.0
|
||||
},
|
||||
"splits": [
|
||||
"development",
|
||||
"validation"
|
||||
],
|
||||
"connected_component_split_overlap_allowed": false,
|
||||
"route_or_time_block_split_overlap_allowed": false,
|
||||
"release_gate_split": "validation"
|
||||
},
|
||||
"review_contract": {
|
||||
"review_unit": "clip-local-object-tracklet",
|
||||
"extent_labels": "sparse-normalized-xyxy-keyframes",
|
||||
"state_labels": "contiguous-tracklet-state-segments",
|
||||
"per_frame_expansion": {
|
||||
"extent": "linear-between-bounding-keyframes",
|
||||
"visibility": "left-keyframe-hold",
|
||||
"state": "contiguous-state-segment"
|
||||
},
|
||||
"semantic_class_labels_allowed": false,
|
||||
"independent_reviewers_required": 2,
|
||||
"adjudication_required": true,
|
||||
"predictions_frozen_before_label_reveal": true,
|
||||
"prediction_content_visible_to_reviewers": false,
|
||||
"selection_hypotheses_visible_to_reviewers": false
|
||||
},
|
||||
"matching": {
|
||||
"extent_iou_threshold": 0.5
|
||||
},
|
||||
"release_thresholds": {
|
||||
"terminal_outcome_accounting": 1.0,
|
||||
"false_free_space_claims": 0,
|
||||
"obstacle_presence_precision": 0.9,
|
||||
"obstacle_presence_recall": 0.9,
|
||||
"critical_corridor_obstacle_recall": 0.95,
|
||||
"geometry_association_correctness": 0.9,
|
||||
"freshness_correctness": 0.9,
|
||||
"motion_decision_correctness": 0.9,
|
||||
"critical_threat_not_threat": 0
|
||||
},
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "missioncore.m48-small-static-passage-regression-profile/v1",
|
||||
"profile_id": "m48-small-static-passage-regression/v1",
|
||||
"pipeline_id": "m48-class-free-object-quality/v1",
|
||||
"experiment_id": "m48-small-static-passage-regression/v1",
|
||||
"human_lab_id": "M4.8",
|
||||
"run_label": "M4.8R1",
|
||||
"anchor_selection": "operator-added-tracklets-in-reviewed-clips/v1",
|
||||
"extent_iou_threshold": 0.5,
|
||||
"minimum_assisted_anchor_recall": 0.9,
|
||||
"minimum_anchor_count": 1,
|
||||
"independent_truth": false
|
||||
}
|
||||
@@ -185,6 +185,29 @@ The evidence slot has two admitted renderers:
|
||||
- `diagnostic-model` — a specialized visual result such as the LAB E28 L2.6
|
||||
surface/timeline/review viewer.
|
||||
|
||||
Recorded camera clips used for review are a frozen sub-contract of the admitted
|
||||
viewer, `missioncore.laboratory-recorded-clip-viewer/v1`, implemented by
|
||||
`LaboratoryRecordedClipPlayer`. It owns the generation-bound fMP4 manifest,
|
||||
bounded MediaSource buffering, source-time playback clock, clip looping and the
|
||||
canonical timeline. A LAB may provide typed overlays and an alternative spatial
|
||||
scene, but it may not implement its own frame timer, per-frame JPEG playback,
|
||||
media cache, port, Rerun blueprint or loading grammar. Exact JPEG remains a
|
||||
paused-frame/fallback evidence endpoint, never the continuous playback transport.
|
||||
Forward frame progression may roll an already-buffered segment target; a
|
||||
backward seek or clip loop must perform an explicit decoder seek and remain
|
||||
decoder-ready without exposing a per-frame loader.
|
||||
|
||||
When recorded camera and frame-indexed spatial evidence are both required for
|
||||
one review question, the shared player presents them simultaneously on the same
|
||||
media clock. The camera remains the clock owner; a bounded experiment-neutral
|
||||
spatial cache prefetches exact source sequences, including across the loop
|
||||
boundary. Mode switching changes presentation only. It must not pause the clock,
|
||||
hide the companion camera, create another transport, or flash a full-stage
|
||||
loader between spatial frames. Connected M4.8 workflows project CAMERA, 3D and
|
||||
PLAN through the same left-side vertical glass rail of canonical circular
|
||||
actions; the report, assisted correction and formal review flows do not invent
|
||||
separate mode-control geometry.
|
||||
|
||||
An admitted diagnostic viewer may own the result interaction internally when
|
||||
the evidence itself is the review/result instrument, as in E28 and E30. This is
|
||||
not permission to omit the result from a new ordinary LAB report. New bounded
|
||||
|
||||
@@ -173,6 +173,37 @@ The viewer frame must:
|
||||
- remain keyboard-addressable and restore the previous surface on Escape;
|
||||
- avoid hard-coded product colors and application-local focus/hover states.
|
||||
|
||||
Connected recorded review uses stacked viewer chrome: source/case navigation
|
||||
occupies a dedicated header above the visual viewport; playback controls and
|
||||
the timeline occupy a dedicated transport panel below it. CAMERA/3D/PLAN use
|
||||
one reusable vertical `GlassSurface` rail on the left of the viewport. Its
|
||||
three actions are canonical 46 px circular `IconButton` controls: an outline
|
||||
camera glyph, the `3D` text glyph and an outline plan glyph. The rail may float
|
||||
over the viewport but must not resize it; every other control region remains
|
||||
outside the camera/spatial interaction area. These regions form one seamless
|
||||
viewer surface without per-region outlines or gaps. When spatial
|
||||
and camera evidence are shown together, the shared vertical divider is
|
||||
pointer- and keyboard-resizable, preserves one mounted camera transport, and
|
||||
may only reveal its visual affordance while hovered, focused, or dragged.
|
||||
CAMERA visibility remains independent. 3D/PLAN remain mutually exclusive
|
||||
spatial modes whose selected mode can be disabled while CAMERA stays visible.
|
||||
The UI must preserve at least one visible evidence channel and must not encode
|
||||
CAMERA, 3D and PLAN as one exclusive group.
|
||||
|
||||
For recorded clip review, continuous camera playback must use the shared
|
||||
`missioncore.laboratory-recorded-clip-viewer/v1` transport: one immutable
|
||||
manifest admission followed by bounded generation-bound fMP4 fragments on the
|
||||
source media clock. A visible loader on every source frame, a LAB-owned timer,
|
||||
or a LAB-owned video/Rerun configuration is a contract regression. Spatial modes
|
||||
whose evidence cannot be delivered at source pace remain explicit frame-step
|
||||
modes; they must not stall or relabel the camera clock as realtime. When spatial
|
||||
evidence is admitted at source pace, it follows the same source sequence through
|
||||
a bounded look-ahead cache. CAMERA + 3D/PLAN remain simultaneously visible, and
|
||||
no intermediate spatial miss may replace or stop the camera surface.
|
||||
The shared player may retain a rolling target only for the same or a later
|
||||
segment. Rewind and clip-loop transitions must seek backward explicitly while
|
||||
keeping the admitted generation and decoder owner mounted.
|
||||
|
||||
### 3D and 2D policy
|
||||
|
||||
Choose the default representation from the operator question:
|
||||
@@ -187,6 +218,10 @@ When both questions matter, expose 2D and 3D as modes of the same viewer. They
|
||||
must use the same selected case and immutable source indices. Do not create a
|
||||
second LAB page or duplicate the evidence state.
|
||||
|
||||
For M4.8, **3D/PLAN always includes the synchronized RIGHT-camera companion**.
|
||||
The point cloud must advance with the recorded media clock; a paused 3D snapshot
|
||||
with disabled playback is not admissible evidence for connected-object review.
|
||||
|
||||
For E30, **camera + projected LiDAR is the default** because the first review
|
||||
question is whether a camera claim, its bbox and the projected points refer to
|
||||
the same visible object. A black pixel-plane scatter without the exact camera
|
||||
|
||||
@@ -175,6 +175,36 @@ receives a concise typed projection.
|
||||
DOM or CSS classes. The product UI test discovers every `ENNResult.tsx`
|
||||
automatically and rejects such a fork.
|
||||
|
||||
`components/laboratory/LaboratoryRecordedClipPlayer.tsx` exclusively owns the
|
||||
versioned `missioncore.laboratory-recorded-clip-viewer/v1` camera transport and
|
||||
clock. Feature renderers may add typed overlays or a synchronized spatial scene
|
||||
through its slots. Source-paced spatial evidence follows exact media sequences
|
||||
through a bounded reusable look-ahead cache; frame-step remains an explicit
|
||||
capability only when the source cannot keep pace. Experiment-named players must not fetch a JPEG
|
||||
per playback frame, instantiate MediaSource, schedule frame timers, or declare a
|
||||
new Rerun receiver/blueprint. Forward buffered progression and backward
|
||||
loop/seek are separate shared-player transitions; a backward target cannot be
|
||||
treated as an ordinary rolling-buffer advance.
|
||||
|
||||
The shared player owns the admitted `primary` camera and `companion` camera +
|
||||
spatial presentations. A feature mode may not disable playback, unmount the
|
||||
camera, or create a second viewer configuration merely to show 3D/PLAN.
|
||||
`LaboratoryEvidenceViewer` owns the reusable `stacked` chrome layout: one header,
|
||||
one visual stage and one transport/timeline rail joined into a seamless surface.
|
||||
`LaboratoryRecordedClipPlayer` reuses the canonical `SplitPane` for resizable
|
||||
spatial + companion-camera evidence while keeping the recorded camera owner
|
||||
mounted across CAMERA/3D/PLAN transitions. Feature code supplies typed actions
|
||||
and modes; it does not declare a feature-local splitter. M4.8 reuses one
|
||||
`M48EvidenceModeRail` projection in the report, candidate-assisted correction,
|
||||
independent review and adjudication surfaces. The rail is the admitted
|
||||
viewport overlay: a canonical `GlassSurface` containing three default-size
|
||||
`IconButton` controls and no local control geometry. CAMERA/3D/PLAN therefore
|
||||
do not return to the stacked header or fork per workflow:
|
||||
CAMERA visibility is independent from the nullable 3D/PLAN spatial mode, and
|
||||
their state transition cannot hide both channels. Candidate seed data belongs
|
||||
to the correction contract only; it cannot enter the blind source decoder or
|
||||
upgrade assisted evidence to independent truth.
|
||||
|
||||
Legacy/integrated diagnostic viewers may keep a result interaction inside the
|
||||
evidence slot only where that viewer is already the admitted result instrument.
|
||||
This exception does not apply automatically to a new LAB.
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
Date: 2026-08-05
|
||||
|
||||
Status: in progress; M4.0–M4.6 accepted, the M4.7 canonical graph and Worker
|
||||
006 shadow artifact are implemented locally, and Worker preflight/full shadow
|
||||
acceptance plus durable cutover remain open
|
||||
Status: in progress; M4.0–M4.6 and the M4.7 canonical lossless Worker 006
|
||||
shadow are accepted, M4.8 prediction-frozen review-pack preparation is complete,
|
||||
and the independent reviews/adjudication, M4.8 scoring, M4.9 release candidate
|
||||
and durable Worker cutover remain open
|
||||
|
||||
Audit base: `1b3e0b3` on `feat/simulation-polygon-s1`
|
||||
|
||||
@@ -500,10 +501,11 @@ Physical mounted threat acceptance remains outside Milestone 4.
|
||||
|
||||
### M4.7 — cut Worker 006 over to the canonical graph
|
||||
|
||||
Status: implementation complete locally on 2026-08-23; Worker 006 preflight,
|
||||
full lossless shadow evidence and durable process replacement are not yet
|
||||
accepted. The current E15 worker and Triton identities remain the rollback
|
||||
predecessor. No K1, Zarya or connection-stack change is part of this phase.
|
||||
Status: the isolated Worker 006 preflight and full lossless shadow were accepted
|
||||
on 2026-08-23 with all `4,489` frames delivered, zero parity mismatches and queue
|
||||
high-water mark `2`. Durable process replacement is not implemented or accepted.
|
||||
The current E15 worker and Triton identities remain the rollback predecessor. No
|
||||
K1, Zarya or connection-stack change is part of this phase.
|
||||
|
||||
Deliverables:
|
||||
|
||||
@@ -533,8 +535,9 @@ adds a separate object-centric review contract; it does not require class labels
|
||||
Dataset construction:
|
||||
|
||||
- freeze 20–30 connected clips of 5–10 seconds across route/time blocks;
|
||||
- include occupied object, background false-positive, partial occlusion, fisheye
|
||||
edge, small obstacle, moving crossing, static obstacle and no-object strata;
|
||||
- derive the private balancing hypotheses only from already frozen prediction
|
||||
rows (associated/unassociated, moving/static/threat, small, fisheye-edge and
|
||||
sparse-scene signals); never use unrevealed truth to select the release set;
|
||||
- label obstacle presence/extent, current geometry association, freshness,
|
||||
moving/static/unknown and virtual-corridor threat/unknown;
|
||||
- freeze graph predictions before review labels are joined;
|
||||
@@ -1194,12 +1197,199 @@ accepting the run. This makes Worker 006 usable without touching the stabilized
|
||||
K1/Zarya connection path.
|
||||
|
||||
Local contract, graph, result-sealing, artifact and historical-rollback tests
|
||||
pass. This is implementation evidence only. It does not claim that Worker 006
|
||||
has the pinned local-surface input, that preflight has passed, that the 4,489
|
||||
frame graph shadow matches the accepted M4.5R/M4.6 ledgers, or that the durable
|
||||
E15 command has been replaced. Those are the next M4.7 acceptance actions, in
|
||||
that order. K1, Zarya, the stable connection path and physical-live authority
|
||||
remain untouched.
|
||||
pass. Worker result
|
||||
`m47-reference-graph-5f6a851cd655c7cf07c3025dacadbc188018b0afa97eda3a08802266e12da87d`
|
||||
sealed the full shadow with `4,489/4,489` admitted and delivered frames, zero
|
||||
failed/stale/superseded/rejected/unavailable outcomes and zero mismatches in all
|
||||
seven parity dimensions. LAB result
|
||||
`m47-reference-graph-lab-49678f0a7c628c7e991af0964fa57d005baa027d2d1eea19f38bbfe27ed39ce5`
|
||||
therefore opens the independent object-centric quality gate. This does not claim
|
||||
that the durable E15 command has been replaced: the accepted runner is explicitly
|
||||
one-shot shadow-only, and the required persistent service, telemetry-continuity
|
||||
and automatic rollback contract do not yet exist. K1, Zarya, the stable
|
||||
connection path and physical-live authority remain untouched.
|
||||
|
||||
### 2026-08-24 — M4.8 independent review pack frozen
|
||||
|
||||
The first provenance-complete M4.8 pack is
|
||||
`m48-object-quality-pack-680c091cd81cce802931dbb8987db6f26dc568c5d7395166cda2e9e5a4c78e27`.
|
||||
It binds the accepted M4.7 LAB manifest, exact M4.6 threat ledger and exact
|
||||
single-sample camera-fragment hashes for all `4,489` source frames, plus the
|
||||
adapter, selection, camera index, graph, threat and geometry manifests/ledgers by
|
||||
SHA-256. The selected review surface contains `24` non-overlapping connected
|
||||
clips of approximately six seconds each (`12` development and `12` validation;
|
||||
`1,464` selected frames) across six split-local route blocks and eight
|
||||
split-local time blocks. All eight private balancing hypotheses are derived
|
||||
mechanically from the frozen prediction rows and are present in validation; the
|
||||
reviewer projection contains neither those hypotheses nor frozen boxes, IDs,
|
||||
scores, model identity or semantic-class tasks.
|
||||
|
||||
The pack state is intentionally
|
||||
`prepared-predictions-frozen-labels-unavailable`. It is not an accepted quality
|
||||
result. Two distinct capability-bound reviewers must complete the class-free
|
||||
tracklet review, both submissions must freeze, and a separate adjudication must
|
||||
seal before the frozen predictions can be joined. Only then may the deterministic
|
||||
per-frame ledger, critical-first failure atlas and M4.8 thresholds produce an
|
||||
ACCEPT or REJECT. Release gates use validation only; development and combined
|
||||
metrics remain diagnostic. No aggregate metric may waive a false-free claim, critical
|
||||
miss, hidden terminal outcome or critical `threat`→`not-threat` error.
|
||||
|
||||
### 2026-08-24 — M4.8 laboratory architecture hardening
|
||||
|
||||
M4.8 is represented by one catalog work,
|
||||
`m48-object-centric-quality`, whose evidence lifecycle advances from the
|
||||
prepared review pack to the terminal quality result instead of publishing two
|
||||
competing LAB cards. Review and adjudication use one shared focus-owning
|
||||
laboratory workspace frame, while opening either full-screen capability unmounts
|
||||
the background work output. Consequently only one camera decoder, spatial
|
||||
renderer and playback clock can own the selected evidence at a time.
|
||||
|
||||
The M4.8 surface reuses the shared recorded camera and metric spatial viewers;
|
||||
it defines no Rerun blueprint, receiver, window identifier or per-LAB viewer
|
||||
configuration. Spatial legends are derived from admitted data and enabled
|
||||
layers, so source-only review does not advertise unavailable threat, rolling or
|
||||
local-surface semantics. Recorded-camera decode lanes and source manifests are
|
||||
bounded by independent LRU caches. These changes do not alter the stabilized K1
|
||||
connection, control or physical-live path.
|
||||
|
||||
The earlier M4.8 camera surface nevertheless still advanced playback through an
|
||||
experiment-local timer and decoded one HTTP JPEG per frame. On the 10 FPS source,
|
||||
each request also reopened and validated the full neutral pack, producing
|
||||
approximately 0.17–0.42 s frame latency and a visible loader between frames.
|
||||
That path is removed from continuous playback. M4.8 now consumes the frozen
|
||||
`missioncore.laboratory-recorded-clip-viewer/v1`: the backend validates the
|
||||
content-addressed pack and all selected camera fragment hashes once, binds the
|
||||
canonical recorded-media generation, and the browser admits one compact
|
||||
manifest before fetching bounded fMP4 fragments ahead of the source clock.
|
||||
Switching clips, review/adjudication surfaces, or CAMERA/3D/PLAN modes does not
|
||||
create another media configuration or another port. The exact JPEG endpoint is
|
||||
retained only for a paused-frame/failure fallback and no longer participates in
|
||||
normal playback.
|
||||
|
||||
The camera-fragment identity remains byte-exact. The replay graph stores integer
|
||||
nanoseconds while the fMP4 boundary is represented through its media timescale;
|
||||
the binding therefore admits at most `1,000 ns` of representation drift and
|
||||
rejects any larger timeline change. Live acceptance on the canonical backend
|
||||
confirmed a warm source projection in `12–16 ms`, the manifest in `13 ms`, and
|
||||
21 init/fragment reads at `6.13 ms` mean, `10.94 ms` p95 and `23.02 ms` maximum.
|
||||
The first post-process durable-package restoration is one explicit initial
|
||||
admission and is not repeated per frame.
|
||||
|
||||
Acceptance also exposed and closed a shared loop defect: a backward clip target
|
||||
had been misclassified as forward rolling-buffer progress. The shared player now
|
||||
keeps rolling only for the same or a later segment and performs a decoder seek
|
||||
for rewind/loop. A live six-second clip crossed `5.832 → 6.062 → 0.177 s` with
|
||||
`readyState=4`, continuous playing state and no frame loader.
|
||||
|
||||
This viewer version is held as an architecture invariant. New LABs reuse
|
||||
`LaboratoryRecordedClipPlayer`; they may supply domain overlays and a typed
|
||||
alternative scene, but cannot own frame timers, MediaSource, per-frame playback
|
||||
fetches, Rerun receivers or loading grammar. CAMERA is continuous and driven by
|
||||
the recorded media clock. M4.8 `3D` and `PLAN` use the shared companion
|
||||
presentation: the spatial scene and RIGHT camera remain visible together, play
|
||||
from one media clock, and switching representation does not pause or remount the
|
||||
decoder. Exact spatial frames are prefetched by a bounded 14-frame look-ahead
|
||||
window, retained in a 24-frame client cache, and served through a 256-frame
|
||||
backend LRU; the cache also looks through the clip-loop boundary.
|
||||
|
||||
Live acceptance on `m48-clip-02` confirmed camera progression
|
||||
`11.991 → 14.841 s` and spatial progression `121 → 149` over the same interval.
|
||||
Across 12 consecutive observations the 3D sequence changed 12 times with zero
|
||||
loader observations. A separate loop check crossed `17.904 → 12.010 s`, advanced
|
||||
16 distinct spatial sequences and again exposed no intermediate loader. This is
|
||||
recorded source-paced evidence, not physical-live or navigation authority.
|
||||
|
||||
The selected evidence is not uniformly body-frame qualified: `1,202/1,464`
|
||||
review frames expose the bounded current LiDAR increment, while `262` are
|
||||
camera-only because no qualified body frame exists. The first selected clip is
|
||||
camera-only for all `61` frames; the remaining clips have partial spatial
|
||||
coverage. The review UI presents those frames as explicit unavailable evidence,
|
||||
uses one admitted channel, creates no empty spatial canvas and never interprets
|
||||
the absence as free space.
|
||||
|
||||
The frozen evaluation now runs through the canonical laboratory runner and
|
||||
emits a content-addressed run receipt alongside the quality result. This is
|
||||
execution provenance, not gate acceptance: the current pack remains at `0/2`
|
||||
frozen independent reviews, with no adjudication, truth seal or evaluated M4.8
|
||||
result.
|
||||
|
||||
### 2026-08-24 — M4.8 Worker 006 assisted correction LAB
|
||||
|
||||
The operator-facing final check no longer starts from an empty annotation
|
||||
surface. A separate capability-bound correction session projects the immutable
|
||||
Worker 006 prediction rows into editable class-free boxes before the first clip
|
||||
opens. The current pack contributes `5,236` boxes across all `1,464` frames and
|
||||
all `24` clips. The operator selects, moves, resizes or deletes an existing box,
|
||||
draws a missing box, and then explicitly marks each clip reviewed.
|
||||
|
||||
This workflow reuses the same `LaboratoryRecordedClipPlayer`, camera/3D/PLAN
|
||||
controls, split pane, timeline and spatial cache as the blind workflow. It adds
|
||||
no viewer, Rerun blueprint, port, media transport or experiment-local playback
|
||||
clock. Because the accepted M4.7 output has no provider tracklet identity, each
|
||||
frozen detection is projected honestly as a one-frame editable object; the LAB
|
||||
does not fabricate temporal identity between adjacent detections.
|
||||
|
||||
The correction artifact stores the frozen prediction-row SHA-256, original
|
||||
Worker identity, complete corrected clip set and a deterministic human delta:
|
||||
confirmed candidates, unchanged candidates, modified candidates, deleted false
|
||||
positives and added misses. Its assistance mode is
|
||||
`frozen-candidate-seeded`; candidate predictions are explicitly visible, model
|
||||
scores and semantic classes remain absent, and `independent_truth_eligible` is
|
||||
always false. Freezing this artifact therefore supplies regression evidence for
|
||||
Worker 006 without pretending that candidate-assisted review satisfies the
|
||||
two-reviewer independent release gate above.
|
||||
|
||||
The correction UI persists the clip-level `reviewed` transition immediately;
|
||||
there is no second global Save action after the operator changes that checker.
|
||||
Object geometry/state edits retain their bounded explicit Save action. Success
|
||||
and error statuses use the canonical independently timed toast lifecycle and
|
||||
roll a failed clip-status transition back to the last server revision while
|
||||
preserving any pre-existing dirty object edits.
|
||||
|
||||
Small static objects are not split into semantic one-off rules for bins,
|
||||
bollards, pipes or road hemispheres. M4.8 already records two orthogonal state
|
||||
dimensions: `threat` is the immediate threat decision, while
|
||||
`critical_corridor_obstacle` means that the object constrains passage and must
|
||||
receive avoidance/clearance treatment even when `threat=not-threat`. The LAB UI
|
||||
therefore exposes the latter as the separate **Проезд → Объезд или запас**
|
||||
option. A camera rectangle is review evidence only: it cannot become
|
||||
an oversized 3D collider. Future planner clearance must derive from admitted
|
||||
LiDAR/local-occupancy geometry, its uncertainty envelope and the configured
|
||||
vehicle footprint. This distinction is part of the durable M4.8 contract and
|
||||
must not appear or disappear with detector-class tuning.
|
||||
|
||||
### 2026-08-24 — M4.8R1 small-static passage regression baseline
|
||||
|
||||
The first correction-derived regression is a new experiment inside the existing
|
||||
M4.8 human LAB, not a replacement for the assisted-correction run. It keeps the
|
||||
`m48-class-free-object-quality/v1` pipeline fixed, uses experiment
|
||||
`m48-small-static-passage-regression/v1`, and publishes append-only run label
|
||||
`M4.8R1`. The source correction session and frozen Worker 006 pack are read-only
|
||||
inputs; no correction revision, pack artifact or earlier LAB result is mutated.
|
||||
|
||||
Canonical run
|
||||
`m48-small-static-passage-regression-3e3a2001f87fd3adcb736de52a65e42515044d83faa3515f892705b40915c084`
|
||||
snapshots revision 29 of the assisted correction. It contains `14`
|
||||
operator-added exact-frame anchors across `8` reviewed clips, including `12`
|
||||
anchors marked **Объезд или запас**. Against the frozen `5,236` Worker 006 boxes,
|
||||
the exact-frame class-free comparator found `0/14` matches at IoU `>= 0.50`.
|
||||
This deliberately fails the diagnostic `0.90` assisted-anchor recall target and
|
||||
establishes a concrete miss baseline for the next perception experiment.
|
||||
|
||||
The result is not independent truth: the operator saw the Worker 006 candidates,
|
||||
and the selection is intentionally biased toward objects that required manual
|
||||
addition. It therefore cannot produce unbiased precision/recall or satisfy the
|
||||
two-reviewer M4.8 release gate. A camera rectangle is also not a metric collider;
|
||||
clearance and passability remain functions of admitted LiDAR/local occupancy,
|
||||
uncertainty and the configured vehicle footprint. Physical live, navigation,
|
||||
commands, actuation and collision-safety authority remain false.
|
||||
|
||||
The regression reuses the held M4.8 recorded viewer and its CAMERA/3D/PLAN,
|
||||
single media clock, timeline and bounded spatial cache. Each assisted anchor and
|
||||
the frozen Worker objects are shown only on their exact source frame; the UI does
|
||||
not drift a manually drawn rectangle across subsequent frames or fabricate a
|
||||
track. A later Worker candidate must publish another immutable M4.8R run against
|
||||
the frozen seed, leaving this baseline available for before/after comparison.
|
||||
|
||||
## Implementation order
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze the source-scoped RAVNOVES00 M4.8 independent-review pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.laboratory.m48_ravnoves00_pack import prepare_m48_ravnoves00_pack
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--m47-lab-root", type=Path, required=True)
|
||||
parser.add_argument("--graph-result-root", type=Path, required=True)
|
||||
parser.add_argument("--threat-result-root", type=Path, required=True)
|
||||
parser.add_argument("--geometry-result-root", type=Path, required=True)
|
||||
parser.add_argument("--camera-index", type=Path, required=True)
|
||||
parser.add_argument("--selection", type=Path, required=True)
|
||||
parser.add_argument("--frozen-at-utc", required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = prepare_m48_ravnoves00_pack(
|
||||
m47_lab_root=args.m47_lab_root,
|
||||
graph_result_root=args.graph_result_root,
|
||||
threat_result_root=args.threat_result_root,
|
||||
geometry_result_root=args.geometry_result_root,
|
||||
camera_index_path=args.camera_index,
|
||||
selection_path=args.selection,
|
||||
frozen_at_utc=args.frozen_at_utc,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"status": result.report["status"],
|
||||
"clip_count": result.report["metrics"]["clip_count"],
|
||||
"frame_count": result.report["metrics"]["frame_count"],
|
||||
"truth_labels_available": False,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish one canonical append-only M4.8 small-static regression run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.compute.pipeline_telemetry import JsonlPipelineTelemetrySink
|
||||
from k1link.laboratory import (
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryExecutionRegistry,
|
||||
LaboratoryRunner,
|
||||
LaboratoryRunRequest,
|
||||
)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--pack-root", type=Path, required=True)
|
||||
parser.add_argument("--correction-session", type=Path, required=True)
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
parser.add_argument("--receipt-root", type=Path, required=True)
|
||||
parser.add_argument("--telemetry-path", type=Path, required=True)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument("--request-id", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _parser().parse_args()
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
evidence = LaboratoryEvidenceRegistry.from_directory(
|
||||
repository_root / "config" / "laboratories"
|
||||
)
|
||||
execution = LaboratoryExecutionRegistry.from_file(
|
||||
repository_root / "config" / "laboratory-execution.json",
|
||||
evidence,
|
||||
)
|
||||
runner = LaboratoryRunner(
|
||||
registry=execution,
|
||||
evidence_registry=evidence,
|
||||
sink=JsonlPipelineTelemetrySink(args.telemetry_path),
|
||||
)
|
||||
pack_id = args.pack_root.name
|
||||
result = runner.run(
|
||||
LaboratoryRunRequest(
|
||||
work_id="m48-small-static-passage-regression",
|
||||
run_id=args.run_id,
|
||||
request_id=args.request_id,
|
||||
contour_id="mission-core-laboratory",
|
||||
agent_id="local-control-plane",
|
||||
node_id=socket.gethostname(),
|
||||
source_id="RAVNOVES00",
|
||||
source_package_id=pack_id,
|
||||
method_id="m48-small-static-passage-regression/v1",
|
||||
inputs={
|
||||
"pack_root": args.pack_root,
|
||||
"correction_session_path": args.correction_session,
|
||||
"profile_path": args.profile,
|
||||
},
|
||||
output_root=args.output_root,
|
||||
receipt_root=args.receipt_root,
|
||||
)
|
||||
)
|
||||
print(json.dumps({
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"receipt_id": result.receipt_id,
|
||||
"receipt_root": str(result.receipt_root),
|
||||
}, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from k1link.laboratory.evidence_registry import (
|
||||
LABORATORY_EVIDENCE_DEFINITION_SCHEMA,
|
||||
LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA,
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceVariant,
|
||||
LaboratoryRegistryError,
|
||||
)
|
||||
from k1link.laboratory.evidence_report import (
|
||||
@@ -34,9 +36,11 @@ from k1link.laboratory.value_review_registry import (
|
||||
|
||||
__all__ = [
|
||||
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA",
|
||||
"LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA",
|
||||
"LABORATORY_EVIDENCE_REPORT_SCHEMA",
|
||||
"LaboratoryEvidenceDefinition",
|
||||
"LaboratoryEvidenceRegistry",
|
||||
"LaboratoryEvidenceVariant",
|
||||
"LaboratoryEvidenceReportError",
|
||||
"LaboratoryEvidenceReportNotFound",
|
||||
"LaboratoryEvidenceReportService",
|
||||
|
||||
@@ -7,13 +7,22 @@ from pathlib import Path, PurePosixPath
|
||||
from typing import Final
|
||||
|
||||
LABORATORY_EVIDENCE_DEFINITION_SCHEMA: Final = "missioncore.laboratory-evidence-definition/v1"
|
||||
LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA: Final = (
|
||||
"missioncore.laboratory-evidence-definition/v2"
|
||||
)
|
||||
_DEFINITION_MAX_BYTES: Final = 16 * 1024
|
||||
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
||||
_SCHEMA_VERSION = re.compile(r"^missioncore\.[a-z0-9.-]+/v[1-9][0-9]*$")
|
||||
_TOP_LEVEL_KEYS: Final = frozenset({"schema_version", "work_id", "evidence"})
|
||||
_LIFECYCLE_TOP_LEVEL_KEYS: Final = frozenset(
|
||||
{"schema_version", "work_id", "evidence_lifecycle"}
|
||||
)
|
||||
_EVIDENCE_KEYS: Final = frozenset(
|
||||
{"runtime_relative_root", "result_id_prefix", "document_name", "schema_version"}
|
||||
)
|
||||
_LIFECYCLE_EVIDENCE_KEYS: Final = frozenset(
|
||||
{"phase", "runtime_relative_root", "result_id_prefix", "document_name", "schema_version"}
|
||||
)
|
||||
|
||||
|
||||
class LaboratoryRegistryError(ValueError):
|
||||
@@ -21,15 +30,15 @@ class LaboratoryRegistryError(ValueError):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratoryEvidenceDefinition:
|
||||
work_id: str
|
||||
class LaboratoryEvidenceVariant:
|
||||
phase: str
|
||||
runtime_relative_root: PurePosixPath
|
||||
result_id_prefix: str
|
||||
document_name: str
|
||||
result_schema_version: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.work_id, "work_id")
|
||||
_identifier(self.phase, "evidence phase")
|
||||
_identifier(self.result_id_prefix, "result_id_prefix")
|
||||
_document_name(self.document_name)
|
||||
_schema_version(self.result_schema_version)
|
||||
@@ -45,6 +54,69 @@ class LaboratoryEvidenceDefinition:
|
||||
return runtime_root.joinpath(*self.runtime_relative_root.parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratoryEvidenceDefinition:
|
||||
work_id: str
|
||||
runtime_relative_root: PurePosixPath
|
||||
result_id_prefix: str
|
||||
document_name: str
|
||||
result_schema_version: str
|
||||
lifecycle_variants: tuple[LaboratoryEvidenceVariant, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_identifier(self.work_id, "work_id")
|
||||
primary = LaboratoryEvidenceVariant(
|
||||
phase="result",
|
||||
runtime_relative_root=self.runtime_relative_root,
|
||||
result_id_prefix=self.result_id_prefix,
|
||||
document_name=self.document_name,
|
||||
result_schema_version=self.result_schema_version,
|
||||
)
|
||||
if not self.lifecycle_variants:
|
||||
return
|
||||
if not all(
|
||||
isinstance(variant, LaboratoryEvidenceVariant)
|
||||
for variant in self.lifecycle_variants
|
||||
):
|
||||
raise LaboratoryRegistryError("LAB lifecycle variants must be immutable evidence")
|
||||
if self.lifecycle_variants[-1] != primary:
|
||||
raise LaboratoryRegistryError("LAB lifecycle terminal evidence must be primary")
|
||||
phases = [variant.phase for variant in self.lifecycle_variants]
|
||||
if len(phases) != len(set(phases)):
|
||||
raise LaboratoryRegistryError("duplicate LAB evidence phase")
|
||||
|
||||
@property
|
||||
def evidence_variants(self) -> tuple[LaboratoryEvidenceVariant, ...]:
|
||||
if self.lifecycle_variants:
|
||||
return self.lifecycle_variants
|
||||
return (
|
||||
LaboratoryEvidenceVariant(
|
||||
phase="result",
|
||||
runtime_relative_root=self.runtime_relative_root,
|
||||
result_id_prefix=self.result_id_prefix,
|
||||
document_name=self.document_name,
|
||||
result_schema_version=self.result_schema_version,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def result_id_pattern(self) -> re.Pattern[str]:
|
||||
return re.compile(rf"^{re.escape(self.result_id_prefix)}-[a-f0-9]{{64}}$")
|
||||
|
||||
def result_root(self, runtime_root: Path) -> Path:
|
||||
return runtime_root.joinpath(*self.runtime_relative_root.parts)
|
||||
|
||||
def variant_for_result_id(self, result_id: str) -> LaboratoryEvidenceVariant | None:
|
||||
return next(
|
||||
(
|
||||
variant
|
||||
for variant in self.evidence_variants
|
||||
if variant.result_id_pattern.fullmatch(result_id) is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LaboratoryEvidenceRegistry:
|
||||
definitions: tuple[LaboratoryEvidenceDefinition, ...]
|
||||
@@ -89,14 +161,42 @@ def _read_definition(path: Path) -> LaboratoryEvidenceDefinition:
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise LaboratoryRegistryError(f"LAB definition is unreadable: {path.name}") from exc
|
||||
document = _object(payload, f"LAB definition {path.name}")
|
||||
_exact_keys(document, _TOP_LEVEL_KEYS, f"LAB definition {path.name}")
|
||||
if document["schema_version"] != LABORATORY_EVIDENCE_DEFINITION_SCHEMA:
|
||||
schema_version = document.get("schema_version")
|
||||
if schema_version not in {
|
||||
LABORATORY_EVIDENCE_DEFINITION_SCHEMA,
|
||||
LABORATORY_EVIDENCE_LIFECYCLE_DEFINITION_SCHEMA,
|
||||
}:
|
||||
raise LaboratoryRegistryError(f"LAB definition schema is invalid: {path.name}")
|
||||
expected_keys = (
|
||||
_TOP_LEVEL_KEYS
|
||||
if schema_version == LABORATORY_EVIDENCE_DEFINITION_SCHEMA
|
||||
else _LIFECYCLE_TOP_LEVEL_KEYS
|
||||
)
|
||||
_exact_keys(document, expected_keys, f"LAB definition {path.name}")
|
||||
work_id = _identifier(document["work_id"], "work_id")
|
||||
if path.name != f"{work_id}.json":
|
||||
raise LaboratoryRegistryError(f"LAB definition filename must match work_id: {path.name}")
|
||||
evidence = _object(document["evidence"], f"LAB evidence {work_id}")
|
||||
_exact_keys(evidence, _EVIDENCE_KEYS, f"LAB evidence {work_id}")
|
||||
if schema_version == LABORATORY_EVIDENCE_DEFINITION_SCHEMA:
|
||||
lifecycle_variants: tuple[LaboratoryEvidenceVariant, ...] = ()
|
||||
evidence = _object(document["evidence"], f"LAB evidence {work_id}")
|
||||
_exact_keys(evidence, _EVIDENCE_KEYS, f"LAB evidence {work_id}")
|
||||
else:
|
||||
lifecycle = document["evidence_lifecycle"]
|
||||
if not isinstance(lifecycle, list) or len(lifecycle) < 2:
|
||||
raise LaboratoryRegistryError(
|
||||
f"LAB evidence lifecycle must contain at least two phases: {work_id}"
|
||||
)
|
||||
lifecycle_variants = tuple(
|
||||
_read_variant(row, f"LAB evidence {work_id}[{index}]")
|
||||
for index, row in enumerate(lifecycle)
|
||||
)
|
||||
terminal = lifecycle_variants[-1]
|
||||
evidence = {
|
||||
"runtime_relative_root": str(terminal.runtime_relative_root),
|
||||
"result_id_prefix": terminal.result_id_prefix,
|
||||
"document_name": terminal.document_name,
|
||||
"schema_version": terminal.result_schema_version,
|
||||
}
|
||||
result_id_prefix = _identifier(evidence["result_id_prefix"], "result_id_prefix")
|
||||
document_name = _document_name(evidence["document_name"])
|
||||
result_schema_version = _schema_version(evidence["schema_version"])
|
||||
@@ -106,6 +206,19 @@ def _read_definition(path: Path) -> LaboratoryEvidenceDefinition:
|
||||
result_id_prefix=result_id_prefix,
|
||||
document_name=document_name,
|
||||
result_schema_version=result_schema_version,
|
||||
lifecycle_variants=lifecycle_variants,
|
||||
)
|
||||
|
||||
|
||||
def _read_variant(value: object, label: str) -> LaboratoryEvidenceVariant:
|
||||
evidence = _object(value, label)
|
||||
_exact_keys(evidence, _LIFECYCLE_EVIDENCE_KEYS, label)
|
||||
return LaboratoryEvidenceVariant(
|
||||
phase=_identifier(evidence["phase"], f"{label}.phase"),
|
||||
runtime_relative_root=_relative_root(evidence["runtime_relative_root"]),
|
||||
result_id_prefix=_identifier(evidence["result_id_prefix"], "result_id_prefix"),
|
||||
document_name=_document_name(evidence["document_name"]),
|
||||
result_schema_version=_schema_version(evidence["schema_version"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -177,9 +290,15 @@ def _relative_root(value: object) -> PurePosixPath:
|
||||
def _reject_duplicates(definitions: tuple[LaboratoryEvidenceDefinition, ...]) -> None:
|
||||
dimensions = {
|
||||
"work_id": [definition.work_id for definition in definitions],
|
||||
"result_id_prefix": [definition.result_id_prefix for definition in definitions],
|
||||
"result_id_prefix": [
|
||||
variant.result_id_prefix
|
||||
for definition in definitions
|
||||
for variant in definition.evidence_variants
|
||||
],
|
||||
"runtime_relative_root": [
|
||||
str(definition.runtime_relative_root) for definition in definitions
|
||||
str(variant.runtime_relative_root)
|
||||
for definition in definitions
|
||||
for variant in definition.evidence_variants
|
||||
],
|
||||
}
|
||||
for label, values in dimensions.items():
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, Final
|
||||
from k1link.laboratory.evidence_registry import (
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceVariant,
|
||||
)
|
||||
|
||||
LABORATORY_EVIDENCE_REPORT_SCHEMA: Final = "missioncore.laboratory-evidence-report/v1"
|
||||
@@ -40,12 +41,13 @@ def verify_laboratory_evidence_result(
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportError("LAB evidence result is unavailable") from exc
|
||||
if not resolved.is_dir() or definition.result_id_pattern.fullmatch(resolved.name) is None:
|
||||
variant = definition.variant_for_result_id(resolved.name)
|
||||
if not resolved.is_dir() or variant is None:
|
||||
raise LaboratoryEvidenceReportError("LAB evidence result path is invalid")
|
||||
document_path = _safe_file(resolved, definition.document_name)
|
||||
document_path = _safe_file(resolved, variant.document_name)
|
||||
document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document")
|
||||
document = _json_object(document_bytes, "LAB document")
|
||||
_validate_document(document, definition, resolved.name)
|
||||
_validate_document(document, variant, resolved.name)
|
||||
identity = _object_or_none(document.get("identity"))
|
||||
identity_sha256 = document.get("identity_sha256")
|
||||
if identity is None or not isinstance(identity_sha256, str):
|
||||
@@ -77,13 +79,14 @@ class LaboratoryEvidenceReportService:
|
||||
|
||||
def read(self, work_id: str, result_id: str) -> dict[str, object]:
|
||||
definition = self._definitions.get(work_id)
|
||||
if definition is None or definition.result_id_pattern.fullmatch(result_id) is None:
|
||||
variant = definition.variant_for_result_id(result_id) if definition is not None else None
|
||||
if definition is None or variant is None:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB evidence identity is unknown")
|
||||
result_root = self._result_root(definition, result_id)
|
||||
document_path = _safe_file(result_root, definition.document_name)
|
||||
result_root = self._result_root(variant, result_id)
|
||||
document_path = _safe_file(result_root, variant.document_name)
|
||||
document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document")
|
||||
document = _json_object(document_bytes, "LAB document")
|
||||
_validate_document(document, definition, result_id)
|
||||
_validate_document(document, variant, result_id)
|
||||
|
||||
identity = _object_or_none(document.get("identity"))
|
||||
identity_sha256 = document.get("identity_sha256")
|
||||
@@ -210,7 +213,7 @@ class LaboratoryEvidenceReportService:
|
||||
|
||||
def _result_root(
|
||||
self,
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
variant: LaboratoryEvidenceVariant,
|
||||
result_id: str,
|
||||
) -> Path:
|
||||
configured = self._runtime_root_provider()
|
||||
@@ -223,7 +226,7 @@ class LaboratoryEvidenceReportService:
|
||||
runtime_root = runtime_root.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") from exc
|
||||
candidate = definition.result_root(runtime_root) / result_id
|
||||
candidate = variant.result_root(runtime_root) / result_id
|
||||
if candidate.is_symlink():
|
||||
raise LaboratoryEvidenceReportError("LAB result must not be a symlink")
|
||||
try:
|
||||
@@ -237,7 +240,7 @@ class LaboratoryEvidenceReportService:
|
||||
|
||||
def _validate_document(
|
||||
document: dict[str, Any],
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
definition: LaboratoryEvidenceVariant,
|
||||
result_id: str,
|
||||
) -> None:
|
||||
if document.get("schema_version") != definition.result_schema_version:
|
||||
|
||||
@@ -169,10 +169,11 @@ class LaboratoryExecutionRegistry:
|
||||
f"laboratory classification is incomplete; missing={missing}, unknown={unknown}"
|
||||
)
|
||||
for definition in self.definitions:
|
||||
if (
|
||||
evidence_by_work_id[definition.work_id].result_schema_version
|
||||
!= definition.evidence_contract
|
||||
):
|
||||
evidence_contracts = {
|
||||
variant.result_schema_version
|
||||
for variant in evidence_by_work_id[definition.work_id].evidence_variants
|
||||
}
|
||||
if definition.evidence_contract not in evidence_contracts:
|
||||
raise LaboratoryExecutionError(
|
||||
f"laboratory evidence contract mismatch: {definition.work_id}"
|
||||
)
|
||||
@@ -311,6 +312,10 @@ class LaboratoryRunner:
|
||||
|
||||
def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
||||
return {
|
||||
"canonical.m48-small-static-passage-regression/v1": (
|
||||
_run_m48_small_static_passage_regression
|
||||
),
|
||||
"canonical.m48-object-centric-quality/v1": _run_m48_object_centric_quality,
|
||||
"canonical.m4-replay-threat/v1": _run_m4_replay_threat,
|
||||
"canonical.e33-worker-shadow/v1": _run_e33,
|
||||
"canonical.e35-degradation-recovery/v1": _run_e35,
|
||||
@@ -319,6 +324,41 @@ def canonical_laboratory_adapters() -> dict[str, LaboratoryAdapter]:
|
||||
}
|
||||
|
||||
|
||||
def _run_m48_small_static_passage_regression(
|
||||
request: LaboratoryRunRequest,
|
||||
) -> LaboratoryAdapterResult:
|
||||
from k1link.laboratory.m48_small_static_regression import (
|
||||
build_m48_small_static_passage_regression,
|
||||
)
|
||||
|
||||
result = build_m48_small_static_passage_regression(
|
||||
pack_root=request.inputs["pack_root"],
|
||||
correction_session_path=request.inputs["correction_session_path"],
|
||||
profile_path=request.inputs["profile_path"],
|
||||
output_root=request.output_root,
|
||||
)
|
||||
return LaboratoryAdapterResult(
|
||||
result_root=result.result_root,
|
||||
result_id=result.result_id,
|
||||
)
|
||||
|
||||
|
||||
def _run_m48_object_centric_quality(
|
||||
request: LaboratoryRunRequest,
|
||||
) -> LaboratoryAdapterResult:
|
||||
from k1link.laboratory.m48_object_quality import score_m48_object_quality
|
||||
|
||||
result = score_m48_object_quality(
|
||||
pack_root=request.inputs["pack_root"],
|
||||
truth_seal_root=request.inputs["truth_seal_root"],
|
||||
output_root=request.output_root,
|
||||
)
|
||||
return LaboratoryAdapterResult(
|
||||
result_root=result.result_root,
|
||||
result_id=result.result_id,
|
||||
)
|
||||
|
||||
|
||||
def _run_m4_replay_threat(request: LaboratoryRunRequest) -> LaboratoryAdapterResult:
|
||||
from k1link.perception.threat_replay import build_threat_replay
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,567 @@
|
||||
"""Deterministic RAVNOVES00 adapter for the M4.8 object-quality pack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Iterator, Mapping
|
||||
from itertools import zip_longest
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.laboratory.m47_reference_graph import read_m47_reference_graph_lab
|
||||
from k1link.laboratory.m48_object_quality import (
|
||||
M48_PREPARATION_PROVENANCE_SCHEMA,
|
||||
M48_SELECTION_HYPOTHESIS_PROFILE,
|
||||
M48ObjectQualityPack,
|
||||
build_m48_object_quality_pack,
|
||||
)
|
||||
|
||||
M48_SELECTION_SCHEMA: Final = "missioncore.m48-object-quality-selection/v1"
|
||||
M48_SELECTION_ID: Final = "m48-ravnoves00-balanced-connected-clips/v1"
|
||||
M48_SOURCE_ID: Final = "RAVNOVES00"
|
||||
M48_SOURCE_SESSION_ID: Final = "20260720T065719Z_viewer_live"
|
||||
M48_FRAME_COUNT: Final = 4_489
|
||||
M48_IMAGE_WIDTH: Final = 800
|
||||
M48_IMAGE_HEIGHT: Final = 600
|
||||
_CAMERA_INDEX_SCHEMA: Final = "missioncore.camera-recording-index/v1"
|
||||
_GRAPH_FRAME_SCHEMA: Final = "missioncore.local-obstacle-map/v1"
|
||||
_THREAT_FRAME_SCHEMAS: Final = frozenset(
|
||||
{
|
||||
"missioncore.perception-threat-replay-frame/v1",
|
||||
"missioncore.perception-threat-replay-frame/v2",
|
||||
}
|
||||
)
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class M48Ravnoves00PackError(RuntimeError):
|
||||
"""The source adapter escaped the accepted immutable RAVNOVES00 evidence."""
|
||||
|
||||
|
||||
def prepare_m48_ravnoves00_pack(
|
||||
*,
|
||||
m47_lab_root: Path,
|
||||
graph_result_root: Path,
|
||||
threat_result_root: Path,
|
||||
geometry_result_root: Path,
|
||||
camera_index_path: Path,
|
||||
selection_path: Path,
|
||||
frozen_at_utc: str,
|
||||
output_root: Path,
|
||||
) -> M48ObjectQualityPack:
|
||||
"""Freeze the selected M4.8 clips from the exact accepted M4.7 source."""
|
||||
|
||||
lab = read_m47_reference_graph_lab(m47_lab_root)
|
||||
source = _mapping(lab.report.get("source"), "M4.7 source")
|
||||
graph_root = _directory(graph_result_root, "M4.7 graph result")
|
||||
threat_root = _directory(threat_result_root, "M4.6 visual result")
|
||||
geometry_root = _directory(geometry_result_root, "M4.4 geometry result")
|
||||
camera_index = _file(camera_index_path, "recorded camera index")
|
||||
selection = _read_json(_file(selection_path, "M4.8 selection"), "M4.8 selection")
|
||||
clips = _selection_clips(selection)
|
||||
|
||||
if (
|
||||
graph_root.name != source.get("graph_result_id")
|
||||
or threat_root.name != source.get("visual_result_id")
|
||||
or source.get("source_id") != M48_SOURCE_ID
|
||||
or source.get("source_session_id") != M48_SOURCE_SESSION_ID
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.8 source roots do not match the accepted M4.7 LAB")
|
||||
|
||||
graph_frames_path = _validate_graph_result(graph_root)
|
||||
threat_frames_path, threat_identity = _validate_threat_result(
|
||||
threat_root,
|
||||
expected_frames_sha256=source.get("threat_frames_sha256"),
|
||||
)
|
||||
geometry_frames_path = _validate_geometry_result(
|
||||
geometry_root,
|
||||
expected_result_id=threat_identity.get("geometry_result_id"),
|
||||
expected_frames_sha256=threat_identity.get("geometry_frames_sha256"),
|
||||
)
|
||||
camera_rows = tuple(_iter_jsonl(camera_index, "recorded camera index"))
|
||||
_validate_camera_rows(camera_rows)
|
||||
|
||||
selected_sequences = {
|
||||
sequence
|
||||
for clip in clips
|
||||
for sequence in range(
|
||||
_integer(clip.get("start_sequence"), "clip start_sequence"),
|
||||
_integer(clip.get("end_sequence"), "clip end_sequence") + 1,
|
||||
)
|
||||
}
|
||||
frame_catalog: list[dict[str, object]] = []
|
||||
predictions: list[dict[str, object]] = []
|
||||
previous_source_time_ns = -1
|
||||
graph_rows = _iter_jsonl(graph_frames_path, "M4.7 graph frames")
|
||||
threat_rows = _iter_jsonl(threat_frames_path, "M4.6 threat frames")
|
||||
geometry_rows = _iter_jsonl(geometry_frames_path, "M4.4 geometry frames")
|
||||
for frame_index, values in enumerate(
|
||||
zip_longest(graph_rows, threat_rows, geometry_rows, camera_rows),
|
||||
):
|
||||
graph_row, threat_row, geometry_row, camera_row = values
|
||||
if graph_row is None or threat_row is None or geometry_row is None or camera_row is None:
|
||||
raise M48Ravnoves00PackError("M4.8 source ledgers have different lengths")
|
||||
sequence = frame_index + 1
|
||||
source_time_ns = _validate_bound_frame(
|
||||
graph_row=graph_row,
|
||||
threat_row=threat_row,
|
||||
geometry_row=geometry_row,
|
||||
camera_row=camera_row,
|
||||
frame_index=frame_index,
|
||||
previous_source_time_ns=previous_source_time_ns,
|
||||
)
|
||||
previous_source_time_ns = source_time_ns
|
||||
frame_catalog.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"source_time_ns": source_time_ns,
|
||||
"camera_fragment_sha256": camera_row["sha256"],
|
||||
}
|
||||
)
|
||||
if sequence in selected_sequences:
|
||||
obstacle_map = _mapping(graph_row.get("obstacle_map"), "M4.7 obstacle map")
|
||||
predictions.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"source_time_ns": source_time_ns,
|
||||
"terminal_outcome": "delivered",
|
||||
"terminal_reason": None,
|
||||
"free_space_claimed": obstacle_map["free_space_claimed"],
|
||||
"objects": _prediction_objects(
|
||||
threat_row.get("camera_proposals"),
|
||||
geometry_observations=geometry_row.get("observations"),
|
||||
metric_obstacles=threat_row.get("metric_obstacles"),
|
||||
),
|
||||
}
|
||||
)
|
||||
if len(frame_catalog) != M48_FRAME_COUNT:
|
||||
raise M48Ravnoves00PackError("M4.8 source frame count changed")
|
||||
|
||||
preparation_provenance = {
|
||||
"schema_version": M48_PREPARATION_PROVENANCE_SCHEMA,
|
||||
"adapter": {
|
||||
"module": "k1link.laboratory.m48_ravnoves00_pack",
|
||||
"sha256": _file_sha256(Path(__file__).resolve(strict=True)),
|
||||
},
|
||||
"selection": {
|
||||
"selection_id": M48_SELECTION_ID,
|
||||
"sha256": _file_sha256(selection_path),
|
||||
},
|
||||
"camera_index": {
|
||||
"source_session_id": M48_SOURCE_SESSION_ID,
|
||||
"sha256": _file_sha256(camera_index),
|
||||
"byte_length": camera_index.stat().st_size,
|
||||
"frame_count": len(camera_rows),
|
||||
},
|
||||
"graph": _source_provenance(graph_root, graph_frames_path),
|
||||
"threat": _source_provenance(threat_root, threat_frames_path),
|
||||
"geometry": _source_provenance(geometry_root, geometry_frames_path),
|
||||
}
|
||||
|
||||
return build_m48_object_quality_pack(
|
||||
m47_lab_root=lab.result_root,
|
||||
frame_catalog=frame_catalog,
|
||||
clips=clips,
|
||||
predictions=predictions,
|
||||
preparation_provenance=preparation_provenance,
|
||||
frozen_at_utc=frozen_at_utc,
|
||||
output_root=output_root,
|
||||
)
|
||||
|
||||
|
||||
def _validate_graph_result(root: Path) -> Path:
|
||||
manifest = _read_json(_file(root / "manifest.json", "M4.7 graph manifest"), "graph manifest")
|
||||
files = _mapping(manifest.get("files"), "M4.7 graph files")
|
||||
descriptor = _mapping(files.get("frames.jsonl"), "M4.7 graph frame descriptor")
|
||||
frames = _file(root / "frames.jsonl", "M4.7 graph frames")
|
||||
expected_bytes = descriptor.get("bytes")
|
||||
expected_sha256 = descriptor.get("sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != "missioncore.reference-perception-graph-manifest/v1"
|
||||
or manifest.get("result_id") != root.name
|
||||
or manifest.get("accepted") is not True
|
||||
or manifest.get("graph_id") != "reference-perception-graph/v2"
|
||||
or manifest.get("run_mode") != "lossless-replay"
|
||||
or not isinstance(expected_bytes, int)
|
||||
or expected_bytes != frames.stat().st_size
|
||||
or not _is_sha256(expected_sha256)
|
||||
or _file_sha256(frames) != expected_sha256
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.7 graph result changed")
|
||||
return frames
|
||||
|
||||
|
||||
def _validate_threat_result(
|
||||
root: Path,
|
||||
*,
|
||||
expected_frames_sha256: object,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
manifest = _read_json(
|
||||
_file(root / "manifest.json", "M4.6 threat manifest"),
|
||||
"threat manifest",
|
||||
)
|
||||
identity = _mapping(manifest.get("identity"), "M4.6 threat identity")
|
||||
frames = _file(root / "frames.jsonl", "M4.6 threat frames")
|
||||
if (
|
||||
manifest.get("schema_version") != "missioncore.perception-threat-replay-result/v2"
|
||||
or manifest.get("result_id") != root.name
|
||||
or manifest.get("accepted") is not True
|
||||
or identity.get("source_session_id") != M48_SOURCE_SESSION_ID
|
||||
or not _is_sha256(expected_frames_sha256)
|
||||
or identity.get("frames_sha256") != expected_frames_sha256
|
||||
or _file_sha256(frames) != expected_frames_sha256
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.6 threat result changed")
|
||||
return frames, identity
|
||||
|
||||
|
||||
def _validate_geometry_result(
|
||||
root: Path,
|
||||
*,
|
||||
expected_result_id: object,
|
||||
expected_frames_sha256: object,
|
||||
) -> Path:
|
||||
manifest = _read_json(
|
||||
_file(root / "manifest.json", "M4.4 geometry manifest"),
|
||||
"geometry manifest",
|
||||
)
|
||||
identity = _mapping(manifest.get("identity"), "M4.4 geometry identity")
|
||||
frames = _file(root / "frames.jsonl", "M4.4 geometry frames")
|
||||
if (
|
||||
manifest.get("schema_version") != "missioncore.perception-geometry-replay-result/v1"
|
||||
or root.name != expected_result_id
|
||||
or identity.get("accepted") is not True
|
||||
or identity.get("source_pack_id")
|
||||
!= "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b"
|
||||
or not _is_sha256(expected_frames_sha256)
|
||||
or identity.get("frames_sha256") != expected_frames_sha256
|
||||
or _file_sha256(frames) != expected_frames_sha256
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.4 geometry result changed")
|
||||
return frames
|
||||
|
||||
|
||||
def _selection_clips(document: Mapping[str, object]) -> tuple[dict[str, object], ...]:
|
||||
expected_keys = {
|
||||
"schema_version",
|
||||
"selection_id",
|
||||
"source_id",
|
||||
"source_session_id",
|
||||
"selection_basis",
|
||||
"camera_frame_size",
|
||||
"selection_hypothesis_profile",
|
||||
"clips",
|
||||
}
|
||||
frame_size = _mapping(document.get("camera_frame_size"), "selection frame size")
|
||||
raw_clips = document.get("clips")
|
||||
if (
|
||||
set(document) != expected_keys
|
||||
or document.get("schema_version") != M48_SELECTION_SCHEMA
|
||||
or document.get("selection_id") != M48_SELECTION_ID
|
||||
or document.get("source_id") != M48_SOURCE_ID
|
||||
or document.get("source_session_id") != M48_SOURCE_SESSION_ID
|
||||
or document.get("selection_basis")
|
||||
!= "prediction-frozen-source-curation-before-independent-truth"
|
||||
or frame_size != {"width": M48_IMAGE_WIDTH, "height": M48_IMAGE_HEIGHT}
|
||||
or document.get("selection_hypothesis_profile") != M48_SELECTION_HYPOTHESIS_PROFILE
|
||||
or not isinstance(raw_clips, list)
|
||||
or any(not isinstance(item, dict) for item in raw_clips)
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.8 selection contract changed")
|
||||
return tuple(dict(item) for item in raw_clips)
|
||||
|
||||
|
||||
def _source_provenance(root: Path, frames_path: Path) -> dict[str, str]:
|
||||
return {
|
||||
"result_id": root.name,
|
||||
"manifest_sha256": _file_sha256(root / "manifest.json"),
|
||||
"frames_sha256": _file_sha256(frames_path),
|
||||
}
|
||||
|
||||
|
||||
def _validate_camera_rows(rows: tuple[dict[str, Any], ...]) -> None:
|
||||
if len(rows) != M48_FRAME_COUNT:
|
||||
raise M48Ravnoves00PackError("recorded camera index frame count changed")
|
||||
previous_session_time = -1
|
||||
for expected_sequence, row in enumerate(rows, start=1):
|
||||
session_time = row.get("session_monotonic_ns")
|
||||
if (
|
||||
row.get("schema_version") != _CAMERA_INDEX_SCHEMA
|
||||
or row.get("kind") != "media"
|
||||
or row.get("sequence") != expected_sequence
|
||||
or not isinstance(session_time, int)
|
||||
or session_time <= previous_session_time
|
||||
or not _is_sha256(row.get("sha256"))
|
||||
):
|
||||
raise M48Ravnoves00PackError("recorded camera index changed")
|
||||
previous_session_time = session_time
|
||||
|
||||
|
||||
def _validate_bound_frame(
|
||||
*,
|
||||
graph_row: Mapping[str, Any],
|
||||
threat_row: Mapping[str, Any],
|
||||
geometry_row: Mapping[str, Any],
|
||||
camera_row: Mapping[str, Any],
|
||||
frame_index: int,
|
||||
previous_source_time_ns: int,
|
||||
) -> int:
|
||||
obstacle_map = _mapping(graph_row.get("obstacle_map"), "M4.7 obstacle map")
|
||||
source_time_ns = threat_row.get("source_time_ns")
|
||||
if (
|
||||
graph_row.get("sequence") != frame_index
|
||||
or obstacle_map.get("schema_version") != _GRAPH_FRAME_SCHEMA
|
||||
or obstacle_map.get("frame_id") != f"frame-{frame_index:06d}"
|
||||
or not isinstance(obstacle_map.get("free_space_claimed"), bool)
|
||||
or threat_row.get("schema_version") not in _THREAT_FRAME_SCHEMAS
|
||||
or threat_row.get("sequence") != frame_index
|
||||
or threat_row.get("frame_id") != f"frame-{frame_index:06d}"
|
||||
or geometry_row.get("schema_version") != "missioncore.perception-geometry-replay-frame/v1"
|
||||
or geometry_row.get("sequence") != frame_index
|
||||
or geometry_row.get("frame_id") != f"frame-{frame_index:06d}"
|
||||
or geometry_row.get("source_available") != threat_row.get("source_available")
|
||||
or not isinstance(source_time_ns, int)
|
||||
or source_time_ns <= previous_source_time_ns
|
||||
or camera_row.get("sequence") != frame_index + 1
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.8 frame binding changed")
|
||||
return source_time_ns
|
||||
|
||||
|
||||
def _prediction_objects(
|
||||
value: object,
|
||||
*,
|
||||
geometry_observations: object,
|
||||
metric_obstacles: object,
|
||||
) -> list[dict[str, object]]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
||||
raise M48Ravnoves00PackError("M4.6 camera proposal collection changed")
|
||||
observations = _proposal_observations(geometry_observations)
|
||||
obstacles = _metric_obstacles(metric_obstacles)
|
||||
objects: list[dict[str, object]] = []
|
||||
seen: set[str] = set()
|
||||
for proposal in value:
|
||||
prediction_id = proposal.get("proposal_id")
|
||||
occupied_support = proposal.get("occupied_support")
|
||||
threat_value = proposal.get("threat_decision")
|
||||
if (
|
||||
not isinstance(prediction_id, str)
|
||||
or prediction_id in seen
|
||||
or not isinstance(occupied_support, bool)
|
||||
or threat_value not in {None, "threat", "not-threat", "unknown"}
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.6 camera proposal identity changed")
|
||||
seen.add(prediction_id)
|
||||
observation = observations.get(prediction_id)
|
||||
geometry = "associated" if occupied_support else "unknown"
|
||||
freshness = "current"
|
||||
motion = "unsupported"
|
||||
threat = threat_value if isinstance(threat_value, str) else "unknown"
|
||||
if occupied_support:
|
||||
if observation is None:
|
||||
raise M48Ravnoves00PackError("associated proposal lost its geometry observation")
|
||||
currentness = observation.get("currentness")
|
||||
if currentness not in {"current", "held", "stale", "unavailable"}:
|
||||
raise M48Ravnoves00PackError("associated proposal currentness changed")
|
||||
freshness = str(currentness)
|
||||
centroid = _metric_centroid(observation)
|
||||
obstacle = _match_metric_obstacle(centroid, obstacles)
|
||||
raw_motion = obstacle.get("motion")
|
||||
motion = {
|
||||
"moving": "moving",
|
||||
"stationary": "static",
|
||||
"unknown": "unknown",
|
||||
}.get(str(raw_motion), "")
|
||||
assessment = _mapping(obstacle.get("assessment"), "metric obstacle assessment")
|
||||
obstacle_threat = assessment.get("decision")
|
||||
if not motion or obstacle_threat not in {"threat", "not-threat", "unknown"}:
|
||||
raise M48Ravnoves00PackError("associated proposal state changed")
|
||||
threat = str(obstacle_threat)
|
||||
causes: set[str] = set()
|
||||
if geometry == "unknown":
|
||||
causes.add("insufficient-geometry-support")
|
||||
if threat == "unknown":
|
||||
causes.add("threat-evidence-insufficient")
|
||||
if motion == "unknown":
|
||||
causes.add("motion-not-supported")
|
||||
objects.append(
|
||||
{
|
||||
"prediction_id": prediction_id,
|
||||
"extent_xyxy": _normalized_extent(proposal.get("bbox_xyxy")),
|
||||
"geometry_association": geometry,
|
||||
"freshness": freshness,
|
||||
"motion": motion,
|
||||
"threat": threat,
|
||||
"unknown_causes": sorted(causes),
|
||||
}
|
||||
)
|
||||
return objects
|
||||
|
||||
|
||||
def _proposal_observations(value: object) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
||||
raise M48Ravnoves00PackError("M4.4 observation collection changed")
|
||||
mapped: dict[str, dict[str, Any]] = {}
|
||||
for observation in value:
|
||||
proposal_ids = observation.get("proposal_ids")
|
||||
if not isinstance(proposal_ids, list) or any(
|
||||
not isinstance(item, str) for item in proposal_ids
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.4 proposal binding changed")
|
||||
for proposal_id in proposal_ids:
|
||||
if proposal_id in mapped:
|
||||
raise M48Ravnoves00PackError("M4.4 proposal has multiple observations")
|
||||
mapped[proposal_id] = observation
|
||||
return mapped
|
||||
|
||||
|
||||
def _metric_obstacles(value: object) -> tuple[dict[str, Any], ...]:
|
||||
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
|
||||
raise M48Ravnoves00PackError("M4.6 metric obstacle collection changed")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _metric_centroid(observation: Mapping[str, Any]) -> tuple[float, float, float]:
|
||||
geometry = _mapping(observation.get("metric_geometry"), "proposal metric geometry")
|
||||
value = geometry.get("centroid_xyz_m")
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 3
|
||||
or any(
|
||||
not isinstance(item, (int, float))
|
||||
or isinstance(item, bool)
|
||||
or not math.isfinite(float(item))
|
||||
for item in value
|
||||
)
|
||||
):
|
||||
raise M48Ravnoves00PackError("proposal metric centroid changed")
|
||||
return float(value[0]), float(value[1]), float(value[2])
|
||||
|
||||
|
||||
def _match_metric_obstacle(
|
||||
centroid: tuple[float, float, float],
|
||||
obstacles: tuple[dict[str, Any], ...],
|
||||
) -> dict[str, Any]:
|
||||
matches: list[dict[str, Any]] = []
|
||||
for obstacle in obstacles:
|
||||
value = obstacle.get("centroid_map_xyz_m")
|
||||
if (
|
||||
isinstance(value, list)
|
||||
and len(value) == 3
|
||||
and all(isinstance(item, (int, float)) and not isinstance(item, bool) for item in value)
|
||||
and max(
|
||||
abs(float(left) - float(right)) for left, right in zip(value, centroid, strict=True)
|
||||
)
|
||||
<= 1e-9
|
||||
):
|
||||
matches.append(obstacle)
|
||||
if len(matches) != 1:
|
||||
raise M48Ravnoves00PackError("proposal metric obstacle association is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _normalized_extent(value: object) -> list[float]:
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 4
|
||||
or any(
|
||||
not isinstance(item, (int, float))
|
||||
or isinstance(item, bool)
|
||||
or not math.isfinite(float(item))
|
||||
for item in value
|
||||
)
|
||||
):
|
||||
raise M48Ravnoves00PackError("M4.6 proposal extent changed")
|
||||
x_min, y_min, x_max, y_max = (float(item) for item in value)
|
||||
extent = [
|
||||
x_min / M48_IMAGE_WIDTH,
|
||||
y_min / M48_IMAGE_HEIGHT,
|
||||
x_max / M48_IMAGE_WIDTH,
|
||||
y_max / M48_IMAGE_HEIGHT,
|
||||
]
|
||||
if not 0.0 <= extent[0] < extent[2] <= 1.0 or not 0.0 <= extent[1] < extent[3] <= 1.0:
|
||||
raise M48Ravnoves00PackError("M4.6 proposal extent escaped the camera raster")
|
||||
return extent
|
||||
|
||||
|
||||
def _iter_jsonl(path: Path, label: str) -> Iterator[dict[str, Any]]:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise M48Ravnoves00PackError(f"{label} row {line_number} is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise M48Ravnoves00PackError(f"{label} row {line_number} is not an object")
|
||||
yield value
|
||||
|
||||
|
||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise M48Ravnoves00PackError(f"{label} is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise M48Ravnoves00PackError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise M48Ravnoves00PackError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise M48Ravnoves00PackError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _directory(path: Path, label: str) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise M48Ravnoves00PackError(f"{label} must not be a symlink")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise M48Ravnoves00PackError(f"{label} is unavailable") from exc
|
||||
if not resolved.is_dir():
|
||||
raise M48Ravnoves00PackError(f"{label} is unavailable")
|
||||
return resolved
|
||||
|
||||
|
||||
def _file(path: Path, label: str) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise M48Ravnoves00PackError(f"{label} must not be a symlink")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise M48Ravnoves00PackError(f"{label} is unavailable") from exc
|
||||
if not resolved.is_file():
|
||||
raise M48Ravnoves00PackError(f"{label} is unavailable")
|
||||
return resolved
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _is_sha256(value: object) -> bool:
|
||||
return isinstance(value, str) and _SHA256.fullmatch(value) is not None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M48Ravnoves00PackError",
|
||||
"M48_SELECTION_ID",
|
||||
"M48_SELECTION_SCHEMA",
|
||||
"prepare_m48_ravnoves00_pack",
|
||||
]
|
||||
@@ -0,0 +1,507 @@
|
||||
"""Prediction-free raw spatial evidence for the neutral M4.8 review surface.
|
||||
|
||||
This reader deliberately does not open the M4.7 graph payload or the frozen M4.8
|
||||
prediction ledger. It reuses the already verified recorded-geometry and replay
|
||||
body-frame primitives to expose only a bounded current LiDAR increment in the
|
||||
virtual body frame, together with immutable rig/corridor parameters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.laboratory.m47_reference_graph import (
|
||||
M47ReferenceGraphLabError,
|
||||
read_m47_reference_graph_lab,
|
||||
)
|
||||
from k1link.laboratory.m48_object_quality import M48ObjectQualityPack
|
||||
from k1link.perception.spatial_evidence import (
|
||||
SpatialEvidenceProjectionError,
|
||||
sample_points_in_body_frame,
|
||||
)
|
||||
from k1link.perception.threat_replay import (
|
||||
ThreatReplayError,
|
||||
ThreatReplayResult,
|
||||
read_threat_replay_result,
|
||||
)
|
||||
from k1link.perception.threat_timeline import (
|
||||
RECORDED_SPATIAL_POINT_LIMIT,
|
||||
RecordedThreatTimeline,
|
||||
RecordedThreatTimelineError,
|
||||
)
|
||||
|
||||
M48_RAW_SPATIAL_FRAME_SCHEMA: Final = "missioncore.m48-neutral-object-review-spatial-frame/v1"
|
||||
M48_EXPECTED_SOURCE_ID: Final = "RAVNOVES00"
|
||||
M48_EXPECTED_E10_SOURCE_ID: Final = "sensor.camera.right"
|
||||
M48_EXPECTED_SESSION_ID: Final = "20260720T065719Z_viewer_live"
|
||||
M48_EXPECTED_FRAME_COUNT: Final = 4_489
|
||||
M48_EXPECTED_SOURCE_PACK_ID: Final = (
|
||||
"e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b"
|
||||
)
|
||||
M48_EXPECTED_SOURCE_PACK_SHA256: Final = (
|
||||
"0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
|
||||
)
|
||||
M48_EXPECTED_THREAT_RESULT_ID: Final = (
|
||||
"m4-threat-replay-2a953c5f27f2a5b1dddc5c658c1de2c323d7796084a099c024987a1da03aa324"
|
||||
)
|
||||
|
||||
_E10_SCHEMA: Final = "missioncore.e10-lidar-replay-pack/v1"
|
||||
_E10_ARTIFACT_NAME: Final = "lidar-pack.npz"
|
||||
_FALSE_AUTHORITY: Final = {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
_MANIFEST_KEYS: Final = {
|
||||
"artifact",
|
||||
"classification",
|
||||
"created_at_utc",
|
||||
"ground_truth",
|
||||
"identity",
|
||||
"identity_sha256",
|
||||
"pack_id",
|
||||
"schema_version",
|
||||
}
|
||||
_IDENTITY_KEYS: Final = {
|
||||
"available_lidar_frames",
|
||||
"calibration_sha256",
|
||||
"camera_slot",
|
||||
"e6_profile_sha256",
|
||||
"e6_result_id",
|
||||
"frame_count",
|
||||
"input_sha256",
|
||||
"job_id",
|
||||
"point_count",
|
||||
"producer_sha256",
|
||||
"projection",
|
||||
"schema_version",
|
||||
"semantic_timeline_result_id",
|
||||
"session_id",
|
||||
"source_end_frame_index",
|
||||
"source_id",
|
||||
"source_start_frame_index",
|
||||
"temporal_binding",
|
||||
"temporal_policy",
|
||||
"timeline_end_seconds",
|
||||
"timeline_start_seconds",
|
||||
}
|
||||
_ARTIFACT_KEYS: Final = {"byte_length", "media_type", "path", "sha256"}
|
||||
|
||||
|
||||
class M48RawEvidenceError(RuntimeError):
|
||||
"""Neutral M4.8 spatial evidence escaped an immutable source binding."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PackFrameBinding:
|
||||
clip_id: str
|
||||
source_time_ns: int
|
||||
|
||||
|
||||
class M48RawEvidenceReader:
|
||||
"""Provide one prediction-blind, bounded body-frame projection per call.
|
||||
|
||||
Construct production instances with :meth:`from_repository`. The object is
|
||||
directly compatible with the M4.8 API provider callable:
|
||||
``reader(pack, one_based_sequence)``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository_root: Path,
|
||||
threat_result: ThreatReplayResult,
|
||||
timeline: RecordedThreatTimeline,
|
||||
point_limit: int = RECORDED_SPATIAL_POINT_LIMIT,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(point_limit, int)
|
||||
or isinstance(point_limit, bool)
|
||||
or not 1 <= point_limit <= RECORDED_SPATIAL_POINT_LIMIT
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 raw evidence point limit is invalid")
|
||||
self.repository_root = repository_root.resolve(strict=True)
|
||||
self.threat_result = threat_result
|
||||
self.timeline = timeline
|
||||
self.point_limit = point_limit
|
||||
self._pack_indices: dict[str, dict[int, _PackFrameBinding]] = {}
|
||||
self._lock = RLock()
|
||||
|
||||
@classmethod
|
||||
def from_repository(
|
||||
cls,
|
||||
*,
|
||||
repository_root: Path,
|
||||
threat_result_root: Path,
|
||||
expected_source_pack_id: str = M48_EXPECTED_SOURCE_PACK_ID,
|
||||
point_limit: int = RECORDED_SPATIAL_POINT_LIMIT,
|
||||
) -> M48RawEvidenceReader:
|
||||
"""Open the exact sealed M4 result and its exact E10 source generation.
|
||||
|
||||
``threat_result_root`` is the immutable result generation directory, not
|
||||
the parent collection. No latest-by-mtime discovery is permitted.
|
||||
"""
|
||||
|
||||
repository = _strict_directory(repository_root, "repository root")
|
||||
if expected_source_pack_id != M48_EXPECTED_SOURCE_PACK_ID:
|
||||
raise M48RawEvidenceError("M4.8 E10 pack id escaped the canonical binding")
|
||||
threat_root = _strict_directory(threat_result_root, "threat result root")
|
||||
try:
|
||||
result = read_threat_replay_result(threat_root)
|
||||
except (OSError, ValueError, ThreatReplayError) as exc:
|
||||
raise M48RawEvidenceError("M4.8 threat result is invalid") from exc
|
||||
_validate_threat_result(result, expected_source_pack_id=expected_source_pack_id)
|
||||
pack_root = (
|
||||
repository / ".runtime/compute-experiments/e10/lidar-packs" / expected_source_pack_id
|
||||
)
|
||||
_validate_e10_pack(
|
||||
pack_root,
|
||||
expected_pack_id=expected_source_pack_id,
|
||||
expected_artifact_sha256=M48_EXPECTED_SOURCE_PACK_SHA256,
|
||||
)
|
||||
try:
|
||||
timeline = RecordedThreatTimeline(repository_root=repository, result=result)
|
||||
except (OSError, ValueError, RecordedThreatTimelineError) as exc:
|
||||
raise M48RawEvidenceError("M4.8 recorded geometry timeline is invalid") from exc
|
||||
if (
|
||||
len(timeline.index.source_times_ns) != M48_EXPECTED_FRAME_COUNT
|
||||
or timeline.profile.source_id != M48_EXPECTED_SOURCE_ID
|
||||
or timeline.profile.session_id != M48_EXPECTED_SESSION_ID
|
||||
or timeline.profile.source_pack_id != expected_source_pack_id
|
||||
or timeline.profile.source_pack_sha256 != M48_EXPECTED_SOURCE_PACK_SHA256
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 recorded geometry binding changed")
|
||||
return cls(
|
||||
repository_root=repository,
|
||||
threat_result=result,
|
||||
timeline=timeline,
|
||||
point_limit=point_limit,
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
pack: M48ObjectQualityPack,
|
||||
sequence: int,
|
||||
) -> dict[str, object]:
|
||||
return self.frame(pack=pack, sequence=sequence)
|
||||
|
||||
def frame(
|
||||
self,
|
||||
*,
|
||||
pack: M48ObjectQualityPack,
|
||||
sequence: int,
|
||||
) -> dict[str, object]:
|
||||
"""Return one one-based, clip-bound neutral spatial frame."""
|
||||
|
||||
if (
|
||||
not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or not 1 <= sequence <= M48_EXPECTED_FRAME_COUNT
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 raw evidence sequence is invalid")
|
||||
binding = self._binding_for(pack, sequence)
|
||||
frame_index = sequence - 1
|
||||
try:
|
||||
temporal = self.timeline.store.temporal_binding_for_index(frame_index)
|
||||
body_frame = self.timeline.body_frames.body_frame_for_frame(f"frame-{frame_index:06d}")
|
||||
except (RuntimeError, TypeError, ValueError) as exc:
|
||||
raise M48RawEvidenceError("M4.8 source frame binding is invalid") from exc
|
||||
if temporal.frame_index != frame_index or temporal.source_time_ns != binding.source_time_ns:
|
||||
raise M48RawEvidenceError("M4.8 source time escaped the neutral frame reference")
|
||||
|
||||
points_body: list[list[float]] = []
|
||||
if body_frame is not None:
|
||||
if not temporal.source_available:
|
||||
raise M48RawEvidenceError("unavailable source produced an M4.8 body frame")
|
||||
points = self.timeline.store.current_points_for_frame(frame_index)
|
||||
if points is None:
|
||||
raise M48RawEvidenceError("qualified M4.8 body frame lacks current LiDAR")
|
||||
try:
|
||||
points_body, _ = sample_points_in_body_frame(
|
||||
points,
|
||||
body_frame,
|
||||
point_limit=self.point_limit,
|
||||
)
|
||||
except SpatialEvidenceProjectionError as exc:
|
||||
raise M48RawEvidenceError("M4.8 body-frame point projection failed") from exc
|
||||
|
||||
profile = self.timeline.profile
|
||||
return {
|
||||
"schema_version": M48_RAW_SPATIAL_FRAME_SCHEMA,
|
||||
"pack_id": pack.result_id,
|
||||
"clip_id": binding.clip_id,
|
||||
"sequence": sequence,
|
||||
"source_time_ns": temporal.source_time_ns,
|
||||
"source_available": temporal.source_available,
|
||||
"body_frame_available": body_frame is not None,
|
||||
"point_cloud_body_xyz_m": points_body,
|
||||
"rig": {
|
||||
"profile_id": profile.rig.profile_id,
|
||||
"length_m": profile.rig.body_length_m,
|
||||
"width_m": profile.rig.body_width_m,
|
||||
"lidar_reference": profile.rig.lidar_reference,
|
||||
"nominal_sensor_height_m": profile.rig.nominal_sensor_height_m,
|
||||
"physical_mount_claimed": False,
|
||||
},
|
||||
"corridor": {
|
||||
"profile_id": profile.corridor.profile_id,
|
||||
"forward_length_m": profile.corridor.forward_length_m,
|
||||
"rear_margin_m": profile.corridor.rear_margin_m,
|
||||
"lateral_clearance_m": profile.corridor.lateral_clearance_m,
|
||||
"half_width_m": (
|
||||
profile.rig.body_width_m / 2 + profile.corridor.lateral_clearance_m
|
||||
),
|
||||
"prediction_horizon_seconds": (profile.corridor.prediction_horizon_seconds),
|
||||
},
|
||||
"occupied_voxel_size_m": profile.corridor.occupied_voxel_size_m,
|
||||
"candidate_identity_included": False,
|
||||
"graph_boxes_ids_scores_included": False,
|
||||
"frozen_predictions_included": False,
|
||||
"strata_included": False,
|
||||
"authority": dict(_FALSE_AUTHORITY),
|
||||
}
|
||||
|
||||
def _binding_for(
|
||||
self,
|
||||
pack: M48ObjectQualityPack,
|
||||
sequence: int,
|
||||
) -> _PackFrameBinding:
|
||||
with self._lock:
|
||||
index = self._pack_indices.get(pack.result_id)
|
||||
if index is None:
|
||||
_validate_m47_pack_binding(
|
||||
repository_root=self.repository_root,
|
||||
pack=pack,
|
||||
threat_result=self.threat_result,
|
||||
)
|
||||
index = _index_neutral_frame_references(pack)
|
||||
self._pack_indices[pack.result_id] = index
|
||||
binding = index.get(sequence)
|
||||
if binding is None:
|
||||
raise M48RawEvidenceError("M4.8 sequence is outside the selected neutral clips")
|
||||
return binding
|
||||
|
||||
|
||||
def _validate_threat_result(
|
||||
result: ThreatReplayResult,
|
||||
*,
|
||||
expected_source_pack_id: str,
|
||||
) -> None:
|
||||
identity = result.manifest.get("identity")
|
||||
metrics = identity.get("metrics") if isinstance(identity, dict) else None
|
||||
frames = metrics.get("frames") if isinstance(metrics, dict) else None
|
||||
if (
|
||||
result.result_id != M48_EXPECTED_THREAT_RESULT_ID
|
||||
or result.result_root.name != result.result_id
|
||||
or result.accepted is not True
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("source_id") != M48_EXPECTED_SOURCE_ID
|
||||
or identity.get("source_session_id") != M48_EXPECTED_SESSION_ID
|
||||
or identity.get("source_pack_id") != expected_source_pack_id
|
||||
or identity.get("source_pack_sha256") != M48_EXPECTED_SOURCE_PACK_SHA256
|
||||
or not isinstance(frames, dict)
|
||||
or frames.get("total") != M48_EXPECTED_FRAME_COUNT
|
||||
or identity.get("authority")
|
||||
!= {
|
||||
**_FALSE_AUTHORITY,
|
||||
"physical_collision_accepted": False,
|
||||
"ground_truth": False,
|
||||
}
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 threat result escaped the canonical source")
|
||||
|
||||
|
||||
def _validate_e10_pack(
|
||||
pack_root: Path,
|
||||
*,
|
||||
expected_pack_id: str,
|
||||
expected_artifact_sha256: str,
|
||||
) -> Path:
|
||||
root = _strict_directory(pack_root, "E10 pack root")
|
||||
if root.name != expected_pack_id:
|
||||
raise M48RawEvidenceError("E10 pack path escaped its expected identity")
|
||||
manifest_path = root / "manifest.json"
|
||||
if (
|
||||
manifest_path.is_symlink()
|
||||
or not manifest_path.is_file()
|
||||
or manifest_path.resolve(strict=True).parent != root
|
||||
):
|
||||
raise M48RawEvidenceError("E10 manifest path is invalid")
|
||||
manifest = _read_json(manifest_path, "E10 manifest")
|
||||
if set(manifest) != _MANIFEST_KEYS:
|
||||
raise M48RawEvidenceError("E10 manifest fields changed")
|
||||
identity = _mapping(manifest.get("identity"), "E10 identity")
|
||||
artifact = _mapping(manifest.get("artifact"), "E10 artifact")
|
||||
if set(identity) != _IDENTITY_KEYS or set(artifact) != _ARTIFACT_KEYS:
|
||||
raise M48RawEvidenceError("E10 identity or artifact fields changed")
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
if (
|
||||
manifest.get("schema_version") != _E10_SCHEMA
|
||||
or manifest.get("pack_id") != expected_pack_id
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or expected_pack_id != f"e10-lidar-pack-{identity_sha256}"
|
||||
or manifest.get("classification") != "private-recorded-sensor-replay-input"
|
||||
or manifest.get("ground_truth") is not False
|
||||
or identity.get("schema_version") != _E10_SCHEMA
|
||||
or identity.get("source_id") != M48_EXPECTED_E10_SOURCE_ID
|
||||
or identity.get("session_id") != M48_EXPECTED_SESSION_ID
|
||||
or identity.get("frame_count") != M48_EXPECTED_FRAME_COUNT
|
||||
or identity.get("source_start_frame_index") != 0
|
||||
or identity.get("source_end_frame_index") != M48_EXPECTED_FRAME_COUNT - 1
|
||||
or artifact.get("path") != _E10_ARTIFACT_NAME
|
||||
or artifact.get("media_type") != "application/x-npz"
|
||||
or artifact.get("sha256") != expected_artifact_sha256
|
||||
):
|
||||
raise M48RawEvidenceError("E10 pack identity changed")
|
||||
byte_length = artifact.get("byte_length")
|
||||
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
|
||||
raise M48RawEvidenceError("E10 artifact byte length is invalid")
|
||||
artifact_path = root / _E10_ARTIFACT_NAME
|
||||
if (
|
||||
artifact_path.is_symlink()
|
||||
or not artifact_path.is_file()
|
||||
or artifact_path.resolve(strict=True).parent != root
|
||||
or artifact_path.stat().st_size != byte_length
|
||||
or _file_sha256(artifact_path) != expected_artifact_sha256
|
||||
):
|
||||
raise M48RawEvidenceError("E10 artifact content changed")
|
||||
return artifact_path.resolve(strict=True)
|
||||
|
||||
|
||||
def _validate_m47_pack_binding(
|
||||
*,
|
||||
repository_root: Path,
|
||||
pack: M48ObjectQualityPack,
|
||||
threat_result: ThreatReplayResult,
|
||||
) -> None:
|
||||
identity = _mapping(pack.manifest.get("identity"), "M4.8 pack identity")
|
||||
source = _mapping(identity.get("source"), "M4.8 pack source")
|
||||
m47_id = source.get("m47_lab_result_id")
|
||||
m47_manifest_sha256 = source.get("m47_lab_manifest_sha256")
|
||||
if (
|
||||
source.get("source_id") != M48_EXPECTED_SOURCE_ID
|
||||
or source.get("source_session_id") != M48_EXPECTED_SESSION_ID
|
||||
or not isinstance(m47_id, str)
|
||||
or not isinstance(m47_manifest_sha256, str)
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 pack source binding changed")
|
||||
m47_root = repository_root / ".runtime/compute-experiments/m47/reference-graph-labs" / m47_id
|
||||
manifest_path = m47_root / "manifest.json"
|
||||
if (
|
||||
manifest_path.is_symlink()
|
||||
or not manifest_path.is_file()
|
||||
or _file_sha256(manifest_path) != m47_manifest_sha256
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 pack M4.7 manifest binding changed")
|
||||
try:
|
||||
m47 = read_m47_reference_graph_lab(m47_root)
|
||||
except (OSError, ValueError, M47ReferenceGraphLabError) as exc:
|
||||
raise M48RawEvidenceError("M4.8 pack M4.7 LAB is invalid") from exc
|
||||
m47_source = _mapping(m47.report.get("source"), "M4.7 source")
|
||||
threat_identity = _mapping(threat_result.manifest.get("identity"), "M4 threat identity")
|
||||
if (
|
||||
m47.manifest.get("accepted") is not True
|
||||
or m47_source.get("source_id") != M48_EXPECTED_SOURCE_ID
|
||||
or m47_source.get("source_session_id") != M48_EXPECTED_SESSION_ID
|
||||
or m47_source.get("visual_result_id") != threat_result.result_id
|
||||
or m47_source.get("threat_frames_sha256") != threat_identity.get("frames_sha256")
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 pack escaped its accepted M4.7 visual source")
|
||||
|
||||
|
||||
def _index_neutral_frame_references(
|
||||
pack: M48ObjectQualityPack,
|
||||
) -> dict[int, _PackFrameBinding]:
|
||||
index: dict[int, _PackFrameBinding] = {}
|
||||
for raw in pack.frame_references:
|
||||
row = _mapping(raw, "M4.8 neutral frame reference")
|
||||
sequence = row.get("sequence")
|
||||
source_time_ns = row.get("source_time_ns")
|
||||
clip_id = row.get("clip_id")
|
||||
if (
|
||||
not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or not 1 <= sequence <= M48_EXPECTED_FRAME_COUNT
|
||||
or not isinstance(source_time_ns, int)
|
||||
or isinstance(source_time_ns, bool)
|
||||
or source_time_ns < 0
|
||||
or not isinstance(clip_id, str)
|
||||
or not clip_id
|
||||
or sequence in index
|
||||
):
|
||||
raise M48RawEvidenceError("M4.8 neutral frame references are invalid")
|
||||
index[sequence] = _PackFrameBinding(
|
||||
clip_id=clip_id,
|
||||
source_time_ns=source_time_ns,
|
||||
)
|
||||
if not index:
|
||||
raise M48RawEvidenceError("M4.8 neutral frame reference set is empty")
|
||||
return index
|
||||
|
||||
|
||||
def _strict_directory(path: Path, label: str) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise M48RawEvidenceError(f"{label} must not be a symlink")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise M48RawEvidenceError(f"{label} is unavailable") from exc
|
||||
if not resolved.is_dir():
|
||||
raise M48RawEvidenceError(f"{label} is not a directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise M48RawEvidenceError(f"{label} is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise M48RawEvidenceError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise M48RawEvidenceError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M48_EXPECTED_FRAME_COUNT",
|
||||
"M48_EXPECTED_SESSION_ID",
|
||||
"M48_EXPECTED_SOURCE_ID",
|
||||
"M48_EXPECTED_SOURCE_PACK_ID",
|
||||
"M48_EXPECTED_THREAT_RESULT_ID",
|
||||
"M48_RAW_SPATIAL_FRAME_SCHEMA",
|
||||
"M48RawEvidenceError",
|
||||
"M48RawEvidenceReader",
|
||||
]
|
||||
@@ -0,0 +1,711 @@
|
||||
"""Immutable M4.8 development regression over operator-added missed-object anchors.
|
||||
|
||||
The experiment deliberately stays inside M4.8 and reuses the frozen Worker 006
|
||||
prediction pack. It snapshots only operator-added tracklets from reviewed clips,
|
||||
compares the exact source frame against the already-frozen prediction row, and
|
||||
publishes a separate append-only result. The assisted correction is never called
|
||||
independent truth and the result grants no navigation or safety authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.laboratory.m48_object_quality import (
|
||||
M48ObjectQualityError,
|
||||
read_m48_object_quality_pack,
|
||||
)
|
||||
|
||||
M48_SMALL_STATIC_PROFILE_SCHEMA: Final = (
|
||||
"missioncore.m48-small-static-passage-regression-profile/v1"
|
||||
)
|
||||
M48_SMALL_STATIC_RESULT_SCHEMA: Final = (
|
||||
"missioncore.m48-small-static-passage-regression-result/v1"
|
||||
)
|
||||
M48_SMALL_STATIC_REPORT_SCHEMA: Final = (
|
||||
"missioncore.m48-small-static-passage-regression-report/v1"
|
||||
)
|
||||
M48_SMALL_STATIC_ANCHOR_SCHEMA: Final = (
|
||||
"missioncore.m48-assisted-missed-object-anchor/v1"
|
||||
)
|
||||
M48_SMALL_STATIC_COMPARISON_SCHEMA: Final = (
|
||||
"missioncore.m48-assisted-anchor-comparison/v1"
|
||||
)
|
||||
M48_SMALL_STATIC_PREFIX: Final = "m48-small-static-passage-regression-"
|
||||
|
||||
_CORRECTION_SCHEMA: Final = "missioncore.m48-assisted-object-correction-session/v1"
|
||||
_METHOD_SCHEMA: Final = "missioncore.laboratory-method/v1"
|
||||
_OBJECT_ID = re.compile(r"^object-[0-9]{2,}$")
|
||||
_AUTHORITY: Final = {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
class M48SmallStaticRegressionError(RuntimeError):
|
||||
"""The assisted development-regression source or result is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class M48SmallStaticRegressionResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
manifest: dict[str, Any]
|
||||
report: dict[str, Any]
|
||||
anchors: tuple[dict[str, Any], ...]
|
||||
comparisons: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
def build_m48_small_static_passage_regression(
|
||||
*,
|
||||
pack_root: Path,
|
||||
correction_session_path: Path,
|
||||
profile_path: Path,
|
||||
output_root: Path,
|
||||
run_created_at_utc: str | None = None,
|
||||
) -> M48SmallStaticRegressionResult:
|
||||
"""Publish one append-only M4.8R development baseline without mutating inputs."""
|
||||
|
||||
try:
|
||||
pack = read_m48_object_quality_pack(pack_root)
|
||||
except M48ObjectQualityError as exc:
|
||||
raise M48SmallStaticRegressionError("M4.8 frozen prediction pack is invalid") from exc
|
||||
profile_bytes, profile = _read_profile(profile_path)
|
||||
correction_bytes, correction = _read_correction(correction_session_path, pack.result_id)
|
||||
anchors = _assisted_anchors(correction)
|
||||
if len(anchors) < int(profile["minimum_anchor_count"]):
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted anchor set is too small")
|
||||
|
||||
prediction_rows: dict[tuple[str, int], dict[str, Any]] = {}
|
||||
for row in pack.predictions:
|
||||
clip_id = row.get("clip_id")
|
||||
sequence = row.get("sequence")
|
||||
if not isinstance(clip_id, str) or not _integer(sequence):
|
||||
raise M48SmallStaticRegressionError("M4.8 frozen prediction binding is invalid")
|
||||
key = (clip_id, int(sequence))
|
||||
if key in prediction_rows:
|
||||
raise M48SmallStaticRegressionError("M4.8 frozen prediction binding collided")
|
||||
prediction_rows[key] = row
|
||||
|
||||
threshold = float(profile["extent_iou_threshold"])
|
||||
comparisons = tuple(
|
||||
_compare_anchor(anchor, prediction_rows, threshold)
|
||||
for anchor in anchors
|
||||
)
|
||||
recalled = sum(bool(row["matched_at_threshold"]) for row in comparisons)
|
||||
recall = recalled / len(comparisons)
|
||||
passage_count = sum(bool(row["requires_avoidance_or_clearance"]) for row in anchors)
|
||||
clip_count = len({str(row["clip_id"]) for row in anchors})
|
||||
target = float(profile["minimum_assisted_anchor_recall"])
|
||||
accepted = recall >= target
|
||||
created_at = _utc_timestamp(run_created_at_utc or datetime.now(UTC).isoformat())
|
||||
correction_sha256 = hashlib.sha256(correction_bytes).hexdigest()
|
||||
profile_sha256 = hashlib.sha256(profile_bytes).hexdigest()
|
||||
producer_sha256 = _file_sha256(Path(__file__).resolve())
|
||||
pack_identity = _object(pack.manifest.get("identity"), "M4.8 pack identity")
|
||||
freeze = _object(pack_identity.get("freeze"), "M4.8 pack freeze")
|
||||
|
||||
identity: dict[str, Any] = {
|
||||
"schema_version": M48_SMALL_STATIC_RESULT_SCHEMA,
|
||||
"human_lab_id": profile["human_lab_id"],
|
||||
"run_label": profile["run_label"],
|
||||
"run_created_at_utc": created_at,
|
||||
"pipeline_id": profile["pipeline_id"],
|
||||
"experiment_id": profile["experiment_id"],
|
||||
"profile_id": profile["profile_id"],
|
||||
"profile_sha256": profile_sha256,
|
||||
"producer_sha256": producer_sha256,
|
||||
"source": {
|
||||
"source_id": _object(
|
||||
pack_identity.get("source"), "M4.8 source"
|
||||
).get("source_id"),
|
||||
"source_session_id": _object(
|
||||
pack_identity.get("source"), "M4.8 source"
|
||||
).get("source_session_id"),
|
||||
"pack_id": pack.result_id,
|
||||
"pack_identity_sha256": pack.manifest["identity_sha256"],
|
||||
"prediction_rows_sha256": freeze.get("prediction_rows_sha256"),
|
||||
"correction_session_id": correction["session_id"],
|
||||
"correction_revision": correction["revision"],
|
||||
"correction_updated_at_utc": correction["updated_at_utc"],
|
||||
"correction_document_sha256": correction_sha256,
|
||||
"correction_independent_truth": False,
|
||||
},
|
||||
"selection": {
|
||||
"anchor_selection": profile["anchor_selection"],
|
||||
"assisted_tracklet_count": len({(row["clip_id"], row["object_id"]) for row in anchors}),
|
||||
"anchor_count": len(anchors),
|
||||
"clip_count": clip_count,
|
||||
"requires_avoidance_or_clearance_count": passage_count,
|
||||
},
|
||||
"authority": dict(_AUTHORITY),
|
||||
}
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
result_id = f"{M48_SMALL_STATIC_PREFIX}{identity_sha256}"
|
||||
method = {
|
||||
"schema_version": _METHOD_SCHEMA,
|
||||
"completeness": "complete",
|
||||
"execution_class": "deterministic",
|
||||
"pipeline_id": profile["pipeline_id"],
|
||||
"components": [
|
||||
{
|
||||
"kind": "source",
|
||||
"name": "M4.8 frozen Worker 006 predictions",
|
||||
"version": pack.result_id,
|
||||
"role": "immutable candidate rows from the current M4.8 pipeline",
|
||||
"identity_sha256": freeze.get("prediction_rows_sha256"),
|
||||
},
|
||||
{
|
||||
"kind": "source",
|
||||
"name": "operator-added missed-object anchors",
|
||||
"version": f"{correction['session_id']}:revision-{correction['revision']}",
|
||||
"role": "assisted development regression seed; not independent truth",
|
||||
"identity_sha256": correction_sha256,
|
||||
},
|
||||
{
|
||||
"kind": "algorithm",
|
||||
"name": "exact-frame class-free IoU comparator",
|
||||
"version": profile["profile_id"],
|
||||
"role": "diagnostic detection recall over operator-added anchors",
|
||||
"identity_sha256": producer_sha256,
|
||||
},
|
||||
],
|
||||
}
|
||||
metrics = {
|
||||
"assisted_anchor_count": len(comparisons),
|
||||
"assisted_tracklet_count": len({(row["clip_id"], row["object_id"]) for row in anchors}),
|
||||
"anchor_clip_count": clip_count,
|
||||
"requires_avoidance_or_clearance_count": passage_count,
|
||||
"worker_recalled_anchor_count": recalled,
|
||||
"worker_missed_anchor_count": len(comparisons) - recalled,
|
||||
"assisted_anchor_recall": recall,
|
||||
"extent_iou_threshold": threshold,
|
||||
"minimum_assisted_anchor_recall": target,
|
||||
}
|
||||
gates = {
|
||||
"anchor_set_non_empty": len(comparisons) >= int(profile["minimum_anchor_count"]),
|
||||
"development_anchor_recall_target": accepted,
|
||||
"independent_truth_available": False,
|
||||
}
|
||||
decision = {
|
||||
"state": (
|
||||
"accepted-development-regression-baseline"
|
||||
if accepted
|
||||
else "failed-development-regression-baseline"
|
||||
),
|
||||
"summary": (
|
||||
f"Worker 006 matched {recalled}/{len(comparisons)} exact-frame assisted anchors "
|
||||
f"at IoU >= {threshold:.2f}."
|
||||
),
|
||||
"next_action": (
|
||||
"Keep the pipeline contract fixed, change only the perception experiment, "
|
||||
"and publish another immutable M4.8R run against this frozen seed."
|
||||
),
|
||||
}
|
||||
limitations = [
|
||||
"The anchors come from candidate-visible operator correction and are not "
|
||||
"independent truth.",
|
||||
"The seed is intentionally biased toward objects the current Worker 006 output missed.",
|
||||
"A camera rectangle is evidence of a missed visible object, not a measured 3D collider.",
|
||||
"No physical-live, navigation, command, actuation or collision-safety "
|
||||
"authority is granted.",
|
||||
]
|
||||
report = {
|
||||
"schema_version": M48_SMALL_STATIC_REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"source": identity["source"],
|
||||
"configuration": {
|
||||
**profile,
|
||||
"profile_sha256": profile_sha256,
|
||||
},
|
||||
"method": method,
|
||||
"execution": {
|
||||
"comparison_node": "mission-core-local-control-plane",
|
||||
"source_worker_id": "006",
|
||||
"frozen_prediction_rows_sha256": freeze.get("prediction_rows_sha256"),
|
||||
"determinism": "exact canonical JSON + exact-frame IoU; no inference rerun",
|
||||
},
|
||||
"metrics": metrics,
|
||||
"gates": gates,
|
||||
"decision": decision,
|
||||
"limitations": limitations,
|
||||
"authority": dict(_AUTHORITY),
|
||||
"visual_review": {
|
||||
"viewer": "missioncore.laboratory-recorded-clip-viewer/v1",
|
||||
"case_count": len(comparisons),
|
||||
"camera_anchor_and_worker_boxes": True,
|
||||
"camera_3d_plan_shared_clock": True,
|
||||
},
|
||||
}
|
||||
destination = output_root.expanduser().absolute() / result_id
|
||||
_publish_result(
|
||||
destination=destination,
|
||||
identity=identity,
|
||||
created_at_utc=created_at,
|
||||
accepted=accepted,
|
||||
report=report,
|
||||
anchors=anchors,
|
||||
comparisons=comparisons,
|
||||
)
|
||||
return read_m48_small_static_passage_regression(destination)
|
||||
|
||||
|
||||
def read_m48_small_static_passage_regression(
|
||||
root: Path,
|
||||
) -> M48SmallStaticRegressionResult:
|
||||
candidate = root.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise M48SmallStaticRegressionError("M4.8 regression result must not be a symlink")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression result is unavailable") from exc
|
||||
if not resolved.is_dir() or not resolved.name.startswith(M48_SMALL_STATIC_PREFIX):
|
||||
raise M48SmallStaticRegressionError("M4.8 regression result path is invalid")
|
||||
manifest = _read_json(resolved / "manifest.json", maximum=1024 * 1024)
|
||||
identity = _object(manifest.get("identity"), "M4.8 regression identity")
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
if (
|
||||
manifest.get("schema_version") != M48_SMALL_STATIC_RESULT_SCHEMA
|
||||
or manifest.get("result_id") != resolved.name
|
||||
or manifest.get("identity_sha256") != identity_sha256
|
||||
or resolved.name != f"{M48_SMALL_STATIC_PREFIX}{identity_sha256}"
|
||||
or manifest.get("ground_truth") is not False
|
||||
or manifest.get("authority") != _AUTHORITY
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 regression identity changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 3:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression artifact inventory changed")
|
||||
by_path: dict[str, dict[str, Any]] = {}
|
||||
for raw in artifacts:
|
||||
descriptor = _object(raw, "M4.8 regression artifact")
|
||||
path_name = descriptor.get("path")
|
||||
if not isinstance(path_name, str) or path_name not in {
|
||||
"anchors.jsonl", "comparisons.jsonl", "report.json"
|
||||
} or path_name in by_path:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression artifact path changed")
|
||||
path = resolved / path_name
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or descriptor.get("byte_length") != path.stat().st_size
|
||||
or descriptor.get("sha256") != _file_sha256(path)
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 regression artifact proof changed")
|
||||
by_path[path_name] = descriptor
|
||||
report = _read_json(resolved / "report.json", maximum=1024 * 1024)
|
||||
anchors = tuple(_read_jsonl(resolved / "anchors.jsonl"))
|
||||
comparisons = tuple(_read_jsonl(resolved / "comparisons.jsonl"))
|
||||
if (
|
||||
report.get("schema_version") != M48_SMALL_STATIC_REPORT_SCHEMA
|
||||
or report.get("result_id") != resolved.name
|
||||
or len(anchors) != len(comparisons)
|
||||
or any(row.get("schema_version") != M48_SMALL_STATIC_ANCHOR_SCHEMA for row in anchors)
|
||||
or any(
|
||||
row.get("schema_version") != M48_SMALL_STATIC_COMPARISON_SCHEMA
|
||||
for row in comparisons
|
||||
)
|
||||
or [row.get("anchor_id") for row in anchors]
|
||||
!= [row.get("anchor_id") for row in comparisons]
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 regression content changed")
|
||||
return M48SmallStaticRegressionResult(
|
||||
result_id=resolved.name,
|
||||
result_root=resolved,
|
||||
manifest=manifest,
|
||||
report=report,
|
||||
anchors=anchors,
|
||||
comparisons=comparisons,
|
||||
)
|
||||
|
||||
|
||||
def _read_profile(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
encoded, profile = _read_json_bytes(path, maximum=64 * 1024, label="M4.8 regression profile")
|
||||
expected = {
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"pipeline_id",
|
||||
"experiment_id",
|
||||
"human_lab_id",
|
||||
"run_label",
|
||||
"anchor_selection",
|
||||
"extent_iou_threshold",
|
||||
"minimum_assisted_anchor_recall",
|
||||
"minimum_anchor_count",
|
||||
"independent_truth",
|
||||
}
|
||||
if set(profile) != expected or profile.get("schema_version") != M48_SMALL_STATIC_PROFILE_SCHEMA:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression profile contract changed")
|
||||
if (
|
||||
profile.get("human_lab_id") != "M4.8"
|
||||
or profile.get("anchor_selection") != "operator-added-tracklets-in-reviewed-clips/v1"
|
||||
or profile.get("independent_truth") is not False
|
||||
or not _rate(profile.get("extent_iou_threshold"))
|
||||
or not _rate(profile.get("minimum_assisted_anchor_recall"))
|
||||
or not _integer(profile.get("minimum_anchor_count"))
|
||||
or int(profile["minimum_anchor_count"]) < 1
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 regression profile is invalid")
|
||||
for key in ("profile_id", "pipeline_id", "experiment_id", "run_label"):
|
||||
if not isinstance(profile.get(key), str) or not str(profile[key]).strip():
|
||||
raise M48SmallStaticRegressionError("M4.8 regression profile identity is invalid")
|
||||
return encoded, profile
|
||||
|
||||
|
||||
def _read_correction(path: Path, pack_id: str) -> tuple[bytes, dict[str, Any]]:
|
||||
encoded, correction = _read_json_bytes(
|
||||
path,
|
||||
maximum=16 * 1024 * 1024,
|
||||
label="M4.8 correction snapshot",
|
||||
)
|
||||
assistance = _object(correction.get("assistance"), "M4.8 correction assistance")
|
||||
if (
|
||||
correction.get("schema_version") != _CORRECTION_SCHEMA
|
||||
or correction.get("pack_id") != pack_id
|
||||
or correction.get("state") not in {"saved", "frozen"}
|
||||
or not _integer(correction.get("revision"))
|
||||
or int(correction["revision"]) < 1
|
||||
or not isinstance(correction.get("session_id"), str)
|
||||
or not isinstance(correction.get("updated_at_utc"), str)
|
||||
or assistance.get("candidate_predictions_seen") is not True
|
||||
or assistance.get("independent_truth_eligible") is not False
|
||||
or correction.get("authority") != _AUTHORITY
|
||||
or not isinstance(correction.get("clips"), list)
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 correction snapshot is invalid")
|
||||
return encoded, correction
|
||||
|
||||
|
||||
def _assisted_anchors(correction: dict[str, Any]) -> tuple[dict[str, Any], ...]:
|
||||
anchors: list[dict[str, Any]] = []
|
||||
for clip_raw in correction["clips"]:
|
||||
clip = _object(clip_raw, "M4.8 correction clip")
|
||||
if clip.get("review_state") != "reviewed":
|
||||
continue
|
||||
clip_id = clip.get("clip_id")
|
||||
tracklets = clip.get("tracklets")
|
||||
if not isinstance(clip_id, str) or not isinstance(tracklets, list):
|
||||
raise M48SmallStaticRegressionError("M4.8 correction clip is invalid")
|
||||
for tracklet_raw in tracklets:
|
||||
tracklet = _object(tracklet_raw, "M4.8 correction tracklet")
|
||||
object_id = tracklet.get("object_id")
|
||||
if not isinstance(object_id, str) or _OBJECT_ID.fullmatch(object_id) is None:
|
||||
continue
|
||||
keyframes = tracklet.get("keyframes")
|
||||
if not isinstance(keyframes, list) or not keyframes:
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted tracklet has no keyframes")
|
||||
for keyframe_raw in keyframes:
|
||||
keyframe = _object(keyframe_raw, "M4.8 correction keyframe")
|
||||
sequence = keyframe.get("sequence")
|
||||
extent = _extent(keyframe.get("extent_xyxy"))
|
||||
if not _integer(sequence):
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted anchor sequence is invalid")
|
||||
state = _state_for_sequence(tracklet, int(sequence))
|
||||
anchor_identity = {
|
||||
"clip_id": clip_id,
|
||||
"object_id": object_id,
|
||||
"sequence": int(sequence),
|
||||
"extent_xyxy": extent,
|
||||
}
|
||||
anchors.append({
|
||||
"schema_version": M48_SMALL_STATIC_ANCHOR_SCHEMA,
|
||||
"anchor_id": "anchor-" + _canonical_sha256(anchor_identity)[:24],
|
||||
**anchor_identity,
|
||||
"visibility": keyframe.get("visibility"),
|
||||
"geometry_association": state.get("geometry_association"),
|
||||
"freshness": state.get("freshness"),
|
||||
"motion": state.get("motion"),
|
||||
"threat": state.get("threat"),
|
||||
"requires_avoidance_or_clearance": bool(
|
||||
state.get("critical_corridor_obstacle")
|
||||
),
|
||||
"authority": "operator-assisted-development-anchor-not-truth",
|
||||
})
|
||||
anchors.sort(key=lambda row: (str(row["clip_id"]), int(row["sequence"]), str(row["object_id"])))
|
||||
if len({str(row["anchor_id"]) for row in anchors}) != len(anchors):
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted anchor identity collided")
|
||||
return tuple(anchors)
|
||||
|
||||
|
||||
def _state_for_sequence(tracklet: dict[str, Any], sequence: int) -> dict[str, Any]:
|
||||
segments = tracklet.get("state_segments")
|
||||
if not isinstance(segments, list):
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted state segments are invalid")
|
||||
matches = [
|
||||
_object(row, "M4.8 assisted state segment")
|
||||
for row in segments
|
||||
if isinstance(row, dict)
|
||||
and _integer(row.get("start_sequence"))
|
||||
and _integer(row.get("end_sequence"))
|
||||
and int(row["start_sequence"]) <= sequence <= int(row["end_sequence"])
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted anchor state is ambiguous")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _compare_anchor(
|
||||
anchor: dict[str, Any],
|
||||
prediction_rows: dict[tuple[str, int], dict[str, Any]],
|
||||
threshold: float,
|
||||
) -> dict[str, Any]:
|
||||
key = (str(anchor["clip_id"]), int(anchor["sequence"]))
|
||||
row = prediction_rows.get(key)
|
||||
if row is None or row.get("terminal_outcome") != "delivered":
|
||||
raise M48SmallStaticRegressionError("M4.8 assisted anchor lacks delivered prediction row")
|
||||
objects = row.get("objects")
|
||||
if not isinstance(objects, list):
|
||||
raise M48SmallStaticRegressionError("M4.8 prediction objects are invalid")
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for raw in objects:
|
||||
item = _object(raw, "M4.8 prediction object")
|
||||
normalized.append({
|
||||
"prediction_id": item.get("prediction_id"),
|
||||
"extent_xyxy": _extent(item.get("extent_xyxy")),
|
||||
"geometry_association": item.get("geometry_association"),
|
||||
"freshness": item.get("freshness"),
|
||||
"motion": item.get("motion"),
|
||||
"threat": item.get("threat"),
|
||||
})
|
||||
ranked = sorted(
|
||||
((_iou(anchor["extent_xyxy"], item["extent_xyxy"]), item) for item in normalized),
|
||||
key=lambda pair: (pair[0], str(pair[1].get("prediction_id"))),
|
||||
reverse=True,
|
||||
)
|
||||
best_iou, best = ranked[0] if ranked else (0.0, None)
|
||||
return {
|
||||
"schema_version": M48_SMALL_STATIC_COMPARISON_SCHEMA,
|
||||
"anchor_id": anchor["anchor_id"],
|
||||
"clip_id": anchor["clip_id"],
|
||||
"sequence": anchor["sequence"],
|
||||
"source_time_ns": row.get("source_time_ns"),
|
||||
"anchor_extent_xyxy": anchor["extent_xyxy"],
|
||||
"requires_avoidance_or_clearance": anchor["requires_avoidance_or_clearance"],
|
||||
"worker_candidate_count": len(normalized),
|
||||
"worker_objects": normalized,
|
||||
"best_prediction_id": best.get("prediction_id") if best else None,
|
||||
"best_iou": best_iou,
|
||||
"extent_iou_threshold": threshold,
|
||||
"matched_at_threshold": best_iou >= threshold,
|
||||
"outcome": "recalled" if best_iou >= threshold else "missed-assisted-anchor",
|
||||
}
|
||||
|
||||
|
||||
def _publish_result(
|
||||
*,
|
||||
destination: Path,
|
||||
identity: dict[str, Any],
|
||||
created_at_utc: str,
|
||||
accepted: bool,
|
||||
report: dict[str, Any],
|
||||
anchors: tuple[dict[str, Any], ...],
|
||||
comparisons: tuple[dict[str, Any], ...],
|
||||
) -> None:
|
||||
parent = destination.parent
|
||||
if parent.is_symlink():
|
||||
raise M48SmallStaticRegressionError("M4.8 regression output root must not be a symlink")
|
||||
parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if not parent.is_dir():
|
||||
raise M48SmallStaticRegressionError("M4.8 regression output root is invalid")
|
||||
staging = parent / f".{destination.name}.{uuid.uuid4().hex}.tmp"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
try:
|
||||
_write_json(staging / "report.json", report)
|
||||
_write_jsonl(staging / "anchors.jsonl", anchors)
|
||||
_write_jsonl(staging / "comparisons.jsonl", comparisons)
|
||||
artifacts = [
|
||||
_artifact(
|
||||
staging / "anchors.jsonl",
|
||||
"assisted-regression-anchors",
|
||||
M48_SMALL_STATIC_ANCHOR_SCHEMA,
|
||||
),
|
||||
_artifact(
|
||||
staging / "comparisons.jsonl",
|
||||
"exact-frame-worker-comparisons",
|
||||
M48_SMALL_STATIC_COMPARISON_SCHEMA,
|
||||
),
|
||||
_artifact(
|
||||
staging / "report.json",
|
||||
"m48-small-static-regression-report",
|
||||
M48_SMALL_STATIC_REPORT_SCHEMA,
|
||||
),
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": M48_SMALL_STATIC_RESULT_SCHEMA,
|
||||
"result_id": destination.name,
|
||||
"identity_sha256": _canonical_sha256(identity),
|
||||
"identity": identity,
|
||||
"created_at_utc": created_at_utc,
|
||||
"accepted": accepted,
|
||||
"ground_truth": False,
|
||||
"authority": dict(_AUTHORITY),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
_write_json(staging / "manifest.json", manifest)
|
||||
if destination.exists():
|
||||
existing = {
|
||||
path.name: _file_sha256(path)
|
||||
for path in destination.iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
proposed = {
|
||||
path.name: _file_sha256(path)
|
||||
for path in staging.iterdir()
|
||||
if path.is_file()
|
||||
}
|
||||
if existing != proposed:
|
||||
raise M48SmallStaticRegressionError("immutable M4.8 regression identity collided")
|
||||
shutil.rmtree(staging)
|
||||
return
|
||||
os.replace(staging, destination)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def _artifact(path: Path, role: str, schema_version: str) -> dict[str, object]:
|
||||
return {
|
||||
"path": path.name,
|
||||
"role": role,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _file_sha256(path),
|
||||
"schema_version": schema_version,
|
||||
"media_type": "application/x-ndjson" if path.suffix == ".jsonl" else "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _read_json_bytes(path: Path, *, maximum: int, label: str) -> tuple[bytes, dict[str, Any]]:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink() or not candidate.is_file() or candidate.stat().st_size > maximum:
|
||||
raise M48SmallStaticRegressionError(f"{label} is unavailable")
|
||||
try:
|
||||
encoded = candidate.read_bytes()
|
||||
value = json.loads(encoded)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise M48SmallStaticRegressionError(f"{label} is unreadable") from exc
|
||||
return encoded, _object(value, label)
|
||||
|
||||
|
||||
def _read_json(path: Path, *, maximum: int) -> dict[str, Any]:
|
||||
return _read_json_bytes(path, maximum=maximum, label=path.name)[1]
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size > 8 * 1024 * 1024:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression rows are unavailable")
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
if line.strip():
|
||||
rows.append(_object(json.loads(line), "M4.8 regression row"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise M48SmallStaticRegressionError("M4.8 regression rows are unreadable") from exc
|
||||
return rows
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_bytes(_canonical_json(value) + b"\n")
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: tuple[dict[str, Any], ...]) -> None:
|
||||
path.write_bytes(b"".join(_canonical_json(row) + b"\n" for row in rows))
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise M48SmallStaticRegressionError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _rate(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and 0.0 < float(value) <= 1.0
|
||||
)
|
||||
|
||||
|
||||
def _extent(value: object) -> list[float]:
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 4
|
||||
or any(not isinstance(item, (int, float)) or isinstance(item, bool) for item in value)
|
||||
):
|
||||
raise M48SmallStaticRegressionError("M4.8 extent is invalid")
|
||||
extent = [float(item) for item in value]
|
||||
if not (0.0 <= extent[0] < extent[2] <= 1.0 and 0.0 <= extent[1] < extent[3] <= 1.0):
|
||||
raise M48SmallStaticRegressionError("M4.8 extent is outside the camera plane")
|
||||
return extent
|
||||
|
||||
|
||||
def _iou(left: list[float], right: list[float]) -> float:
|
||||
x1 = max(left[0], right[0])
|
||||
y1 = max(left[1], right[1])
|
||||
x2 = min(left[2], right[2])
|
||||
y2 = min(left[3], right[3])
|
||||
intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
||||
left_area = (left[2] - left[0]) * (left[3] - left[1])
|
||||
right_area = (right[2] - right[0]) * (right[3] - right[1])
|
||||
union = left_area + right_area - intersection
|
||||
return intersection / union if union > 0.0 else 0.0
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _utc_timestamp(value: object) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise M48SmallStaticRegressionError("M4.8 run creation time is invalid")
|
||||
text = value.strip()
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise M48SmallStaticRegressionError("M4.8 run creation time is invalid") from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise M48SmallStaticRegressionError("M4.8 run creation time must be UTC")
|
||||
return parsed.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"M48_SMALL_STATIC_RESULT_SCHEMA",
|
||||
"M48SmallStaticRegressionError",
|
||||
"M48SmallStaticRegressionResult",
|
||||
"build_m48_small_static_passage_regression",
|
||||
"read_m48_small_static_passage_regression",
|
||||
]
|
||||
@@ -5,7 +5,11 @@ from .active import (
|
||||
ActiveSessionLeaseError,
|
||||
recover_stale_active_session_marker,
|
||||
)
|
||||
from .camera_frame import RecordedCameraFrame, RecordedCameraFrameService
|
||||
from .camera_frame import (
|
||||
RecordedCameraFrame,
|
||||
RecordedCameraFrameService,
|
||||
RecordedCameraPlaybackSource,
|
||||
)
|
||||
from .lab_cache import publish_lab_replay_cache
|
||||
from .media import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
@@ -68,6 +72,7 @@ __all__ = [
|
||||
"RecordedMediaArtifact",
|
||||
"RecordedCameraFrame",
|
||||
"RecordedCameraFrameService",
|
||||
"RecordedCameraPlaybackSource",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
"RecordedMediaFile",
|
||||
"RecordedMediaInspector",
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -15,6 +16,8 @@ from .store import SessionStore
|
||||
|
||||
_MAX_KEYFRAME_DISTANCE = 120
|
||||
_FFMPEG_TIMEOUT_SECONDS = 15.0
|
||||
_DEFAULT_MAX_DECODE_LANES = 32
|
||||
_DEFAULT_MAX_SOURCE_MANIFESTS = 32
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -24,6 +27,34 @@ class RecordedCameraFrame:
|
||||
width: int
|
||||
height: int
|
||||
sha256: str
|
||||
source_fragment_sha256: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedCameraPlaybackSource:
|
||||
"""Immutable single-epoch camera source for bounded review playback."""
|
||||
|
||||
session_id: str
|
||||
public_source_id: str
|
||||
artifact_id: str
|
||||
synchronization: str
|
||||
generation_sha256: str
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
byte_length: int
|
||||
media_type: str
|
||||
segment_sha256s: tuple[str, ...]
|
||||
segment_start_times_ns: tuple[int, ...]
|
||||
|
||||
@property
|
||||
def segment_count(self) -> int:
|
||||
return len(self.segment_sha256s)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CameraDecodeLane:
|
||||
active: bool = False
|
||||
latest_ticket: int = 0
|
||||
|
||||
|
||||
class RecordedCameraFrameService:
|
||||
@@ -42,6 +73,8 @@ class RecordedCameraFrameService:
|
||||
*,
|
||||
ffmpeg_path: Path,
|
||||
cache_root: Path,
|
||||
max_decode_lanes: int = _DEFAULT_MAX_DECODE_LANES,
|
||||
max_source_manifests: int = _DEFAULT_MAX_SOURCE_MANIFESTS,
|
||||
) -> None:
|
||||
resolved_ffmpeg = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
if not resolved_ffmpeg.is_file() or not os.access(resolved_ffmpeg, os.X_OK):
|
||||
@@ -53,7 +86,16 @@ class RecordedCameraFrameService:
|
||||
self._cache_root.mkdir(parents=True, exist_ok=True)
|
||||
if self._cache_root.is_symlink() or not self._cache_root.is_dir():
|
||||
raise SessionIntegrityError("camera frame cache root is invalid")
|
||||
self._lock = threading.Lock()
|
||||
if max_decode_lanes < 1 or max_source_manifests < 1:
|
||||
raise SessionIntegrityError("camera frame memory cache bounds are invalid")
|
||||
self._max_decode_lanes = max_decode_lanes
|
||||
self._max_source_manifests = max_source_manifests
|
||||
self._coordination = threading.Condition(threading.Lock())
|
||||
self._lanes: OrderedDict[tuple[str, str], _CameraDecodeLane] = OrderedDict()
|
||||
self._source_manifests: OrderedDict[
|
||||
tuple[str, str],
|
||||
RecordedMediaManifest,
|
||||
] = OrderedDict()
|
||||
|
||||
def extract(
|
||||
self,
|
||||
@@ -64,6 +106,108 @@ class RecordedCameraFrameService:
|
||||
) -> RecordedCameraFrame:
|
||||
if frame_index < 0:
|
||||
raise SessionIntegrityError("camera frame index is invalid")
|
||||
source_key = (session_id, expected_source_name)
|
||||
lane, ticket = self._acquire_lane(source_key)
|
||||
try:
|
||||
manifest = self._source_manifest(
|
||||
source_key,
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
self._require_latest(lane, ticket)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{manifest.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
|
||||
cached = _read_cached_jpeg(
|
||||
cache_path,
|
||||
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
|
||||
)
|
||||
if cached is not None:
|
||||
self._require_latest(lane, ticket)
|
||||
return cached
|
||||
self._require_latest(lane, ticket)
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
self._require_latest(lane, ticket)
|
||||
return frame
|
||||
finally:
|
||||
self._release_lane(source_key, lane)
|
||||
|
||||
def playback_source(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
expected_source_name: str = "sensor.camera.right",
|
||||
) -> RecordedCameraPlaybackSource:
|
||||
"""Return the generation-bound fMP4 source used by the shared LAB viewer.
|
||||
|
||||
LAB playback is intentionally admitted from the same cached immutable
|
||||
manifest as exact JPEG extraction. The contract is limited to one
|
||||
codec epoch because LAB frame sequence is a direct one-based segment
|
||||
sequence; a future multi-epoch source must add an explicit mapping
|
||||
contract instead of guessing across epoch boundaries.
|
||||
"""
|
||||
|
||||
manifest = self._source_manifest(
|
||||
(session_id, expected_source_name),
|
||||
session_id=session_id,
|
||||
expected_source_name=expected_source_name,
|
||||
)
|
||||
if manifest.synchronization != "host-arrival-best-effort" or len(manifest.epochs) != 1:
|
||||
raise SessionIntegrityError("recorded camera playback source is incompatible")
|
||||
epoch = manifest.epochs[0]
|
||||
if (
|
||||
epoch.ordinal != 1
|
||||
or epoch.timeline_start_seconds != manifest.timeline_start_seconds
|
||||
or epoch.timeline_end_seconds != manifest.timeline_end_seconds
|
||||
or not epoch.media_type.startswith("video/mp4;")
|
||||
or not epoch.segments
|
||||
or tuple(segment.sequence for segment in epoch.segments)
|
||||
!= tuple(range(1, len(epoch.segments) + 1))
|
||||
):
|
||||
raise SessionIntegrityError("recorded camera playback epoch is incompatible")
|
||||
starts_ns: list[int] = []
|
||||
previous_end_seconds = 0.0
|
||||
for segment in epoch.segments:
|
||||
starts_ns.append(
|
||||
round((epoch.timeline_start_seconds + previous_end_seconds) * 1_000_000_000)
|
||||
)
|
||||
previous_end_seconds = segment.end_time_seconds
|
||||
return RecordedCameraPlaybackSource(
|
||||
session_id=manifest.session_id,
|
||||
public_source_id=manifest.public_source_id,
|
||||
artifact_id=manifest.artifact_id,
|
||||
synchronization=manifest.synchronization,
|
||||
generation_sha256=manifest.generation_sha256,
|
||||
timeline_start_seconds=manifest.timeline_start_seconds,
|
||||
timeline_end_seconds=manifest.timeline_end_seconds,
|
||||
byte_length=manifest.byte_length,
|
||||
media_type=epoch.media_type,
|
||||
segment_sha256s=tuple(segment.sha256 for segment in epoch.segments),
|
||||
segment_start_times_ns=tuple(starts_ns),
|
||||
)
|
||||
|
||||
def _source_manifest(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
*,
|
||||
session_id: str,
|
||||
expected_source_name: str,
|
||||
) -> RecordedMediaManifest:
|
||||
"""Bind one immutable recorded source without rescanning it per frame."""
|
||||
|
||||
with self._coordination:
|
||||
cached = self._source_manifests.get(source_key)
|
||||
if cached is not None:
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
replay = self._store.prepare_replay(session_id, speed=1.0, loop=False)
|
||||
matches = tuple(
|
||||
artifact
|
||||
@@ -74,22 +218,66 @@ class RecordedCameraFrameService:
|
||||
raise SessionIntegrityError("recorded camera source is unavailable")
|
||||
artifact = matches[0]
|
||||
manifest = self._inspector.inspect(artifact, replay)
|
||||
epoch, sequence = _frame_location(manifest, frame_index)
|
||||
cache_key = hashlib.sha256(
|
||||
(
|
||||
f"{manifest.generation_sha256}\0{artifact.artifact_id}\0"
|
||||
f"{expected_source_name}\0{frame_index}\0jpeg-q2-v1"
|
||||
).encode()
|
||||
).hexdigest()
|
||||
cache_path = self._cache_root / f"{cache_key}.jpg"
|
||||
with self._coordination:
|
||||
bound = self._source_manifests.get(source_key)
|
||||
if bound is None:
|
||||
bound = manifest
|
||||
self._source_manifests[source_key] = manifest
|
||||
self._source_manifests.move_to_end(source_key)
|
||||
while len(self._source_manifests) > self._max_source_manifests:
|
||||
self._source_manifests.popitem(last=False)
|
||||
return bound
|
||||
|
||||
with self._lock:
|
||||
cached = _read_cached_jpeg(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
frame = self._decode(manifest, epoch, sequence)
|
||||
_publish_cached_jpeg(cache_path, frame.payload)
|
||||
return frame
|
||||
def _acquire_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
) -> tuple[_CameraDecodeLane, int]:
|
||||
"""Admit only the newest waiter behind one active source decode."""
|
||||
|
||||
with self._coordination:
|
||||
lane = self._lanes.get(source_key)
|
||||
if lane is None:
|
||||
lane = _CameraDecodeLane()
|
||||
self._lanes[source_key] = lane
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes(exclude=source_key)
|
||||
lane.latest_ticket += 1
|
||||
ticket = lane.latest_ticket
|
||||
self._coordination.notify_all()
|
||||
while lane.active:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
self._coordination.wait()
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
lane.active = True
|
||||
return lane, ticket
|
||||
|
||||
def _evict_inactive_lanes(self, *, exclude: tuple[str, str] | None = None) -> None:
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
return
|
||||
for source_key, lane in tuple(self._lanes.items()):
|
||||
if len(self._lanes) <= self._max_decode_lanes:
|
||||
break
|
||||
if source_key != exclude and not lane.active:
|
||||
del self._lanes[source_key]
|
||||
|
||||
def _require_latest(self, lane: _CameraDecodeLane, ticket: int) -> None:
|
||||
with self._coordination:
|
||||
if ticket != lane.latest_ticket:
|
||||
raise SessionIntegrityError("camera frame request was superseded")
|
||||
|
||||
def _release_lane(
|
||||
self,
|
||||
source_key: tuple[str, str],
|
||||
lane: _CameraDecodeLane,
|
||||
) -> None:
|
||||
with self._coordination:
|
||||
lane.active = False
|
||||
if self._lanes.get(source_key) is lane:
|
||||
self._lanes.move_to_end(source_key)
|
||||
self._evict_inactive_lanes()
|
||||
self._coordination.notify_all()
|
||||
|
||||
def _decode(
|
||||
self,
|
||||
@@ -156,6 +344,7 @@ class RecordedCameraFrameService:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=digest,
|
||||
source_fragment_sha256=target.sha256,
|
||||
)
|
||||
|
||||
|
||||
@@ -196,7 +385,11 @@ def _jpeg_dimensions(payload: bytes) -> tuple[int, int]:
|
||||
raise SessionIntegrityError("camera frame JPEG dimensions are unavailable")
|
||||
|
||||
|
||||
def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
def _read_cached_jpeg(
|
||||
path: Path,
|
||||
*,
|
||||
source_fragment_sha256: str,
|
||||
) -> RecordedCameraFrame | None:
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return None
|
||||
@@ -210,6 +403,7 @@ def _read_cached_jpeg(path: Path) -> RecordedCameraFrame | None:
|
||||
width=width,
|
||||
height=height,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
source_fragment_sha256=source_fragment_sha256,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ from k1link.compute.e40_perception_product_gate import (
|
||||
read_e40_perception_product_gate,
|
||||
)
|
||||
from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceVariant
|
||||
from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity
|
||||
from k1link.web.l31_pointpillars_ravnoves_api import latest_l31_identity
|
||||
from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity
|
||||
@@ -259,7 +260,10 @@ def _advanced_index(
|
||||
specs: tuple[_AdvancedIndexSpec, ...],
|
||||
) -> dict[str, object]:
|
||||
items: list[dict[str, object]] = []
|
||||
selected_work_ids: set[str] = set()
|
||||
for work_id, provider, pattern, document_name, schema_version in specs:
|
||||
if work_id in selected_work_ids:
|
||||
continue
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
continue
|
||||
@@ -273,6 +277,7 @@ def _advanced_index(
|
||||
schema_version=schema_version,
|
||||
)
|
||||
)
|
||||
selected_work_ids.add(work_id)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
continue
|
||||
@@ -290,17 +295,18 @@ def _registry_index_specs(
|
||||
return tuple(
|
||||
(
|
||||
definition.work_id,
|
||||
_evidence_root_provider(definition, runtime_root_provider),
|
||||
definition.result_id_pattern,
|
||||
definition.document_name,
|
||||
definition.result_schema_version,
|
||||
_evidence_root_provider(variant, runtime_root_provider),
|
||||
variant.result_id_pattern,
|
||||
variant.document_name,
|
||||
variant.result_schema_version,
|
||||
)
|
||||
for definition in registry.definitions
|
||||
for variant in reversed(definition.evidence_variants)
|
||||
)
|
||||
|
||||
|
||||
def _evidence_root_provider(
|
||||
definition: LaboratoryEvidenceDefinition,
|
||||
definition: LaboratoryEvidenceDefinition | LaboratoryEvidenceVariant,
|
||||
runtime_root_provider: RootProvider,
|
||||
) -> RootProvider:
|
||||
def result_root_provider() -> Path | None:
|
||||
|
||||
@@ -23,15 +23,23 @@ from k1link.compute import (
|
||||
RecordedPerceptionOverlayMux,
|
||||
RecordedPerceptionOverlayStore,
|
||||
)
|
||||
from k1link.compute.pipeline_telemetry import JsonlPipelineTelemetrySink
|
||||
from k1link.laboratory import (
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceReportService,
|
||||
LaboratoryExecutionRegistry,
|
||||
LaboratoryRunner,
|
||||
LaboratoryValueReviewRegistry,
|
||||
)
|
||||
from k1link.laboratory.m48_raw_evidence import (
|
||||
M48_EXPECTED_THREAT_RESULT_ID,
|
||||
M48RawEvidenceError,
|
||||
M48RawEvidenceReader,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedCameraFrameService,
|
||||
RecordedCameraPlaybackSource,
|
||||
RecordedMediaInspector,
|
||||
RecordedMediaManifest,
|
||||
RecordingPreparationQueueFull,
|
||||
@@ -116,6 +124,7 @@ from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||
from k1link.web.m4_threat_replay_api import build_m4_threat_replay_router
|
||||
from k1link.web.m48_object_quality_api import build_m48_object_quality_router
|
||||
from k1link.web.map_api import (
|
||||
MapGatewayConfiguration,
|
||||
MapGatewayProxy,
|
||||
@@ -151,6 +160,13 @@ LABORATORY_EXECUTION_REGISTRY = LaboratoryExecutionRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-execution.json",
|
||||
LABORATORY_EVIDENCE_REGISTRY,
|
||||
)
|
||||
LABORATORY_RUNNER = LaboratoryRunner(
|
||||
registry=LABORATORY_EXECUTION_REGISTRY,
|
||||
evidence_registry=LABORATORY_EVIDENCE_REGISTRY,
|
||||
sink=JsonlPipelineTelemetrySink(
|
||||
REPOSITORY_ROOT / ".runtime" / "telemetry" / "laboratory-runs.jsonl"
|
||||
),
|
||||
)
|
||||
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||
)
|
||||
@@ -204,6 +220,20 @@ session_recorded_camera_frame_service = (
|
||||
if _ffmpeg is not None
|
||||
else None
|
||||
)
|
||||
try:
|
||||
m48_raw_evidence_reader: M48RawEvidenceReader | None = M48RawEvidenceReader.from_repository(
|
||||
repository_root=REPOSITORY_ROOT,
|
||||
threat_result_root=(
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "m4"
|
||||
/ "replay-threat"
|
||||
/ M48_EXPECTED_THREAT_RESULT_ID
|
||||
),
|
||||
)
|
||||
except (M48RawEvidenceError, OSError, ValueError):
|
||||
m48_raw_evidence_reader = None
|
||||
session_legacy_perception_overlay_store = (
|
||||
RecordedPerceptionOverlayStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
@@ -295,6 +325,20 @@ session_recording_preparation_manager = SessionRecordingPreparationManager(
|
||||
)
|
||||
|
||||
|
||||
def _m48_recorded_camera_playback_source(
|
||||
session_id: str,
|
||||
) -> RecordedCameraPlaybackSource:
|
||||
"""Publish the durable replay package before exposing its manifest URL."""
|
||||
|
||||
if session_recorded_camera_frame_service is None:
|
||||
raise RuntimeError("recorded camera playback is unavailable")
|
||||
command = session_store.prepare_replay(session_id, speed=1.0, loop=False)
|
||||
snapshot = session_recording_preparation_manager.restore_published(command)
|
||||
if snapshot is None or snapshot.state != "ready" or snapshot.recorded_media is None:
|
||||
raise RuntimeError("recorded camera playback package is not published")
|
||||
return session_recorded_camera_frame_service.playback_source(session_id)
|
||||
|
||||
|
||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||
|
||||
@@ -859,6 +903,47 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_m48_object_quality_router(
|
||||
pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-quality-packs"
|
||||
),
|
||||
workflow_root_provider=lambda: (
|
||||
REPOSITORY_ROOT / ".runtime" / "laboratory-annotations" / "m48-object-quality"
|
||||
),
|
||||
truth_root_provider=lambda: (
|
||||
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-truth-seals"
|
||||
),
|
||||
result_root_provider=lambda: (
|
||||
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "m48" / "object-quality-results"
|
||||
),
|
||||
small_static_result_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "m48"
|
||||
/ "small-static-passage-regression-results"
|
||||
),
|
||||
camera_frame_provider=(
|
||||
session_recorded_camera_frame_service.extract
|
||||
if session_recorded_camera_frame_service is not None
|
||||
else None
|
||||
),
|
||||
camera_playback_provider=(
|
||||
_m48_recorded_camera_playback_source
|
||||
if session_recorded_camera_frame_service is not None
|
||||
else None
|
||||
),
|
||||
spatial_evidence_provider=m48_raw_evidence_reader,
|
||||
evaluation_runner=LABORATORY_RUNNER,
|
||||
evaluation_receipt_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "laboratory-run-receipts"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e47_semantic_slam_router(
|
||||
root_provider=lambda: (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,11 @@ from fastapi.routing import APIRoute
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import k1link.web.advanced_laboratory_api as advanced_api
|
||||
from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory import (
|
||||
LaboratoryEvidenceDefinition,
|
||||
LaboratoryEvidenceRegistry,
|
||||
LaboratoryEvidenceVariant,
|
||||
)
|
||||
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
||||
|
||||
|
||||
@@ -74,6 +78,79 @@ def test_advanced_index_is_empty_when_not_configured() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_advanced_index_projects_one_most_mature_lifecycle_phase(tmp_path: Path) -> None:
|
||||
variants = (
|
||||
LaboratoryEvidenceVariant(
|
||||
phase="review",
|
||||
runtime_relative_root=PurePosixPath("packs"),
|
||||
result_id_prefix="quality-pack",
|
||||
document_name="manifest.json",
|
||||
result_schema_version="missioncore.quality-pack/v1",
|
||||
),
|
||||
LaboratoryEvidenceVariant(
|
||||
phase="result",
|
||||
runtime_relative_root=PurePosixPath("results"),
|
||||
result_id_prefix="quality-result",
|
||||
document_name="manifest.json",
|
||||
result_schema_version="missioncore.quality-result/v1",
|
||||
),
|
||||
)
|
||||
registry = LaboratoryEvidenceRegistry(
|
||||
definitions=(
|
||||
LaboratoryEvidenceDefinition(
|
||||
work_id="quality-lab",
|
||||
runtime_relative_root=variants[-1].runtime_relative_root,
|
||||
result_id_prefix=variants[-1].result_id_prefix,
|
||||
document_name=variants[-1].document_name,
|
||||
result_schema_version=variants[-1].result_schema_version,
|
||||
lifecycle_variants=variants,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def publish(variant: LaboratoryEvidenceVariant, digest: str, created_at: str) -> str:
|
||||
result_id = f"{variant.result_id_prefix}-{digest}"
|
||||
result_root = variant.result_root(tmp_path) / result_id
|
||||
result_root.mkdir(parents=True)
|
||||
(result_root / variant.document_name).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": variant.result_schema_version,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": digest,
|
||||
"identity": {
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
},
|
||||
"created_at_utc": created_at,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return result_id
|
||||
|
||||
pack_id = publish(variants[0], "a" * 64, "2026-08-24T10:00:00Z")
|
||||
router = build_advanced_laboratory_router(
|
||||
evidence_registry=registry,
|
||||
evidence_runtime_root_provider=lambda: tmp_path,
|
||||
)
|
||||
route = _endpoint(router, "/api/v1/laboratory/advanced-index")
|
||||
assert route()["items"][0]["result_id"] == pack_id # type: ignore[index,operator]
|
||||
|
||||
result_id = publish(variants[1], "b" * 64, "2026-08-24T11:00:00Z")
|
||||
index = route() # type: ignore[operator]
|
||||
assert index["items"] == [ # type: ignore[index]
|
||||
{
|
||||
"work_id": "quality-lab",
|
||||
"result_id": result_id,
|
||||
"created_at_utc": "2026-08-24T11:00:00Z",
|
||||
"access": "read-only",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_advanced_index_includes_valid_l31_identity(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.sessions.camera_frame import RecordedCameraFrame, RecordedCameraFrameService
|
||||
from k1link.sessions.media import (
|
||||
RecordedMediaEpoch,
|
||||
RecordedMediaManifest,
|
||||
RecordedMediaSegment,
|
||||
)
|
||||
from k1link.sessions.models import RecordedMediaArtifact, SessionIntegrityError
|
||||
|
||||
_JPEG = b"\xff\xd8\xff\xc0\x00\x07\x08\x00\x01\x00\x01\xff\xd9"
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, *, block_prepare: bool = False) -> None:
|
||||
self.prepare_calls = 0
|
||||
self.list_calls = 0
|
||||
self.prepare_started = threading.Event()
|
||||
self.release_prepare = threading.Event()
|
||||
if not block_prepare:
|
||||
self.release_prepare.set()
|
||||
self.artifact = RecordedMediaArtifact(
|
||||
session_id="recorded-session",
|
||||
public_source_id="camera",
|
||||
artifact_id="camera-artifact",
|
||||
source_path=Path("/sealed/sensor.camera.right"),
|
||||
byte_length=123,
|
||||
)
|
||||
|
||||
def prepare_replay(self, session_id: str, **_: Any) -> SimpleNamespace:
|
||||
self.prepare_calls += 1
|
||||
self.prepare_started.set()
|
||||
assert self.release_prepare.wait(timeout=2.0)
|
||||
return SimpleNamespace(session_id=session_id)
|
||||
|
||||
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
|
||||
self.list_calls += 1
|
||||
return (self.artifact,)
|
||||
|
||||
|
||||
class _Inspector:
|
||||
def __init__(self, manifest: RecordedMediaManifest) -> None:
|
||||
self.manifest = manifest
|
||||
self.inspect_calls = 0
|
||||
|
||||
def inspect(self, artifact: RecordedMediaArtifact, replay: object) -> RecordedMediaManifest:
|
||||
assert artifact.artifact_id == "camera-artifact"
|
||||
assert replay is not None
|
||||
self.inspect_calls += 1
|
||||
return self.manifest
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> RecordedMediaManifest:
|
||||
epoch_path = tmp_path / "epoch-1"
|
||||
segments = tuple(
|
||||
RecordedMediaSegment(
|
||||
sequence=sequence,
|
||||
path=epoch_path / "segments" / f"{sequence:08d}.m4s",
|
||||
byte_length=10,
|
||||
sha256=hashlib.sha256(str(sequence).encode()).hexdigest(),
|
||||
random_access=True,
|
||||
end_time_seconds=float(sequence),
|
||||
)
|
||||
for sequence in range(1, 4)
|
||||
)
|
||||
return RecordedMediaManifest(
|
||||
session_id="recorded-session",
|
||||
public_source_id="camera",
|
||||
artifact_id="camera-artifact",
|
||||
synchronization="recorded",
|
||||
generation_sha256="a" * 64,
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
byte_length=123,
|
||||
epochs=(
|
||||
RecordedMediaEpoch(
|
||||
ordinal=1,
|
||||
path=epoch_path,
|
||||
init_path=epoch_path / "init.mp4",
|
||||
init_byte_length=10,
|
||||
init_sha256="b" * 64,
|
||||
media_type="video/mp4",
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
segments=segments,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
tmp_path: Path,
|
||||
store: _Store,
|
||||
inspector: _Inspector,
|
||||
*,
|
||||
max_decode_lanes: int = 32,
|
||||
max_source_manifests: int = 32,
|
||||
) -> RecordedCameraFrameService:
|
||||
return RecordedCameraFrameService(
|
||||
store, # type: ignore[arg-type]
|
||||
inspector, # type: ignore[arg-type]
|
||||
ffmpeg_path=Path(sys.executable),
|
||||
cache_root=tmp_path / "cache",
|
||||
max_decode_lanes=max_decode_lanes,
|
||||
max_source_manifests=max_source_manifests,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_latest(service: RecordedCameraFrameService, ticket: int) -> None:
|
||||
deadline = time.monotonic() + 2.0
|
||||
source_key = ("recorded-session", "sensor.camera.right")
|
||||
while time.monotonic() < deadline:
|
||||
with service._coordination: # noqa: SLF001
|
||||
lane = service._lanes.get(source_key) # noqa: SLF001
|
||||
if lane is not None and lane.latest_ticket == ticket:
|
||||
return
|
||||
time.sleep(0.005)
|
||||
raise AssertionError(f"camera request ticket {ticket} was not registered")
|
||||
|
||||
|
||||
def _frame(epoch: RecordedMediaEpoch, sequence: int) -> RecordedCameraFrame:
|
||||
return RecordedCameraFrame(
|
||||
payload=_JPEG,
|
||||
media_type="image/jpeg",
|
||||
width=1,
|
||||
height=1,
|
||||
sha256=hashlib.sha256(_JPEG).hexdigest(),
|
||||
source_fragment_sha256=epoch.segments[sequence - 1].sha256,
|
||||
)
|
||||
|
||||
|
||||
def test_camera_frame_burst_scans_manifest_once_and_decodes_only_latest(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _Store(block_prepare=True)
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(tmp_path, store, inspector)
|
||||
decoded: list[int] = []
|
||||
|
||||
def decode(
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch: RecordedMediaEpoch,
|
||||
sequence: int,
|
||||
) -> RecordedCameraFrame:
|
||||
assert manifest is inspector.manifest
|
||||
decoded.append(sequence)
|
||||
return _frame(epoch, sequence)
|
||||
|
||||
monkeypatch.setattr(service, "_decode", decode)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
first = pool.submit(service.extract, "recorded-session", 0)
|
||||
assert store.prepare_started.wait(timeout=2.0)
|
||||
second = pool.submit(service.extract, "recorded-session", 1)
|
||||
_wait_for_latest(service, 2)
|
||||
third = pool.submit(service.extract, "recorded-session", 2)
|
||||
_wait_for_latest(service, 3)
|
||||
store.release_prepare.set()
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
first.result(timeout=2.0)
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
second.result(timeout=2.0)
|
||||
assert third.result(timeout=2.0).source_fragment_sha256 == (
|
||||
inspector.manifest.epochs[0].segments[2].sha256
|
||||
)
|
||||
|
||||
assert store.prepare_calls == 1
|
||||
assert store.list_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
assert decoded == [3]
|
||||
|
||||
|
||||
def test_camera_frame_lane_keeps_only_latest_waiter_behind_active_decode(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(tmp_path, store, inspector)
|
||||
decode_started = threading.Event()
|
||||
release_decode = threading.Event()
|
||||
decoded: list[int] = []
|
||||
|
||||
def decode(
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch: RecordedMediaEpoch,
|
||||
sequence: int,
|
||||
) -> RecordedCameraFrame:
|
||||
assert manifest is inspector.manifest
|
||||
decoded.append(sequence)
|
||||
if sequence == 1:
|
||||
decode_started.set()
|
||||
assert release_decode.wait(timeout=2.0)
|
||||
return _frame(epoch, sequence)
|
||||
|
||||
monkeypatch.setattr(service, "_decode", decode)
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
first = pool.submit(service.extract, "recorded-session", 0)
|
||||
assert decode_started.wait(timeout=2.0)
|
||||
second = pool.submit(service.extract, "recorded-session", 1)
|
||||
_wait_for_latest(service, 2)
|
||||
third = pool.submit(service.extract, "recorded-session", 2)
|
||||
_wait_for_latest(service, 3)
|
||||
release_decode.set()
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
first.result(timeout=2.0)
|
||||
with pytest.raises(SessionIntegrityError, match="superseded"):
|
||||
second.result(timeout=2.0)
|
||||
assert third.result(timeout=2.0).width == 1
|
||||
|
||||
assert decoded == [1, 3]
|
||||
assert store.prepare_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
|
||||
|
||||
def test_camera_frame_memory_caches_are_lru_bounded(tmp_path: Path) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
service = _service(
|
||||
tmp_path,
|
||||
store,
|
||||
inspector,
|
||||
max_decode_lanes=2,
|
||||
max_source_manifests=2,
|
||||
)
|
||||
|
||||
for ordinal in range(4):
|
||||
source_key = (f"session-{ordinal}", "sensor.camera.right")
|
||||
lane, _ = service._acquire_lane(source_key) # noqa: SLF001
|
||||
service._release_lane(source_key, lane) # noqa: SLF001
|
||||
assert tuple(service._lanes) == ( # noqa: SLF001
|
||||
("session-2", "sensor.camera.right"),
|
||||
("session-3", "sensor.camera.right"),
|
||||
)
|
||||
|
||||
for ordinal in range(3):
|
||||
source_key = (f"session-{ordinal}", "sensor.camera.right")
|
||||
service._source_manifest( # noqa: SLF001
|
||||
source_key,
|
||||
session_id=source_key[0],
|
||||
expected_source_name=source_key[1],
|
||||
)
|
||||
assert tuple(service._source_manifests) == ( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
("session-2", "sensor.camera.right"),
|
||||
)
|
||||
|
||||
service._source_manifest( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
session_id="session-1",
|
||||
expected_source_name="sensor.camera.right",
|
||||
)
|
||||
service._source_manifest( # noqa: SLF001
|
||||
("session-3", "sensor.camera.right"),
|
||||
session_id="session-3",
|
||||
expected_source_name="sensor.camera.right",
|
||||
)
|
||||
assert tuple(service._source_manifests) == ( # noqa: SLF001
|
||||
("session-1", "sensor.camera.right"),
|
||||
("session-3", "sensor.camera.right"),
|
||||
)
|
||||
assert store.prepare_calls == 4
|
||||
assert inspector.inspect_calls == 4
|
||||
|
||||
|
||||
def test_camera_playback_source_reuses_manifest_and_exposes_exact_segment_clock(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = _Store()
|
||||
manifest = _manifest(tmp_path)
|
||||
manifest = RecordedMediaManifest(
|
||||
session_id=manifest.session_id,
|
||||
public_source_id=manifest.public_source_id,
|
||||
artifact_id=manifest.artifact_id,
|
||||
synchronization="host-arrival-best-effort",
|
||||
generation_sha256=manifest.generation_sha256,
|
||||
timeline_start_seconds=manifest.timeline_start_seconds,
|
||||
timeline_end_seconds=manifest.timeline_end_seconds,
|
||||
byte_length=manifest.byte_length,
|
||||
epochs=(
|
||||
RecordedMediaEpoch(
|
||||
ordinal=1,
|
||||
path=manifest.epochs[0].path,
|
||||
init_path=manifest.epochs[0].init_path,
|
||||
init_byte_length=manifest.epochs[0].init_byte_length,
|
||||
init_sha256=manifest.epochs[0].init_sha256,
|
||||
media_type='video/mp4; codecs="avc1.640028"',
|
||||
timeline_start_seconds=0.0,
|
||||
timeline_end_seconds=3.0,
|
||||
segments=manifest.epochs[0].segments,
|
||||
),
|
||||
),
|
||||
)
|
||||
inspector = _Inspector(manifest)
|
||||
service = _service(tmp_path, store, inspector)
|
||||
|
||||
first = service.playback_source("recorded-session")
|
||||
second = service.playback_source("recorded-session")
|
||||
|
||||
assert first == second
|
||||
assert first.segment_count == 3
|
||||
assert first.segment_sha256s == tuple(
|
||||
segment.sha256 for segment in manifest.epochs[0].segments
|
||||
)
|
||||
assert first.segment_start_times_ns == (0, 1_000_000_000, 2_000_000_000)
|
||||
assert store.prepare_calls == 1
|
||||
assert inspector.inspect_calls == 1
|
||||
|
||||
|
||||
def test_camera_frame_rejects_unbounded_memory_cache_configuration(tmp_path: Path) -> None:
|
||||
store = _Store()
|
||||
inspector = _Inspector(_manifest(tmp_path))
|
||||
with pytest.raises(SessionIntegrityError, match="cache bounds"):
|
||||
_service(tmp_path, store, inspector, max_decode_lanes=0)
|
||||
@@ -127,7 +127,7 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
||||
repository_root / "config" / "laboratories"
|
||||
)
|
||||
|
||||
assert len(registry.definitions) == 34
|
||||
assert len(registry.definitions) == 36
|
||||
assert {item.work_id for item in registry.definitions} >= {
|
||||
"e31-source-binding",
|
||||
"e46j-raw-fisheye-realtime",
|
||||
@@ -139,4 +139,15 @@ def test_product_registry_declares_every_advanced_evidence_source() -> None:
|
||||
"l34f-adjudicated-reference",
|
||||
"m4-replay-threat",
|
||||
"m47-reference-graph-shadow",
|
||||
"m48-object-centric-quality",
|
||||
"m48-small-static-passage-regression",
|
||||
}
|
||||
m48 = next(
|
||||
item for item in registry.definitions
|
||||
if item.work_id == "m48-object-centric-quality"
|
||||
)
|
||||
assert [variant.phase for variant in m48.evidence_variants] == ["review", "result"]
|
||||
assert [variant.result_id_prefix for variant in m48.evidence_variants] == [
|
||||
"m48-object-quality-pack",
|
||||
"m48-object-quality-result",
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ import hashlib
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -90,6 +91,8 @@ def test_repository_registry_classifies_every_evidence_definition() -> None:
|
||||
evidence, execution = _registries()
|
||||
|
||||
assert {row.work_id for row in execution.definitions} == {
|
||||
"m48-small-static-passage-regression",
|
||||
"m48-object-centric-quality",
|
||||
"m4-replay-threat",
|
||||
"e33-worker-shadow",
|
||||
"e35-degradation-recovery",
|
||||
@@ -97,6 +100,12 @@ def test_repository_registry_classifies_every_evidence_definition() -> None:
|
||||
"e47-semantic-slam-shadow",
|
||||
}
|
||||
by_work_id = {row.work_id: row for row in execution.definitions}
|
||||
assert by_work_id["m48-small-static-passage-regression"].evidence_contract == (
|
||||
"missioncore.m48-small-static-passage-regression-result/v1"
|
||||
)
|
||||
assert by_work_id["m48-object-centric-quality"].evidence_contract == (
|
||||
"missioncore.m48-object-centric-quality-result/v1"
|
||||
)
|
||||
assert by_work_id["e47-semantic-slam-shadow"].lifecycle == "experimental"
|
||||
assert by_work_id["e47-semantic-slam-shadow"].isolation == "bounded-adapter"
|
||||
assert all(
|
||||
@@ -193,6 +202,65 @@ def test_runner_rejects_undeclared_input_before_adapter(tmp_path: Path) -> None:
|
||||
assert called is False
|
||||
|
||||
|
||||
def test_m48_evaluation_uses_registered_adapter_and_common_receipt(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
evidence, execution = _registries()
|
||||
pack_root = tmp_path / "pack"
|
||||
truth_root = tmp_path / "truth"
|
||||
pack_root.mkdir()
|
||||
truth_root.mkdir()
|
||||
adapter_result = _evidence_result(
|
||||
tmp_path / "results",
|
||||
work_id="m48-object-centric-quality",
|
||||
)
|
||||
|
||||
def score(**kwargs: Path) -> SimpleNamespace:
|
||||
assert kwargs == {
|
||||
"pack_root": pack_root,
|
||||
"truth_seal_root": truth_root,
|
||||
"output_root": tmp_path / "results",
|
||||
}
|
||||
return SimpleNamespace(
|
||||
result_root=adapter_result.result_root,
|
||||
result_id=adapter_result.result_id,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"k1link.laboratory.m48_object_quality.score_m48_object_quality",
|
||||
score,
|
||||
)
|
||||
runner = LaboratoryRunner(
|
||||
registry=execution,
|
||||
evidence_registry=evidence,
|
||||
sink=JsonlPipelineTelemetrySink(tmp_path / "pipeline.jsonl"),
|
||||
)
|
||||
request = LaboratoryRunRequest(
|
||||
work_id="m48-object-centric-quality",
|
||||
run_id="m48-evaluation-fixture",
|
||||
request_id="evaluate-once",
|
||||
contour_id="mission-core-lab",
|
||||
agent_id="local-control-plane",
|
||||
node_id="fixture-node",
|
||||
source_id="m48-pack-fixture",
|
||||
source_package_id="m48-truth-fixture",
|
||||
method_id="m48-object-centric-quality/v1",
|
||||
inputs={"pack_root": pack_root, "truth_seal_root": truth_root},
|
||||
output_root=tmp_path / "results",
|
||||
receipt_root=tmp_path / "receipts",
|
||||
)
|
||||
|
||||
result = runner.run(request)
|
||||
|
||||
assert result.result_id == adapter_result.result_id
|
||||
assert result.receipt["adapter_id"] == "canonical.m48-object-centric-quality/v1"
|
||||
assert result.receipt["contracts"]["evidence"] == (
|
||||
"missioncore.m48-object-centric-quality-result/v1"
|
||||
)
|
||||
assert (result.receipt_root / "receipt.json").is_file()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.laboratory.m48_object_quality as m48
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_text(json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
|
||||
|
||||
def _frame_catalog() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"sequence": sequence,
|
||||
"source_time_ns": (sequence - 1) * 100_000_000,
|
||||
"camera_fragment_sha256": hashlib.sha256(f"frame-{sequence}".encode()).hexdigest(),
|
||||
}
|
||||
for sequence in range(1, 4490)
|
||||
]
|
||||
|
||||
|
||||
def _clips() -> list[dict[str, object]]:
|
||||
rows = []
|
||||
for index in range(20):
|
||||
start = 1 + index * 100
|
||||
split = "development" if index < 10 else "validation"
|
||||
split_index = index if index < 10 else index - 10
|
||||
rows.append(
|
||||
{
|
||||
"clip_id": f"clip-{index:02d}",
|
||||
"component_id": f"component-{split}-{split_index // 2:02d}",
|
||||
"route_block": f"route-{split}-{split_index // 3:02d}",
|
||||
"time_block": f"time-{split}-{split_index // 2:02d}",
|
||||
"split": split,
|
||||
"start_sequence": start,
|
||||
"end_sequence": start + 50,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _clip_fixture_state(clip_id: str) -> dict[str, object]:
|
||||
local_index = int(clip_id.rsplit("-", 1)[1]) % 10
|
||||
if local_index == 0:
|
||||
return {
|
||||
"extent_xyxy": [0.2, 0.2, 0.22, 0.22],
|
||||
"geometry_association": "unknown",
|
||||
"motion": "unsupported",
|
||||
"threat": "unknown",
|
||||
"unknown_causes": [
|
||||
"insufficient-geometry-support",
|
||||
"threat-evidence-insufficient",
|
||||
],
|
||||
}
|
||||
if local_index == 1:
|
||||
return {
|
||||
"extent_xyxy": [0.01, 0.2, 0.2, 0.4],
|
||||
"geometry_association": "associated",
|
||||
"motion": "static",
|
||||
"threat": "not-threat",
|
||||
"unknown_causes": [],
|
||||
}
|
||||
if local_index == 2:
|
||||
return {
|
||||
"extent_xyxy": [0.1, 0.1, 0.3, 0.4],
|
||||
"geometry_association": "associated",
|
||||
"motion": "moving",
|
||||
"threat": "threat",
|
||||
"unknown_causes": [],
|
||||
}
|
||||
return {
|
||||
"extent_xyxy": [0.1, 0.1, 0.3, 0.4],
|
||||
"geometry_association": "associated",
|
||||
"motion": "static",
|
||||
"threat": "not-threat",
|
||||
"unknown_causes": [],
|
||||
}
|
||||
|
||||
|
||||
def _preparation_provenance() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": m48.M48_PREPARATION_PROVENANCE_SCHEMA,
|
||||
"adapter": {
|
||||
"module": "k1link.laboratory.m48_ravnoves00_pack",
|
||||
"sha256": "1" * 64,
|
||||
},
|
||||
"selection": {
|
||||
"selection_id": "m48-ravnoves00-balanced-connected-clips/v1",
|
||||
"sha256": "2" * 64,
|
||||
},
|
||||
"camera_index": {
|
||||
"source_session_id": "20260720T065719Z_viewer_live",
|
||||
"sha256": "3" * 64,
|
||||
"byte_length": 1234,
|
||||
"frame_count": 4489,
|
||||
},
|
||||
"graph": {
|
||||
"result_id": "m47-reference-graph-" + "4" * 64,
|
||||
"manifest_sha256": "5" * 64,
|
||||
"frames_sha256": "6" * 64,
|
||||
},
|
||||
"threat": {
|
||||
"result_id": "m4-threat-replay-" + "7" * 64,
|
||||
"manifest_sha256": "8" * 64,
|
||||
"frames_sha256": "9" * 64,
|
||||
},
|
||||
"geometry": {
|
||||
"result_id": "m4-geometry-replay-" + "a" * 64,
|
||||
"manifest_sha256": "b" * 64,
|
||||
"frames_sha256": "c" * 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _prediction_rows(
|
||||
clips: list[dict[str, object]],
|
||||
*,
|
||||
unsafe_free_space: bool,
|
||||
unsafe_free_space_split: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
for clip in clips:
|
||||
fixture = _clip_fixture_state(str(clip["clip_id"]))
|
||||
unsafe = unsafe_free_space and (
|
||||
unsafe_free_space_split is None or clip["split"] == unsafe_free_space_split
|
||||
)
|
||||
for sequence in range(int(clip["start_sequence"]), int(clip["end_sequence"]) + 1):
|
||||
objects = [
|
||||
{
|
||||
"prediction_id": f"prediction-{sequence}",
|
||||
"extent_xyxy": fixture["extent_xyxy"],
|
||||
"geometry_association": fixture["geometry_association"],
|
||||
"freshness": "current",
|
||||
"motion": fixture["motion"],
|
||||
"threat": fixture["threat"],
|
||||
"unknown_causes": fixture["unknown_causes"],
|
||||
}
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"source_time_ns": (sequence - 1) * 100_000_000,
|
||||
"terminal_outcome": "delivered",
|
||||
"terminal_reason": None,
|
||||
"free_space_claimed": unsafe,
|
||||
"objects": objects,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _fake_m47(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path / f"m47-reference-graph-lab-{'a' * 64}"
|
||||
root.mkdir()
|
||||
manifest = {
|
||||
"schema_version": "missioncore.reference-perception-graph-lab/v2",
|
||||
"accepted": True,
|
||||
"ground_truth": False,
|
||||
}
|
||||
_write_json(root / "manifest.json", manifest)
|
||||
report = {
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"source_session_id": "20260720T065719Z_viewer_live",
|
||||
"graph_result_id": "m47-reference-graph-" + "b" * 64,
|
||||
},
|
||||
"method": {
|
||||
"graph_id": "reference-perception-graph/v2",
|
||||
"run_mode": "lossless-replay",
|
||||
"canonical_payload_sha256": "c" * 64,
|
||||
},
|
||||
"decision": {
|
||||
"state": "accepted-reference-graph-replay",
|
||||
"next_gate": "independent-object-centric-detection-quality",
|
||||
},
|
||||
"acceptance": {"accepted": True},
|
||||
"authority": {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"ground_truth": False,
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
m48,
|
||||
"read_m47_reference_graph_lab",
|
||||
lambda _: SimpleNamespace(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
manifest=manifest,
|
||||
report=report,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _review_clips(clips: tuple[dict[str, Any], ...], *, state: str) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for clip in clips:
|
||||
fixture = _clip_fixture_state(str(clip["clip_id"]))
|
||||
start = int(clip["start_sequence"])
|
||||
end = int(clip["end_sequence"])
|
||||
rows.append(
|
||||
{
|
||||
"clip_id": clip["clip_id"],
|
||||
"start_sequence": start,
|
||||
"end_sequence": end,
|
||||
"review_state": state,
|
||||
"no_object": False,
|
||||
"tracklets": [
|
||||
{
|
||||
"object_id": "object-1",
|
||||
"first_sequence": start,
|
||||
"last_sequence": end,
|
||||
"keyframes": [
|
||||
{
|
||||
"sequence": start,
|
||||
"extent_xyxy": fixture["extent_xyxy"],
|
||||
"visibility": "visible",
|
||||
},
|
||||
{
|
||||
"sequence": end,
|
||||
"extent_xyxy": fixture["extent_xyxy"],
|
||||
"visibility": "partial",
|
||||
},
|
||||
],
|
||||
"state_segments": [
|
||||
{
|
||||
"start_sequence": start,
|
||||
"end_sequence": end,
|
||||
"geometry_association": fixture["geometry_association"],
|
||||
"freshness": "current",
|
||||
"motion": fixture["motion"],
|
||||
"threat": fixture["threat"],
|
||||
"critical_corridor_obstacle": True,
|
||||
}
|
||||
],
|
||||
"notes": None,
|
||||
}
|
||||
],
|
||||
"notes": None,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _review(pack: m48.M48ObjectQualityPack, reviewer_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": m48.M48_REVIEW_SCHEMA,
|
||||
"pack_id": pack.result_id,
|
||||
"state": "completed-independent-no-predictions",
|
||||
"reviewer_id": reviewer_id,
|
||||
"review_round": 1,
|
||||
"blindness": {
|
||||
"candidate_identity_seen": False,
|
||||
"model_predictions_seen": False,
|
||||
"model_scores_seen": False,
|
||||
"semantic_class_task_seen": False,
|
||||
},
|
||||
"clips": _review_clips(pack.clips, state="reviewed"),
|
||||
"acceptance": {
|
||||
"all_clips_reviewed": True,
|
||||
"independent": True,
|
||||
"submitted_at_utc": "2026-08-24T11:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_generations(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
unsafe_free_space: bool = False,
|
||||
unsafe_free_space_split: str | None = None,
|
||||
) -> tuple[m48.M48ObjectQualityPack, m48.M48ObjectTruthSeal]:
|
||||
_fake_m47(tmp_path, monkeypatch)
|
||||
clips = _clips()
|
||||
pack = m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=_prediction_rows(
|
||||
clips,
|
||||
unsafe_free_space=unsafe_free_space,
|
||||
unsafe_free_space_split=unsafe_free_space_split,
|
||||
),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs",
|
||||
)
|
||||
review_a = _review(pack, "reviewer-a")
|
||||
review_b = _review(pack, "reviewer-b")
|
||||
review_a_path = tmp_path / "review-a.json"
|
||||
review_b_path = tmp_path / "review-b.json"
|
||||
_write_json(review_a_path, review_a)
|
||||
_write_json(review_b_path, review_b)
|
||||
adjudication = {
|
||||
"schema_version": m48.M48_ADJUDICATION_SCHEMA,
|
||||
"pack_id": pack.result_id,
|
||||
"state": "completed-adjudicated",
|
||||
"adjudicator_id": "adjudicator-1",
|
||||
"review_submission_sha256": sorted(
|
||||
(m48._canonical_sha256(review_a), m48._canonical_sha256(review_b))
|
||||
),
|
||||
"clips": _review_clips(pack.clips, state="adjudicated"),
|
||||
"acceptance": {
|
||||
"all_clips_adjudicated": True,
|
||||
"all_disagreements_resolved": True,
|
||||
"sealed_at_utc": "2026-08-24T12:00:00Z",
|
||||
},
|
||||
}
|
||||
adjudication_path = tmp_path / "adjudication.json"
|
||||
_write_json(adjudication_path, adjudication)
|
||||
truth = m48.build_m48_object_truth_seal(
|
||||
pack_root=pack.result_root,
|
||||
reviewer_a_path=review_a_path,
|
||||
reviewer_b_path=review_b_path,
|
||||
adjudication_path=adjudication_path,
|
||||
output_root=tmp_path / "truth",
|
||||
)
|
||||
return pack, truth
|
||||
|
||||
|
||||
def test_m48_pack_is_neutral_tracklet_review_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack, truth = _build_generations(tmp_path, monkeypatch)
|
||||
|
||||
reviewer_package = json.loads(
|
||||
(pack.result_root / "reviewer-package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert reviewer_package["strata_included"] is False
|
||||
assert reviewer_package["frozen_predictions_included"] is False
|
||||
assert all(
|
||||
"strata" not in clip and "selection_hypotheses" not in clip
|
||||
for clip in reviewer_package["clips"]
|
||||
)
|
||||
assert {
|
||||
hypothesis
|
||||
for clip in pack.clips
|
||||
if clip["split"] == "validation"
|
||||
for hypothesis in clip["selection_hypotheses"]
|
||||
} == set(pack.manifest["identity"]["profile"]["required_validation_hypotheses"])
|
||||
review_template = json.loads(
|
||||
(pack.result_root / "review-template.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert "clips" in review_template and "frames" not in review_template
|
||||
assert "tracklets" in review_template["clips"][0]
|
||||
assert len(truth.truth_rows) == len(pack.frame_references)
|
||||
assert truth.truth_rows[0]["objects"][0]["visibility"] == "visible"
|
||||
assert truth.truth_rows[50]["objects"][0]["visibility"] == "partial"
|
||||
|
||||
|
||||
def test_m48_perfect_class_free_result_passes_all_gates_and_registry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack, truth = _build_generations(tmp_path, monkeypatch)
|
||||
result = m48.score_m48_object_quality(
|
||||
pack_root=pack.result_root,
|
||||
truth_seal_root=truth.result_root,
|
||||
output_root=tmp_path / "results",
|
||||
)
|
||||
|
||||
assert result.report["acceptance"]["accepted"] is True
|
||||
assert all(result.report["acceptance"]["gates"].values())
|
||||
assert result.report["acceptance"]["scope"] == "validation-only"
|
||||
assert result.report["metrics"] == result.report["metrics_by_split"]["validation"]
|
||||
assert result.report["method"]["semantic_class_scored"] is False
|
||||
assert result.report["decision"]["next_gate"] == ("m4.9-recorded-realtime-release-candidate")
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
registry = LaboratoryEvidenceRegistry.from_directory(repository_root / "config/laboratories")
|
||||
definitions = {definition.work_id: definition for definition in registry.definitions}
|
||||
pack_proof = verify_laboratory_evidence_result(
|
||||
definitions["m48-object-centric-quality"], pack.result_root
|
||||
)
|
||||
result_proof = verify_laboratory_evidence_result(
|
||||
definitions["m48-object-centric-quality"], result.result_root
|
||||
)
|
||||
assert pack_proof["artifact_count"] == 7
|
||||
assert result_proof["artifact_count"] == 3
|
||||
|
||||
|
||||
def test_m48_review_rejects_semantic_class_and_same_reviewer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_fake_m47(tmp_path, monkeypatch)
|
||||
clips = _clips()
|
||||
pack = m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=_prediction_rows(clips, unsafe_free_space=False),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs",
|
||||
)
|
||||
review_a = _review(pack, "reviewer-a")
|
||||
review_a["clips"][0]["tracklets"][0]["category"] = "car"
|
||||
review_a_path = tmp_path / "review-a.json"
|
||||
_write_json(review_a_path, review_a)
|
||||
with pytest.raises(m48.M48ObjectQualityError, match="fields"):
|
||||
m48.validate_m48_review_submission(pack_root=pack.result_root, review_path=review_a_path)
|
||||
|
||||
review_a = _review(pack, "reviewer-a")
|
||||
review_b = copy.deepcopy(review_a)
|
||||
review_a_path = tmp_path / "review-a-clean.json"
|
||||
review_b_path = tmp_path / "review-b-same.json"
|
||||
_write_json(review_a_path, review_a)
|
||||
_write_json(review_b_path, review_b)
|
||||
adjudication = {
|
||||
"schema_version": m48.M48_ADJUDICATION_SCHEMA,
|
||||
"pack_id": pack.result_id,
|
||||
"state": "completed-adjudicated",
|
||||
"adjudicator_id": "adjudicator-1",
|
||||
"review_submission_sha256": [m48._canonical_sha256(review_a)] * 2,
|
||||
"clips": _review_clips(pack.clips, state="adjudicated"),
|
||||
"acceptance": {
|
||||
"all_clips_adjudicated": True,
|
||||
"all_disagreements_resolved": True,
|
||||
"sealed_at_utc": "2026-08-24T12:00:00Z",
|
||||
},
|
||||
}
|
||||
adjudication_path = tmp_path / "adjudication.json"
|
||||
_write_json(adjudication_path, adjudication)
|
||||
with pytest.raises(m48.M48ObjectQualityError, match="must differ"):
|
||||
m48.build_m48_object_truth_seal(
|
||||
pack_root=pack.result_root,
|
||||
reviewer_a_path=review_a_path,
|
||||
reviewer_b_path=review_b_path,
|
||||
adjudication_path=adjudication_path,
|
||||
output_root=tmp_path / "truth",
|
||||
)
|
||||
|
||||
|
||||
def test_m48_unsafe_free_space_fails_with_bounded_atlas(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack, truth = _build_generations(tmp_path, monkeypatch, unsafe_free_space=True)
|
||||
result = m48.score_m48_object_quality(
|
||||
pack_root=pack.result_root,
|
||||
truth_seal_root=truth.result_root,
|
||||
output_root=tmp_path / "results",
|
||||
)
|
||||
|
||||
assert result.report["acceptance"]["accepted"] is False
|
||||
assert result.report["acceptance"]["gates"]["false_free_space_claims"] is False
|
||||
assert result.report["acceptance"]["gates"]["critical_corridor_obstacle_recall"] is True
|
||||
assert result.failure_atlas
|
||||
assert any("false-free-space-claim" in row["causes"] for row in result.failure_atlas)
|
||||
assert result.report["decision"]["next_gate"] == (
|
||||
"bounded-cause-remediation-on-failed-m48-clusters"
|
||||
)
|
||||
|
||||
|
||||
def test_m48_development_failures_are_reported_but_cannot_fail_release(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack, truth = _build_generations(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
unsafe_free_space=True,
|
||||
unsafe_free_space_split="development",
|
||||
)
|
||||
result = m48.score_m48_object_quality(
|
||||
pack_root=pack.result_root,
|
||||
truth_seal_root=truth.result_root,
|
||||
output_root=tmp_path / "results",
|
||||
)
|
||||
|
||||
assert result.report["acceptance"]["accepted"] is True
|
||||
assert result.report["metrics_by_split"]["development"]["false_free_space_claims"] > 0
|
||||
assert result.report["metrics"]["false_free_space_claims"] == 0
|
||||
assert all(result.report["acceptance"]["gates"].values())
|
||||
assert any(row["split"] == "development" for row in result.failure_atlas)
|
||||
|
||||
|
||||
def test_m48_rejects_incomplete_clip_contract(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_fake_m47(tmp_path, monkeypatch)
|
||||
clips = _clips()[:19]
|
||||
with pytest.raises(m48.M48ObjectQualityError, match="20–30"):
|
||||
m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=_prediction_rows(clips, unsafe_free_space=False),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs",
|
||||
)
|
||||
|
||||
|
||||
def test_m48_rejects_cross_split_and_vacuous_grouping(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_fake_m47(tmp_path, monkeypatch)
|
||||
clips = _clips()
|
||||
clips[10]["route_block"] = clips[0]["route_block"]
|
||||
with pytest.raises(m48.M48ObjectQualityError, match="route_block crosses"):
|
||||
m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=_prediction_rows(clips, unsafe_free_space=False),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs-cross-split",
|
||||
)
|
||||
|
||||
clips = _clips()
|
||||
for index, clip in enumerate(clips):
|
||||
clip["component_id"] = f"unique-component-{index:02d}"
|
||||
with pytest.raises(m48.M48ObjectQualityError, match="non-vacuous"):
|
||||
m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=_prediction_rows(clips, unsafe_free_space=False),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs-vacuous",
|
||||
)
|
||||
|
||||
|
||||
def test_m48_profile_config_matches_executable_contract() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
document = json.loads(
|
||||
(repository_root / "config/perception/m48-object-quality-v1.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
profile = m48.DEFAULT_M48_OBJECT_QUALITY_PROFILE
|
||||
|
||||
assert document["schema_version"] == m48.M48_PROFILE_SCHEMA
|
||||
assert document["profile_id"] == profile.profile_id
|
||||
assert document["clip_contract"]["minimum_clip_count"] == profile.minimum_clip_count
|
||||
assert document["clip_contract"]["maximum_clip_count"] == profile.maximum_clip_count
|
||||
assert document["review_contract"]["review_unit"] == "clip-local-object-tracklet"
|
||||
assert document["review_contract"]["semantic_class_labels_allowed"] is False
|
||||
assert document["review_contract"]["selection_hypotheses_visible_to_reviewers"] is False
|
||||
assert document["clip_contract"]["release_gate_split"] == "validation"
|
||||
assert document["clip_contract"]["required_validation_hypotheses"] == sorted(
|
||||
m48.DEFAULT_M48_OBJECT_QUALITY_PROFILE.to_dict()["required_validation_hypotheses"]
|
||||
)
|
||||
assert document["release_thresholds"]["obstacle_presence_precision"] == (
|
||||
profile.obstacle_presence_precision
|
||||
)
|
||||
assert document["release_thresholds"]["critical_corridor_obstacle_recall"] == (
|
||||
profile.critical_corridor_obstacle_recall
|
||||
)
|
||||
|
||||
|
||||
def test_m48_pack_identity_is_stable_across_clip_and_prediction_input_order(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_fake_m47(tmp_path, monkeypatch)
|
||||
clips = _clips()
|
||||
predictions = _prediction_rows(clips, unsafe_free_space=False)
|
||||
first = m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=predictions,
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs-a",
|
||||
)
|
||||
second = m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=reversed(clips),
|
||||
predictions=reversed(predictions),
|
||||
preparation_provenance=_preparation_provenance(),
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs-b",
|
||||
)
|
||||
|
||||
assert first.result_id == second.result_id
|
||||
assert first.manifest["identity_sha256"] == second.manifest["identity_sha256"]
|
||||
|
||||
changed_provenance = _preparation_provenance()
|
||||
changed_provenance["camera_index"]["sha256"] = "d" * 64
|
||||
third = m48.build_m48_object_quality_pack(
|
||||
m47_lab_root=tmp_path / "ignored-m47",
|
||||
frame_catalog=_frame_catalog(),
|
||||
clips=clips,
|
||||
predictions=predictions,
|
||||
preparation_provenance=changed_provenance,
|
||||
frozen_at_utc="2026-08-24T10:00:00Z",
|
||||
output_root=tmp_path / "packs-c",
|
||||
)
|
||||
assert third.result_id != first.result_id
|
||||
assert third.manifest["identity"]["preparation"]["camera_index"]["sha256"] == ("d" * 64)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from itertools import pairwise
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from k1link.laboratory.m47_reference_graph import M47_REFERENCE_GRAPH_LAB_SCHEMA
|
||||
from k1link.laboratory.m48_object_quality import read_m48_object_quality_pack
|
||||
from k1link.laboratory.m48_ravnoves00_pack import (
|
||||
M48_FRAME_COUNT,
|
||||
M48_SELECTION_SCHEMA,
|
||||
_prediction_objects,
|
||||
prepare_m48_ravnoves00_pack,
|
||||
)
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_text(_canonical_json(value) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> str:
|
||||
raw = "".join(_canonical_json(row) + "\n" for row in rows).encode()
|
||||
path.write_bytes(raw)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _recursive_keys(value: object) -> set[str]:
|
||||
if isinstance(value, dict):
|
||||
return set(value) | {key for item in value.values() for key in _recursive_keys(item)}
|
||||
if isinstance(value, list):
|
||||
return {key for item in value for key in _recursive_keys(item)}
|
||||
return set()
|
||||
|
||||
|
||||
def test_prediction_projection_is_class_free_and_conservative() -> None:
|
||||
rows = _prediction_objects(
|
||||
[
|
||||
{
|
||||
"proposal_id": "proposal-0-1",
|
||||
"bbox_xyxy": [80.0, 60.0, 400.0, 300.0],
|
||||
"occupied_support": False,
|
||||
"threat_decision": "unknown",
|
||||
"semantic_hint": "person",
|
||||
"objectness": 0.99,
|
||||
},
|
||||
{
|
||||
"proposal_id": "proposal-0-2",
|
||||
"bbox_xyxy": [400.0, 300.0, 720.0, 540.0],
|
||||
"occupied_support": True,
|
||||
"threat_decision": "threat",
|
||||
"semantic_hint": "car",
|
||||
"objectness": 0.98,
|
||||
},
|
||||
],
|
||||
geometry_observations=[
|
||||
{
|
||||
"proposal_ids": ["proposal-0-1"],
|
||||
"currentness": "current",
|
||||
"metric_geometry": None,
|
||||
},
|
||||
{
|
||||
"proposal_ids": ["proposal-0-2"],
|
||||
"currentness": "current",
|
||||
"metric_geometry": {"centroid_xyz_m": [1.0, 2.0, 3.0]},
|
||||
},
|
||||
],
|
||||
metric_obstacles=[
|
||||
{
|
||||
"centroid_map_xyz_m": [1.0, 2.0, 3.0],
|
||||
"motion": "moving",
|
||||
"assessment": {"decision": "threat"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert rows == [
|
||||
{
|
||||
"prediction_id": "proposal-0-1",
|
||||
"extent_xyxy": [0.1, 0.1, 0.5, 0.5],
|
||||
"geometry_association": "unknown",
|
||||
"freshness": "current",
|
||||
"motion": "unsupported",
|
||||
"threat": "unknown",
|
||||
"unknown_causes": [
|
||||
"insufficient-geometry-support",
|
||||
"threat-evidence-insufficient",
|
||||
],
|
||||
},
|
||||
{
|
||||
"prediction_id": "proposal-0-2",
|
||||
"extent_xyxy": [0.5, 0.5, 0.9, 0.9],
|
||||
"geometry_association": "associated",
|
||||
"freshness": "current",
|
||||
"motion": "moving",
|
||||
"threat": "threat",
|
||||
"unknown_causes": [],
|
||||
},
|
||||
]
|
||||
assert "semantic_hint" not in _canonical_json(rows)
|
||||
assert "objectness" not in _canonical_json(rows)
|
||||
|
||||
|
||||
def test_real_selection_contract_is_balanced_and_prediction_blind() -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
document = json.loads(
|
||||
(repository_root / "config/perception/m48-object-quality-selection-v1.json").read_text()
|
||||
)
|
||||
clips = document["clips"]
|
||||
|
||||
assert document["schema_version"] == M48_SELECTION_SCHEMA
|
||||
assert len(clips) == 24
|
||||
assert {clip["split"] for clip in clips} == {"development", "validation"}
|
||||
assert all("strata" not in clip and "hypotheses" not in clip for clip in clips)
|
||||
assert document["selection_hypothesis_profile"] == {
|
||||
"derivation": "exact-frozen-prediction-rows-before-independent-truth",
|
||||
"small_obstacle_max_normalized_area": 0.001,
|
||||
"fisheye_edge_margin_normalized": 0.08,
|
||||
"sparse_scene_max_median_prediction_count": 2.0,
|
||||
}
|
||||
for field in ("component_id", "route_block", "time_block"):
|
||||
group_splits: dict[str, set[str]] = {}
|
||||
for clip in clips:
|
||||
group_splits.setdefault(clip[field], set()).add(clip["split"])
|
||||
assert all(len(splits) == 1 for splits in group_splits.values())
|
||||
assert len(group_splits) < len(clips)
|
||||
assert all(left["end_sequence"] < right["start_sequence"] for left, right in pairwise(clips))
|
||||
forbidden = {"label", "labels", "truth", "review", "adjudication"}
|
||||
assert forbidden.isdisjoint(document)
|
||||
|
||||
|
||||
def test_prepare_pack_binds_all_source_ledgers_and_freezes_selected_frames(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
repository_root = Path(__file__).resolve().parents[1]
|
||||
graph_root = tmp_path / ("m47-reference-graph-" + "a" * 64)
|
||||
threat_root = tmp_path / ("m4-threat-replay-" + "b" * 64)
|
||||
geometry_root = tmp_path / ("m4-geometry-replay-" + "e" * 64)
|
||||
lab_root = tmp_path / ("m47-reference-graph-lab-" + "c" * 64)
|
||||
graph_root.mkdir()
|
||||
threat_root.mkdir()
|
||||
geometry_root.mkdir()
|
||||
lab_root.mkdir()
|
||||
_write_json(lab_root / "manifest.json", {"fixture": True})
|
||||
|
||||
graph_rows: list[dict[str, object]] = []
|
||||
threat_rows: list[dict[str, object]] = []
|
||||
geometry_rows: list[dict[str, object]] = []
|
||||
camera_rows: list[dict[str, object]] = []
|
||||
for frame_index in range(M48_FRAME_COUNT):
|
||||
source_time_ns = 35_421_857_292 + frame_index * 100_000_000
|
||||
graph_rows.append(
|
||||
{
|
||||
"sequence": frame_index,
|
||||
"obstacle_map": {
|
||||
"schema_version": "missioncore.local-obstacle-map/v1",
|
||||
"frame_id": f"frame-{frame_index:06d}",
|
||||
"free_space_claimed": False,
|
||||
},
|
||||
"threats": [],
|
||||
}
|
||||
)
|
||||
threat_rows.append(
|
||||
{
|
||||
"schema_version": "missioncore.perception-threat-replay-frame/v2",
|
||||
"sequence": frame_index,
|
||||
"frame_id": f"frame-{frame_index:06d}",
|
||||
"source_time_ns": source_time_ns,
|
||||
"source_available": True,
|
||||
"camera_proposals": [
|
||||
{
|
||||
"proposal_id": f"proposal-{frame_index}-0",
|
||||
"bbox_xyxy": [0.0, 0.0, 20.0, 20.0],
|
||||
"occupied_support": True,
|
||||
"threat_decision": "threat" if frame_index % 2 == 0 else "not-threat",
|
||||
},
|
||||
{
|
||||
"proposal_id": f"proposal-{frame_index}-1",
|
||||
"bbox_xyxy": [80.0, 60.0, 400.0, 300.0],
|
||||
"occupied_support": False,
|
||||
"threat_decision": "unknown",
|
||||
},
|
||||
],
|
||||
"metric_obstacles": [
|
||||
{
|
||||
"centroid_map_xyz_m": [1.0, 2.0, 3.0],
|
||||
"motion": "moving" if frame_index % 2 == 0 else "stationary",
|
||||
"assessment": {
|
||||
"decision": "threat" if frame_index % 2 == 0 else "not-threat"
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
geometry_rows.append(
|
||||
{
|
||||
"schema_version": "missioncore.perception-geometry-replay-frame/v1",
|
||||
"sequence": frame_index,
|
||||
"frame_id": f"frame-{frame_index:06d}",
|
||||
"source_available": True,
|
||||
"observations": [
|
||||
{
|
||||
"proposal_ids": [f"proposal-{frame_index}-0"],
|
||||
"currentness": "current",
|
||||
"metric_geometry": {"centroid_xyz_m": [1.0, 2.0, 3.0]},
|
||||
},
|
||||
{
|
||||
"proposal_ids": [f"proposal-{frame_index}-1"],
|
||||
"currentness": "current",
|
||||
"metric_geometry": None,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
camera_rows.append(
|
||||
{
|
||||
"schema_version": "missioncore.camera-recording-index/v1",
|
||||
"sequence": frame_index + 1,
|
||||
"kind": "media",
|
||||
"session_monotonic_ns": frame_index + 1,
|
||||
"sha256": hashlib.sha256(f"camera-{frame_index}".encode()).hexdigest(),
|
||||
}
|
||||
)
|
||||
graph_sha256 = _write_jsonl(graph_root / "frames.jsonl", graph_rows)
|
||||
threat_sha256 = _write_jsonl(threat_root / "frames.jsonl", threat_rows)
|
||||
geometry_sha256 = _write_jsonl(geometry_root / "frames.jsonl", geometry_rows)
|
||||
camera_index = tmp_path / "index.jsonl"
|
||||
_write_jsonl(camera_index, camera_rows)
|
||||
_write_json(
|
||||
graph_root / "manifest.json",
|
||||
{
|
||||
"schema_version": "missioncore.reference-perception-graph-manifest/v1",
|
||||
"result_id": graph_root.name,
|
||||
"accepted": True,
|
||||
"graph_id": "reference-perception-graph/v2",
|
||||
"run_mode": "lossless-replay",
|
||||
"files": {
|
||||
"frames.jsonl": {
|
||||
"bytes": (graph_root / "frames.jsonl").stat().st_size,
|
||||
"sha256": graph_sha256,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
_write_json(
|
||||
threat_root / "manifest.json",
|
||||
{
|
||||
"schema_version": "missioncore.perception-threat-replay-result/v2",
|
||||
"result_id": threat_root.name,
|
||||
"accepted": True,
|
||||
"identity": {
|
||||
"source_session_id": "20260720T065719Z_viewer_live",
|
||||
"frames_sha256": threat_sha256,
|
||||
"geometry_result_id": geometry_root.name,
|
||||
"geometry_frames_sha256": geometry_sha256,
|
||||
},
|
||||
},
|
||||
)
|
||||
_write_json(
|
||||
geometry_root / "manifest.json",
|
||||
{
|
||||
"schema_version": "missioncore.perception-geometry-replay-result/v1",
|
||||
"identity": {
|
||||
"accepted": True,
|
||||
"source_pack_id": (
|
||||
"e10-lidar-pack-"
|
||||
"576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b"
|
||||
),
|
||||
"frames_sha256": geometry_sha256,
|
||||
},
|
||||
},
|
||||
)
|
||||
authority = {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
lab = SimpleNamespace(
|
||||
result_id=lab_root.name,
|
||||
result_root=lab_root,
|
||||
manifest={
|
||||
"schema_version": M47_REFERENCE_GRAPH_LAB_SCHEMA,
|
||||
"accepted": True,
|
||||
"ground_truth": False,
|
||||
},
|
||||
report={
|
||||
"source": {
|
||||
"graph_result_id": graph_root.name,
|
||||
"visual_result_id": threat_root.name,
|
||||
"threat_frames_sha256": threat_sha256,
|
||||
"source_id": "RAVNOVES00",
|
||||
"source_session_id": "20260720T065719Z_viewer_live",
|
||||
},
|
||||
"method": {
|
||||
"graph_id": "reference-perception-graph/v2",
|
||||
"run_mode": "lossless-replay",
|
||||
"canonical_payload_sha256": "d" * 64,
|
||||
},
|
||||
"decision": {
|
||||
"state": "accepted-reference-graph-replay",
|
||||
"next_gate": "independent-object-centric-detection-quality",
|
||||
},
|
||||
"acceptance": {"accepted": True},
|
||||
"authority": authority,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"k1link.laboratory.m48_ravnoves00_pack.read_m47_reference_graph_lab",
|
||||
lambda _: lab,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"k1link.laboratory.m48_object_quality.read_m47_reference_graph_lab",
|
||||
lambda _: lab,
|
||||
)
|
||||
|
||||
result = prepare_m48_ravnoves00_pack(
|
||||
m47_lab_root=lab_root,
|
||||
graph_result_root=graph_root,
|
||||
threat_result_root=threat_root,
|
||||
geometry_result_root=geometry_root,
|
||||
camera_index_path=camera_index,
|
||||
selection_path=(repository_root / "config/perception/m48-object-quality-selection-v1.json"),
|
||||
frozen_at_utc="2026-08-24T00:00:00Z",
|
||||
output_root=tmp_path / "runtime/m48/object-quality-packs",
|
||||
)
|
||||
|
||||
assert read_m48_object_quality_pack(result.result_root) == result
|
||||
assert result.report["metrics"]["clip_count"] == 24
|
||||
assert result.report["metrics"]["frame_count"] == 24 * 61
|
||||
assert len(result.predictions) == 24 * 61
|
||||
assert result.manifest["identity"]["preparation"]["adapter"]["sha256"] == (
|
||||
hashlib.sha256(
|
||||
(repository_root / "src/k1link/laboratory/m48_ravnoves00_pack.py").read_bytes()
|
||||
).hexdigest()
|
||||
)
|
||||
assert result.manifest["identity"]["preparation"]["selection"]["sha256"] == (
|
||||
hashlib.sha256(
|
||||
(
|
||||
repository_root / "config/perception/m48-object-quality-selection-v1.json"
|
||||
).read_bytes()
|
||||
).hexdigest()
|
||||
)
|
||||
reviewer_package = json.loads((result.result_root / "reviewer-package.json").read_text())
|
||||
reviewer_keys = _recursive_keys(reviewer_package)
|
||||
assert "strata" not in reviewer_keys
|
||||
assert "prediction_id" not in reviewer_keys
|
||||
assert "semantic_hint" not in reviewer_keys
|
||||
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.laboratory import m48_raw_evidence as raw_module
|
||||
from k1link.laboratory.m48_object_quality import M48ObjectQualityPack
|
||||
from k1link.laboratory.m48_raw_evidence import (
|
||||
M48_EXPECTED_FRAME_COUNT,
|
||||
M48_EXPECTED_SESSION_ID,
|
||||
M48_EXPECTED_SOURCE_ID,
|
||||
M48_RAW_SPATIAL_FRAME_SCHEMA,
|
||||
M48RawEvidenceError,
|
||||
M48RawEvidenceReader,
|
||||
)
|
||||
from k1link.perception.geometry import RecordedFrameTemporalBinding
|
||||
from k1link.perception.threat import ReplayBodyFrame, load_replay_threat_profile
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m4-replay-threat-v3.json"
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _sealed_e10_pack(tmp_path: Path) -> tuple[Path, str, str]:
|
||||
payload = b"sealed-e10-lidar-pack"
|
||||
artifact_sha256 = hashlib.sha256(payload).hexdigest()
|
||||
identity = {
|
||||
"available_lidar_frames": 3928,
|
||||
"calibration_sha256": "1" * 64,
|
||||
"camera_slot": "camera_1",
|
||||
"e6_profile_sha256": "2" * 64,
|
||||
"e6_result_id": "e6-fixture",
|
||||
"frame_count": M48_EXPECTED_FRAME_COUNT,
|
||||
"input_sha256": "3" * 64,
|
||||
"job_id": "recorded-camera-fixture",
|
||||
"point_count": 5,
|
||||
"producer_sha256": "4" * 64,
|
||||
"projection": {
|
||||
"height": 600,
|
||||
"model": "kb4",
|
||||
"source_coordinates": "k1-map",
|
||||
"target_camera": "sensor.camera.right",
|
||||
"width": 800,
|
||||
},
|
||||
"schema_version": "missioncore.e10-lidar-replay-pack/v1",
|
||||
"semantic_timeline_result_id": "result-fixture",
|
||||
"session_id": M48_EXPECTED_SESSION_ID,
|
||||
"source_end_frame_index": M48_EXPECTED_FRAME_COUNT - 1,
|
||||
"source_id": "sensor.camera.right",
|
||||
"source_start_frame_index": 0,
|
||||
"temporal_binding": "accepted-e6-nearest-host-arrival-best-effort",
|
||||
"temporal_policy": {
|
||||
"binding": "nearest-host-arrival-best-effort",
|
||||
"clock_source": "recorded-host-monotonic-arrival",
|
||||
"maximum_lidar_camera_delta_ms": 100.0,
|
||||
"maximum_pose_point_delta_ms": 100.0,
|
||||
},
|
||||
"timeline_end_seconds": 484.0,
|
||||
"timeline_start_seconds": 35.0,
|
||||
}
|
||||
identity_sha256 = _canonical_sha256(identity)
|
||||
pack_id = f"e10-lidar-pack-{identity_sha256}"
|
||||
root = tmp_path / pack_id
|
||||
root.mkdir()
|
||||
(root / "lidar-pack.npz").write_bytes(payload)
|
||||
manifest = {
|
||||
"artifact": {
|
||||
"byte_length": len(payload),
|
||||
"media_type": "application/x-npz",
|
||||
"path": "lidar-pack.npz",
|
||||
"sha256": artifact_sha256,
|
||||
},
|
||||
"classification": "private-recorded-sensor-replay-input",
|
||||
"created_at_utc": "2026-07-22T06:05:22.515Z",
|
||||
"ground_truth": False,
|
||||
"identity": identity,
|
||||
"identity_sha256": identity_sha256,
|
||||
"pack_id": pack_id,
|
||||
"schema_version": "missioncore.e10-lidar-replay-pack/v1",
|
||||
}
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root, pack_id, artifact_sha256
|
||||
|
||||
|
||||
def test_e10_pack_validation_binds_identity_path_length_and_sha256(tmp_path: Path) -> None:
|
||||
root, pack_id, artifact_sha256 = _sealed_e10_pack(tmp_path)
|
||||
|
||||
artifact = raw_module._validate_e10_pack(
|
||||
root,
|
||||
expected_pack_id=pack_id,
|
||||
expected_artifact_sha256=artifact_sha256,
|
||||
)
|
||||
|
||||
assert artifact == (root / "lidar-pack.npz").resolve()
|
||||
|
||||
(root / "lidar-pack.npz").write_bytes(b"tampered")
|
||||
with pytest.raises(M48RawEvidenceError, match="artifact content changed"):
|
||||
raw_module._validate_e10_pack(
|
||||
root,
|
||||
expected_pack_id=pack_id,
|
||||
expected_artifact_sha256=artifact_sha256,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
(lambda manifest: manifest["identity"].update(session_id="other"), "identity changed"),
|
||||
(
|
||||
lambda manifest: manifest["artifact"].update(path="../lidar-pack.npz"),
|
||||
"identity changed",
|
||||
),
|
||||
(lambda manifest: manifest.update(pack_id="e10-lidar-pack-wrong"), "identity changed"),
|
||||
],
|
||||
)
|
||||
def test_e10_pack_validation_rejects_manifest_escape(
|
||||
tmp_path: Path,
|
||||
mutation: object,
|
||||
message: str,
|
||||
) -> None:
|
||||
root, pack_id, artifact_sha256 = _sealed_e10_pack(tmp_path)
|
||||
manifest_path = root / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
assert callable(mutation)
|
||||
mutation(manifest)
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
with pytest.raises(M48RawEvidenceError, match=message):
|
||||
raw_module._validate_e10_pack(
|
||||
root,
|
||||
expected_pack_id=pack_id,
|
||||
expected_artifact_sha256=artifact_sha256,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Store:
|
||||
source_time_ns: int = 2_000_000_000
|
||||
source_available: bool = True
|
||||
|
||||
def temporal_binding_for_index(self, frame_index: int) -> RecordedFrameTemporalBinding:
|
||||
return RecordedFrameTemporalBinding(
|
||||
frame_index=frame_index,
|
||||
source_time_ns=self.source_time_ns,
|
||||
source_available=self.source_available,
|
||||
lidar_camera_delta_ms=1.0 if self.source_available else None,
|
||||
pose_point_delta_ms=1.0 if self.source_available else None,
|
||||
)
|
||||
|
||||
def current_points_for_frame(self, frame_index: int) -> np.ndarray:
|
||||
del frame_index
|
||||
return np.asarray(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
[4.0, 0.0, 0.0],
|
||||
[5.0, 0.0, 0.0],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BodyFrames:
|
||||
available: bool = True
|
||||
|
||||
def body_frame_for_frame(self, frame_id: str) -> ReplayBodyFrame | None:
|
||||
if not self.available:
|
||||
return None
|
||||
return ReplayBodyFrame(
|
||||
frame_id=frame_id,
|
||||
origin_map_xyz_m=(1.0, 0.0, 0.0),
|
||||
basis_map_from_body=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
||||
sensor_height_m=1.25,
|
||||
surface_slope_deg=0.0,
|
||||
forward_source="fixture",
|
||||
camera_forward_alignment_deg=0.0,
|
||||
)
|
||||
|
||||
|
||||
class _PredictionTrapPack:
|
||||
result_id = "m48-object-quality-pack-" + "a" * 64
|
||||
result_root = REPOSITORY_ROOT
|
||||
manifest = {
|
||||
"identity": {
|
||||
"source": {
|
||||
"source_id": M48_EXPECTED_SOURCE_ID,
|
||||
"source_session_id": M48_EXPECTED_SESSION_ID,
|
||||
}
|
||||
}
|
||||
}
|
||||
report: dict[str, object] = {}
|
||||
clips: tuple[dict[str, object], ...] = ()
|
||||
frame_references = (
|
||||
{
|
||||
"clip_id": "clip-01",
|
||||
"sequence": 2,
|
||||
"source_time_ns": 2_000_000_000,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def predictions(self) -> object:
|
||||
raise AssertionError("neutral raw reader opened frozen predictions")
|
||||
|
||||
|
||||
def _reader(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
store: _Store | None = None,
|
||||
body_frames: _BodyFrames | None = None,
|
||||
) -> M48RawEvidenceReader:
|
||||
profile = load_replay_threat_profile(PROFILE_PATH)
|
||||
timeline = SimpleNamespace(
|
||||
store=store or _Store(),
|
||||
body_frames=body_frames or _BodyFrames(),
|
||||
profile=profile,
|
||||
)
|
||||
threat = SimpleNamespace(
|
||||
result_id="m4-threat-replay-fixture",
|
||||
result_root=tmp_path,
|
||||
manifest={"identity": {"frames_sha256": "f" * 64}},
|
||||
)
|
||||
return M48RawEvidenceReader(
|
||||
repository_root=tmp_path,
|
||||
threat_result=threat,
|
||||
timeline=timeline,
|
||||
point_limit=2,
|
||||
)
|
||||
|
||||
|
||||
def test_raw_reader_is_one_based_bounded_body_frame_and_prediction_free(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None)
|
||||
reader = _reader(tmp_path)
|
||||
pack = cast(M48ObjectQualityPack, _PredictionTrapPack())
|
||||
|
||||
frame = reader(pack, 2)
|
||||
|
||||
assert set(frame) == {
|
||||
"schema_version",
|
||||
"pack_id",
|
||||
"clip_id",
|
||||
"sequence",
|
||||
"source_time_ns",
|
||||
"source_available",
|
||||
"body_frame_available",
|
||||
"point_cloud_body_xyz_m",
|
||||
"rig",
|
||||
"corridor",
|
||||
"occupied_voxel_size_m",
|
||||
"candidate_identity_included",
|
||||
"graph_boxes_ids_scores_included",
|
||||
"frozen_predictions_included",
|
||||
"strata_included",
|
||||
"authority",
|
||||
}
|
||||
assert frame["schema_version"] == M48_RAW_SPATIAL_FRAME_SCHEMA
|
||||
assert frame["clip_id"] == "clip-01"
|
||||
assert frame["sequence"] == 2
|
||||
assert frame["source_available"] is True
|
||||
assert frame["body_frame_available"] is True
|
||||
assert frame["point_cloud_body_xyz_m"] == [[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]]
|
||||
assert len(cast(list[object], frame["point_cloud_body_xyz_m"])) <= 2
|
||||
assert frame["candidate_identity_included"] is False
|
||||
assert frame["graph_boxes_ids_scores_included"] is False
|
||||
assert frame["frozen_predictions_included"] is False
|
||||
assert frame["strata_included"] is False
|
||||
assert "metric_obstacles" not in frame
|
||||
assert "camera_proposals" not in frame
|
||||
assert "decision_counts" not in frame
|
||||
assert "body_frame" not in frame
|
||||
|
||||
|
||||
def test_raw_reader_fails_closed_on_clip_or_source_time_escape(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None)
|
||||
pack = cast(M48ObjectQualityPack, _PredictionTrapPack())
|
||||
reader = _reader(tmp_path)
|
||||
|
||||
with pytest.raises(M48RawEvidenceError, match="outside the selected neutral clips"):
|
||||
reader(pack, 1)
|
||||
|
||||
mismatched = _reader(tmp_path, store=_Store(source_time_ns=2_000_000_001))
|
||||
with pytest.raises(M48RawEvidenceError, match="source time escaped"):
|
||||
mismatched(pack, 2)
|
||||
|
||||
|
||||
def test_raw_reader_emits_empty_cloud_when_body_frame_is_unavailable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(raw_module, "_validate_m47_pack_binding", lambda **_: None)
|
||||
reader = _reader(
|
||||
tmp_path,
|
||||
store=_Store(source_available=False),
|
||||
body_frames=_BodyFrames(available=False),
|
||||
)
|
||||
pack = cast(M48ObjectQualityPack, _PredictionTrapPack())
|
||||
|
||||
frame = reader.frame(pack=pack, sequence=2)
|
||||
|
||||
assert frame["source_available"] is False
|
||||
assert frame["body_frame_available"] is False
|
||||
assert frame["point_cloud_body_xyz_m"] == []
|
||||
assert isinstance(frame["rig"], dict)
|
||||
assert isinstance(frame["corridor"], dict)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("point_limit", [0, 4097, True])
|
||||
def test_raw_reader_rejects_unbounded_point_limits(
|
||||
tmp_path: Path,
|
||||
point_limit: int,
|
||||
) -> None:
|
||||
profile = load_replay_threat_profile(PROFILE_PATH)
|
||||
timeline = SimpleNamespace(store=_Store(), body_frames=_BodyFrames(), profile=profile)
|
||||
threat = SimpleNamespace(result_id="fixture", result_root=tmp_path, manifest={})
|
||||
|
||||
with pytest.raises(M48RawEvidenceError, match="point limit"):
|
||||
M48RawEvidenceReader(
|
||||
repository_root=tmp_path,
|
||||
threat_result=threat,
|
||||
timeline=timeline,
|
||||
point_limit=point_limit,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import k1link.laboratory.m48_small_static_regression as regression
|
||||
from k1link.laboratory.evidence_registry import LaboratoryEvidenceRegistry
|
||||
from k1link.laboratory.evidence_report import verify_laboratory_evidence_result
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTHORITY = {
|
||||
"mode": "replay-simulated",
|
||||
"physical_live": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.write_bytes(_canonical(value) + b"\n")
|
||||
|
||||
|
||||
def _correction(pack_id: str) -> dict[str, object]:
|
||||
def tracklet(object_id: str, extent: list[float], *, passage: bool) -> dict[str, object]:
|
||||
return {
|
||||
"object_id": object_id,
|
||||
"first_sequence": 10,
|
||||
"last_sequence": 10,
|
||||
"keyframes": [{
|
||||
"sequence": 10,
|
||||
"extent_xyxy": extent,
|
||||
"visibility": "visible",
|
||||
}],
|
||||
"state_segments": [{
|
||||
"start_sequence": 10,
|
||||
"end_sequence": 10,
|
||||
"geometry_association": "unknown",
|
||||
"freshness": "current",
|
||||
"motion": "static",
|
||||
"threat": "not-threat",
|
||||
"critical_corridor_obstacle": passage,
|
||||
}],
|
||||
"notes": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": "missioncore.m48-assisted-object-correction-session/v1",
|
||||
"pack_id": pack_id,
|
||||
"session_id": "m48-correction-session-" + "b" * 64,
|
||||
"title": "fixture",
|
||||
"revision": 7,
|
||||
"state": "saved",
|
||||
"created_at_utc": "2026-08-24T10:00:00Z",
|
||||
"updated_at_utc": "2026-08-24T11:00:00Z",
|
||||
"clips": [{
|
||||
"clip_id": "m48-clip-01",
|
||||
"start_sequence": 1,
|
||||
"end_sequence": 20,
|
||||
"review_state": "reviewed",
|
||||
"no_object": False,
|
||||
"tracklets": [
|
||||
tracklet("object-01", [0.1, 0.1, 0.2, 0.2], passage=True),
|
||||
tracklet("object-02", [0.7, 0.7, 0.8, 0.8], passage=False),
|
||||
{
|
||||
**tracklet("object-03", [0.3, 0.3, 0.4, 0.4], passage=True),
|
||||
"object_id": "proposal-10-0",
|
||||
},
|
||||
],
|
||||
"notes": None,
|
||||
}],
|
||||
"progress": {"reviewed_clip_count": 1, "clip_count": 1, "complete": True},
|
||||
"seed_summary": {
|
||||
"worker_id": "006",
|
||||
"clip_count": 1,
|
||||
"frame_count": 1,
|
||||
"object_count": 1,
|
||||
"prediction_rows_sha256": "c" * 64,
|
||||
},
|
||||
"evidence_summary": None,
|
||||
"assistance": {
|
||||
"mode": "frozen-candidate-seeded",
|
||||
"candidate_predictions_seen": True,
|
||||
"model_scores_seen": False,
|
||||
"semantic_class_task_seen": False,
|
||||
"independent_truth_eligible": False,
|
||||
},
|
||||
"authority": AUTHORITY,
|
||||
"last_save_idempotency_key": "save-7",
|
||||
"reviewer_id": None,
|
||||
"submitted_at_utc": None,
|
||||
"submission_sha256": None,
|
||||
"frozen_document_name": None,
|
||||
}
|
||||
|
||||
|
||||
def test_builds_separate_assisted_baseline_without_truth_claim(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack_id = "m48-object-quality-pack-" + "a" * 64
|
||||
pack_root = tmp_path / pack_id
|
||||
pack_root.mkdir()
|
||||
pack = SimpleNamespace(
|
||||
result_id=pack_id,
|
||||
result_root=pack_root,
|
||||
manifest={
|
||||
"identity_sha256": "a" * 64,
|
||||
"identity": {
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"source_session_id": "source-session",
|
||||
},
|
||||
"freeze": {"prediction_rows_sha256": "c" * 64},
|
||||
},
|
||||
},
|
||||
predictions=({
|
||||
"schema_version": "missioncore.m48-frozen-prediction-row/v1",
|
||||
"clip_id": "m48-clip-01",
|
||||
"sequence": 10,
|
||||
"source_time_ns": 100,
|
||||
"terminal_outcome": "delivered",
|
||||
"terminal_reason": None,
|
||||
"free_space_claimed": False,
|
||||
"objects": [{
|
||||
"prediction_id": "proposal-10-0",
|
||||
"extent_xyxy": [0.1, 0.1, 0.2, 0.2],
|
||||
"geometry_association": "unknown",
|
||||
"freshness": "current",
|
||||
"motion": "static",
|
||||
"threat": "not-threat",
|
||||
}],
|
||||
},),
|
||||
)
|
||||
monkeypatch.setattr(regression, "read_m48_object_quality_pack", lambda _: pack)
|
||||
correction_path = tmp_path / "correction.json"
|
||||
_write_json(correction_path, _correction(pack_id))
|
||||
profile_path = REPOSITORY_ROOT / "config/perception/m48-small-static-passage-regression-v1.json"
|
||||
|
||||
result = regression.build_m48_small_static_passage_regression(
|
||||
pack_root=pack_root,
|
||||
correction_session_path=correction_path,
|
||||
profile_path=profile_path,
|
||||
output_root=tmp_path / "results",
|
||||
run_created_at_utc="2026-08-24T12:00:00Z",
|
||||
)
|
||||
|
||||
assert result.report["metrics"]["assisted_anchor_count"] == 2
|
||||
assert result.report["metrics"]["worker_recalled_anchor_count"] == 1
|
||||
assert result.report["metrics"]["worker_missed_anchor_count"] == 1
|
||||
assert result.report["metrics"]["assisted_anchor_recall"] == 0.5
|
||||
assert result.manifest["accepted"] is False
|
||||
assert result.manifest["ground_truth"] is False
|
||||
assert result.manifest["identity"]["human_lab_id"] == "M4.8"
|
||||
assert result.manifest["identity"]["experiment_id"] == (
|
||||
"m48-small-static-passage-regression/v1"
|
||||
)
|
||||
assert result.report["method"]["execution_class"] == "deterministic"
|
||||
assert all(
|
||||
row["authority"] == "operator-assisted-development-anchor-not-truth"
|
||||
for row in result.anchors
|
||||
)
|
||||
|
||||
registry = LaboratoryEvidenceRegistry.from_directory(
|
||||
REPOSITORY_ROOT / "config/laboratories"
|
||||
)
|
||||
definition = next(
|
||||
row
|
||||
for row in registry.definitions
|
||||
if row.work_id == "m48-small-static-passage-regression"
|
||||
)
|
||||
proof = verify_laboratory_evidence_result(definition, result.result_root)
|
||||
assert proof["result_id"] == result.result_id
|
||||
assert proof["artifact_count"] == 3
|
||||
|
||||
|
||||
def test_reader_rejects_changed_comparison_artifact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pack_id = "m48-object-quality-pack-" + "a" * 64
|
||||
pack_root = tmp_path / pack_id
|
||||
pack_root.mkdir()
|
||||
pack = SimpleNamespace(
|
||||
result_id=pack_id,
|
||||
result_root=pack_root,
|
||||
manifest={
|
||||
"identity_sha256": "a" * 64,
|
||||
"identity": {
|
||||
"source": {"source_id": "RAVNOVES00", "source_session_id": "source"},
|
||||
"freeze": {"prediction_rows_sha256": "c" * 64},
|
||||
},
|
||||
},
|
||||
predictions=({
|
||||
"clip_id": "m48-clip-01",
|
||||
"sequence": 10,
|
||||
"source_time_ns": 100,
|
||||
"terminal_outcome": "delivered",
|
||||
"objects": [],
|
||||
},),
|
||||
)
|
||||
monkeypatch.setattr(regression, "read_m48_object_quality_pack", lambda _: pack)
|
||||
correction_path = tmp_path / "correction.json"
|
||||
document = _correction(pack_id)
|
||||
document["clips"][0]["tracklets"] = document["clips"][0]["tracklets"][:1]
|
||||
_write_json(correction_path, document)
|
||||
result = regression.build_m48_small_static_passage_regression(
|
||||
pack_root=pack_root,
|
||||
correction_session_path=correction_path,
|
||||
profile_path=(
|
||||
REPOSITORY_ROOT
|
||||
/ "config/perception/m48-small-static-passage-regression-v1.json"
|
||||
),
|
||||
output_root=tmp_path / "results",
|
||||
run_created_at_utc="2026-08-24T12:00:00Z",
|
||||
)
|
||||
comparison_path = result.result_root / "comparisons.jsonl"
|
||||
comparison_path.write_bytes(comparison_path.read_bytes() + b"{}\n")
|
||||
|
||||
with pytest.raises(
|
||||
regression.M48SmallStaticRegressionError,
|
||||
match="artifact proof",
|
||||
):
|
||||
regression.read_m48_small_static_passage_regression(result.result_root)
|
||||
Reference in New Issue
Block a user