diff --git a/apps/control-station/src/components/ObservationTimeline.tsx b/apps/control-station/src/components/ObservationTimeline.tsx index 4f2d2e3..f7e63e3 100644 --- a/apps/control-station/src/components/ObservationTimeline.tsx +++ b/apps/control-station/src/components/ObservationTimeline.tsx @@ -101,13 +101,14 @@ export function ObservationTimeline({ } onClick={() => onPlayingChange?.(!playing)} - > - {buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"} - + /> {buffered && playbackRate !== undefined && onPlaybackRateChange ? ( onPlaybackRateChange(Number(value))} /> diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 6d48de7..9e11dc2 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -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 diff --git a/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx index 559c95e..c6fba3c 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryEvidenceViewer.tsx @@ -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(null); @@ -80,6 +82,36 @@ export function LaboratoryEvidenceViewer< return () => window.removeEventListener("keydown", onKeyDown); }, [expanded, onExpandedChange]); + const controls = ( + + {actions} + {modeControlsVisible && secondaryMode ? ( + + ) : null} + {modeControlsVisible ? ( + + ) : null} + {trailingActions} + onExpandedChange(!expanded)} + > + + + + ); + const viewer = ( - - {children} - - {overlay} - {transport ? ( - - {transport} - - ) : null} - - {actions} - {modeControlsVisible && secondaryMode ? ( - - ) : null} - {modeControlsVisible ? ( - - ) : null} - {trailingActions} - onExpandedChange(!expanded)} - > - - - + {chromeLayout === "stacked" ? ( + <> + + + {overlay} + + {controls} + + + {children} + + {transport ? ( + + {transport} + + ) : null} + > + ) : ( + <> + + {children} + + {overlay} + {transport ? ( + + {transport} + + ) : null} + {controls} + > + )} ); diff --git a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx index fea30f1..99bcb4b 100644 --- a/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx +++ b/apps/control-station/src/components/laboratory/LaboratoryMetricEvidenceScene.tsx @@ -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 ( @@ -485,12 +546,9 @@ LaboratoryMetricEvidenceSceneHandle, {renderError ? {renderError} : null} - Угроза - Вне коридора - Неизвестно - Current increment - Local SLAM surface - Rolling-map occupied + {metricLegendEntries.map((entry) => ( + {entry.label} + ))} {semanticLegendEntries.map((entry) => ( { 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 ( - + - + ЛАБОРАТОРНАЯ РАБОТА {title} - {description} - {status} + + {status} + setExpanded((current) => !current)} + > + + + + + - - {facts.map((fact) => ( - - {fact.label} - {fact.value} - - ))} - - - - - Задача - {brief.question} - - - Как проверяли - {brief.approach} - - - Главный результат - {brief.principalResult} - - - Ограничение - {brief.limitation} - - - - {method ? ( - - - - МЕТОД - {method.pipelineId} + + {description} + + {facts.map((fact) => ( + + {fact.label} + {fact.value} - - {EXECUTION_LABELS[method.executionClass]} - {" · "} - {methodComplete ? "полная идентичность" : "legacy · частично"} - - - - {method.components.map((component, index) => ( - - {COMPONENT_LABELS[component.kind]} - - {component.name} - - {component.role} - {" · "} - {component.version} - {component.identitySha256 - ? ` · ${component.identitySha256.slice(0, 12)}` - : ""} - - + ))} + + + + + Задача + {brief.question} + + + Как проверяли + {brief.approach} + + + Главный результат + {brief.principalResult} + + + Ограничение + {brief.limitation} + + + + {method ? ( + + + + МЕТОД + {method.pipelineId} - ))} - - - ) : null} + + {EXECUTION_LABELS[method.executionClass]} + {" · "} + {methodComplete ? "полная идентичность" : "legacy · частично"} + + + + {method.components.map((component, index) => ( + + {COMPONENT_LABELS[component.kind]} + + {component.name} + + {component.role} + {" · "} + {component.version} + {component.identitySha256 + ? ` · ${component.identitySha256.slice(0, 12)}` + : ""} + + + + ))} + + + ) : null} + ); } diff --git a/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx new file mode 100644 index 0000000..fdb5c63 --- /dev/null +++ b/apps/control-station/src/components/laboratory/LaboratoryRecordedClipPlayer.tsx @@ -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; + 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(() => 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 = ( + + {cameraPresentation !== "primary" ? alternativeScene : null} + + ); + const cameraPane = ( + + {playback ? ( + onPlayingChange(false)} + /> + ) : null} + {cameraPresentation !== "hidden" ? cameraOverlay : null} + + ); + return ( + + + + + { + const nearest = nearestLaboratoryRecordedClipFrame(frames, timeNs); + if (nearest) emitSequence(nearest.sequence); + }} + showJumpToEnd={false} + /> + + ); +} diff --git a/apps/control-station/src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx b/apps/control-station/src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx new file mode 100644 index 0000000..5a4fca4 --- /dev/null +++ b/apps/control-station/src/components/laboratory/LaboratoryReviewWorkspaceFrame.tsx @@ -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, + frame: HTMLElement, +): void { + if (event.key !== "Tab") return; + const focusable = Array.from(frame.querySelectorAll(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(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(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( + { + if (!interactionEnabled) return; + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onCloseRef.current(); + return; + } + keepFocusInside(event, event.currentTarget); + }} + > + {toolbar} + {stage} + {inspector ? : null} + {overlays} + , + document.body, + ); +} diff --git a/apps/control-station/src/core/laboratory/advancedIndex.ts b/apps/control-station/src/core/laboratory/advancedIndex.ts index c5eaadc..a2772c5 100644 --- a/apps/control-station/src/core/laboratory/advancedIndex.ts +++ b/apps/control-station/src/core/laboratory/advancedIndex.ts @@ -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> = { + "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 { 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 не выбрана."); } diff --git a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts index 9ea4969..c5684fe 100644 --- a/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts +++ b/apps/control-station/src/core/laboratory/advancedLaboratoryResults.ts @@ -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; diff --git a/apps/control-station/src/core/laboratory/advancedResults.ts b/apps/control-station/src/core/laboratory/advancedResults.ts index 17ed3e4..27784da 100644 --- a/apps/control-station/src/core/laboratory/advancedResults.ts +++ b/apps/control-station/src/core/laboratory/advancedResults.ts @@ -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, diff --git a/apps/control-station/src/core/laboratory/m48ObjectCentricQuality.ts b/apps/control-station/src/core/laboratory/m48ObjectCentricQuality.ts new file mode 100644 index 0000000..e52ecb7 --- /dev/null +++ b/apps/control-station/src/core/laboratory/m48ObjectCentricQuality.ts @@ -0,0 +1,1007 @@ +import type { LaboratoryFetch } from "./advancedResults"; +import type { ObservationSourceDescriptor } from "../runtime/contracts"; + +export const M48_WORK_ID = "m48-object-centric-quality" as const; + +const PACK_ID = /^m48-object-quality-pack-[a-f0-9]{64}$/; +const RESULT_ID = /^m48-object-quality-result-[a-f0-9]{64}$/; +const TRUTH_ID = /^m48-object-truth-seal-[a-f0-9]{64}$/; +const REVIEW_ID = /^m48-review-session-[a-f0-9]{64}$/; +const CORRECTION_ID = /^m48-correction-session-[a-f0-9]{64}$/; +const ADJUDICATION_ID = /^m48-adjudication-session-[a-f0-9]{64}$/; +const RECEIPT_ID = /^laboratory-run-receipt-[a-f0-9]{64}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SAFE_URL = /^\/api\/v1\/laboratory\/m48\/[A-Za-z0-9/_?&=.%:-]+$/; +const SAFE_RECORDED_MEDIA_MANIFEST_URL = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/media\/[A-Za-z0-9._%-]+\/manifest$/; + +const FORBIDDEN_BLIND_KEYS = new Set([ + "class", + "class_id", + "class_name", + "candidate", + "candidate_id", + "detector", + "detector_id", + "frozen_predictions", + "graph", + "graph_object_id", + "model", + "model_id", + "model_revision", + "model_scores", + "prediction", + "prediction_id", + "predictions", + "score", + "scores", + "semantic_class", + "semantic_label", + "source_track_id", + "split", + "strata", + "stratum", + "world_track_id", +]); + +const BLIND_FALSE_CAPABILITY_KEYS = new Set([ + "graph_boxes_ids_scores", + "graph_output", + "raw_lidar", +]); + +const FORBIDDEN_BLIND_VALUES = new Set([ + "background-false-positive", + "fisheye-edge", + "moving-crossing", + "no-object", + "occupied-object", + "partial-occlusion", + "small-obstacle", + "static-obstacle", +]); + +export type M48Visibility = "visible" | "partial" | "occluded"; +export type M48GeometryAssociation = "associated" | "unavailable" | "ineligible" | "unknown"; +export type M48Freshness = "current" | "held" | "stale" | "unavailable"; +export type M48Motion = "moving" | "static" | "unknown" | "unsupported"; +export type M48Threat = "threat" | "not-threat" | "unknown"; + +export interface M48Authority { + mode: "replay-simulated"; + physicalLive: false; + commandsEnabled: false; + actuationAllowed: false; + navigationOrSafetyAccepted: false; +} + +export interface M48EvidenceCapabilities { + cameraEpochTime: boolean; + currentPointCloudBodyXyzM: boolean; + rig: boolean; + virtualCorridor: boolean; + obstaclePresenceAndExtent: boolean; + geometryAssociation: boolean; + freshness: boolean; + motion: boolean; + threat: boolean; + criticalCorridorObstacle: boolean; + state: string; + unavailableReason: string | null; +} + +export interface M48GateStatus { + kind: "review"; + packId: string; + createdAtUtc: string; + state: "prepared" | "two-reviewers-frozen" | "adjudication-frozen" | "evaluated"; + clipCount: number; + frameCount: number; + seedObjectCount: number; + correctionState: "not-started" | "draft" | "saved" | "frozen"; + correctionReviewedClipCount: number; + correctionComplete: boolean; + reviewSlotCount: number; + frozenReviewerCount: number; + requiredFrozenReviewerCount: 2; + reviewCollectionReady: boolean; + adjudicationUnlocked: boolean; + adjudicationFrozen: boolean; + evaluated: boolean; + truthSealId: string | null; + qualityResultId: string | null; + authority: M48Authority; +} + +export interface M48ReviewFrame { + sequence: number; + sourceTimeNs: number; + cameraFragmentSha256: string; + cameraUrl: string | null; + spatialUrl: string | null; +} + +export interface M48ReviewClipSource { + clipId: string; + ordinal: number; + startSequence: number; + endSequence: number; + frames: readonly M48ReviewFrame[]; +} + +export interface M48RecordedCameraPlayback { + schemaVersion: "missioncore.laboratory-recorded-clip-camera/v1"; + sourceId: string; + label: string; + manifestUrl: string; + manifestGenerationSha256: string; + byteLength: number; + timelineStartSeconds: number; + timelineEndSeconds: number; + segmentCount: number; +} + +export interface M48ReviewSourceCatalog { + packId: string; + cameraPlayback: M48RecordedCameraPlayback | null; + clips: readonly M48ReviewClipSource[]; + clipCount: number; + frameCount: number; + evidenceCapabilities: M48EvidenceCapabilities; +} + +export interface M48ReviewSpatialFrame { + packId: string; + clipId: string; + sequence: number; + sourceTimeNs: number; + sourceAvailable: boolean; + bodyFrameAvailable: boolean; + pointCloudBodyXyzM: readonly (readonly [number, number, number])[]; + rig: { lengthM: number; widthM: number; nominalSensorHeightM: number }; + corridor: { forwardLengthM: number; halfWidthM: number; rearMarginM: number }; + occupiedVoxelSizeM: number; +} + +export interface M48ReviewKeyframe { + sequence: number; + extentXyxy: readonly [number, number, number, number]; + visibility: M48Visibility; +} + +export interface M48ReviewStateSegment { + startSequence: number; + endSequence: number; + geometryAssociation: M48GeometryAssociation; + freshness: M48Freshness; + motion: M48Motion; + threat: M48Threat; + criticalCorridorObstacle: boolean; +} + +export interface M48ReviewTracklet { + objectId: string; + firstSequence: number; + lastSequence: number; + keyframes: readonly M48ReviewKeyframe[]; + stateSegments: readonly M48ReviewStateSegment[]; + notes: string | null; +} + +export interface M48ReviewClipDraft { + clipId: string; + startSequence: number; + endSequence: number; + reviewState: "pending" | "reviewed" | "adjudicated"; + noObject: boolean | null; + tracklets: readonly M48ReviewTracklet[]; + notes: string | null; +} + +export interface M48ReviewSession { + sessionId: string; + packId: string; + reviewerSlot: 1 | 2; + title: string; + state: "draft" | "saved" | "frozen"; + revision: number; + reviewedClipCount: number; + clipCount: number; + complete: boolean; + clips: readonly M48ReviewClipDraft[]; +} + +export interface M48CorrectionEvidenceSummary { + seedObjectCount: number; + correctedObjectCount: number; + confirmedCandidateCount: number; + unchangedCandidateCount: number; + modifiedCandidateCount: number; + falsePositiveRemovedCount: number; + missedObjectAddedCount: number; + candidateConfirmationRate: number; + assistedRecallProxy: number; + independentTruth: false; +} + +export interface M48CorrectionSession { + sessionId: string; + packId: string; + title: string; + state: "draft" | "saved" | "frozen"; + revision: number; + reviewedClipCount: number; + clipCount: number; + complete: boolean; + clips: readonly M48ReviewClipDraft[]; + seedWorkerId: "006"; + seedFrameCount: number; + seedObjectCount: number; + evidenceSummary: M48CorrectionEvidenceSummary | null; +} + +export interface M48AdjudicationReviewInput { + reviewerSlot: 1 | 2; + submissionSha256: string; + clips: readonly M48ReviewClipDraft[]; +} + +export interface M48AdjudicationSession { + sessionId: string; + packId: string; + title: string; + state: "draft" | "saved" | "adjudication-frozen" | "evaluated"; + revision: number; + resolvedClipCount: number; + clipCount: number; + complete: boolean; + clips: readonly M48ReviewClipDraft[]; + reviewInputs: readonly M48AdjudicationReviewInput[]; + truthSealId: string | null; + qualityResultId: string | null; + evaluationReceiptId: string | null; +} + +export interface M48QualityMetrics { + terminalOutcomeAccounting: number; + falseFreeSpaceClaims: number; + obstaclePresencePrecision: number; + obstaclePresenceRecall: number; + criticalCorridorObstacleRecall: number; + geometryAssociationCorrectness: number; + freshnessCorrectness: number; + motionDecisionCorrectness: number; + criticalNotThreatCount: number; + unknownPredictionCount: number; + failureCaseCount: number; +} + +export interface M48QualityResult { + kind: "result"; + resultId: string; + packId: string; + truthSealId: string; + createdAtUtc: string; + status: "accepted-object-centric-source-quality" | "failed-object-centric-source-quality"; + accepted: boolean; + metrics: M48QualityMetrics; + gates: Readonly>; + unknownCauses: Readonly>; + authority: M48Authority; +} + +export type M48AdvancedResult = M48GateStatus | M48QualityResult; + +export interface M48FailureCaseSummary { + caseId: string; + clipId: string; + split: "development" | "validation"; + sequence: number; + severity: "critical" | "high" | "medium"; + failures: readonly string[]; +} + +export interface M48FailureObject { + objectId: string; + extentXyxy: readonly [number, number, number, number]; + geometryAssociation: M48GeometryAssociation; + freshness: M48Freshness; + motion: M48Motion; + threat: M48Threat; +} + +export interface M48FailureCase extends M48FailureCaseSummary { + resultId: string; + frame: M48ReviewFrame; + truth: readonly M48FailureObject[]; + graph: readonly M48FailureObject[]; +} + +export class M48ContractError extends Error {} + +function objectValue(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new M48ContractError(`${label}: ожидался объект.`); + return value as Record; +} + +function arrayValue(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value)) throw new M48ContractError(`${label}: ожидался список.`); + return value; +} + +function keys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new M48ContractError(`${label}: состав полей изменился.`); + } +} + +function stringValue(value: unknown, label: string, maximum = 512): string { + if (typeof value !== "string" || !value.trim() || value.length > maximum) throw new M48ContractError(`${label}: ожидалась строка.`); + return value; +} + +function optionalString(value: unknown, label: string, maximum = 2_000): string | null { + return value === null ? null : stringValue(value, label, maximum); +} + +function integer(value: unknown, label: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) throw new M48ContractError(`${label}: ожидалось целое число.`); + return value as number; +} + +function numberValue(value: unknown, label: string, minimum = 0): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) throw new M48ContractError(`${label}: ожидалось конечное число.`); + return value; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new M48ContractError(`${label}: ожидался boolean.`); + return value; +} + +function enumValue(value: unknown, allowed: readonly T[], label: string): T { + if (typeof value !== "string" || !allowed.includes(value as T)) throw new M48ContractError(`${label}: неизвестное значение.`); + return value as T; +} + +function identifier(value: unknown, label: string): string { + const result = stringValue(value, label, 128); + if (!SAFE_ID.test(result)) throw new M48ContractError(`${label}: небезопасный id.`); + return result; +} + +function identity(value: unknown, pattern: RegExp, label: string): string { + const result = stringValue(value, label, 128); + if (!pattern.test(result)) throw new M48ContractError(`${label}: нарушена identity.`); + return result; +} + +function utc(value: unknown, label: string): string { + const result = stringValue(value, label, 64); + if (!result.endsWith("Z") || Number.isNaN(Date.parse(result))) throw new M48ContractError(`${label}: ожидался UTC timestamp.`); + return result; +} + +function safeUrl(value: unknown, label: string): string { + const result = stringValue(value, label, 1_000); + if (!SAFE_URL.test(result) || result.includes("..")) throw new M48ContractError(`${label}: разрешён только same-origin M4.8 URL.`); + return result; +} + +function recordedMediaManifestUrl(value: unknown, label: string): string { + const result = stringValue(value, label, 1_000); + if (!SAFE_RECORDED_MEDIA_MANIFEST_URL.test(result) || result.includes("..")) { + throw new M48ContractError(`${label}: разрешён только immutable recorded-media manifest.`); + } + return result; +} + +function exactSha(value: unknown, label: string): string { + const result = stringValue(value, label, 64); + if (!SHA256.test(result)) throw new M48ContractError(`${label}: ожидался sha256.`); + return result; +} + +export function assertM48BlindPayload(value: unknown, path = "blind"): void { + if (Array.isArray(value)) { + value.forEach((item, index) => assertM48BlindPayload(item, `${path}[${index}]`)); + return; + } + if (!value || typeof value !== "object") { + if (typeof value === "string" && FORBIDDEN_BLIND_VALUES.has(value.toLowerCase())) throw new M48ContractError(`${path}: раскрыт selection stratum.`); + return; + } + for (const [key, nested] of Object.entries(value as Record)) { + const normalized = key.toLowerCase(); + if (normalized.endsWith("_included")) { + if (nested !== false) throw new M48ContractError(`${path}.${key}: blind safety flag должен быть false.`); + continue; + } + if (BLIND_FALSE_CAPABILITY_KEYS.has(normalized)) { + if (nested !== false) throw new M48ContractError(`${path}.${key}: blind capability должен быть false.`); + continue; + } + if (FORBIDDEN_BLIND_KEYS.has(normalized) || /^(s|w)_?id$/.test(normalized)) { + throw new M48ContractError(`${path}.${key}: candidate material запрещён blind-контрактом.`); + } + assertM48BlindPayload(nested, `${path}.${key}`); + } +} + +function authority(value: unknown, label: string): M48Authority { + const item = objectValue(value, label); + keys(item, ["mode", "physical_live", "commands_enabled", "actuation_allowed", "navigation_or_safety_accepted"], label); + if (item.mode !== "replay-simulated" || item.physical_live !== false || item.commands_enabled !== false || item.actuation_allowed !== false || item.navigation_or_safety_accepted !== false) { + throw new M48ContractError(`${label}: полномочия расширены.`); + } + return { mode: "replay-simulated", physicalLive: false, commandsEnabled: false, actuationAllowed: false, navigationOrSafetyAccepted: false }; +} + +function blindness(value: unknown, label: string): void { + const item = objectValue(value, label); + for (const [key, nested] of Object.entries(item)) { + if (nested !== false) throw new M48ContractError(`${label}.${key}: blind-инвариант нарушен.`); + } +} + +function extent(value: unknown, label: string): readonly [number, number, number, number] { + const item = arrayValue(value, label); + if (item.length !== 4 || item.some((entry) => typeof entry !== "number" || !Number.isFinite(entry))) throw new M48ContractError(`${label}: extent должен содержать четыре числа.`); + const [left, top, right, bottom] = item as number[]; + if (left < 0 || top < 0 || right > 1 || bottom > 1 || right <= left || bottom <= top) throw new M48ContractError(`${label}: нужен normalized xyxy.`); + return [left, top, right, bottom]; +} + +function clipDraft( + value: unknown, + label: string, + allowSeededPending = false, +): M48ReviewClipDraft { + const item = objectValue(value, label); + keys(item, ["clip_id", "start_sequence", "end_sequence", "review_state", "no_object", "tracklets", "notes"], label); + const first = integer(item.start_sequence, `${label}.start_sequence`, 1); + const last = integer(item.end_sequence, `${label}.end_sequence`, first); + const tracklets = arrayValue(item.tracklets, `${label}.tracklets`).map((raw, index) => tracklet(raw, `${label}.tracklets[${index}]`)); + const state = enumValue(item.review_state, ["pending", "reviewed", "adjudicated"], `${label}.review_state`); + const noObject = item.no_object === null ? null : booleanValue(item.no_object, `${label}.no_object`); + if ( + (state !== "pending" && noObject === null) + || (state === "pending" && (noObject !== null || (!allowSeededPending && tracklets.length > 0))) + || (noObject !== null && noObject !== (tracklets.length === 0)) + ) { + throw new M48ContractError(`${label}: no-object конфликтует с tracklets.`); + } + return { clipId: identifier(item.clip_id, `${label}.clip_id`), startSequence: first, endSequence: last, reviewState: state, noObject, tracklets, notes: optionalString(item.notes, `${label}.notes`) }; +} + +function tracklet(value: unknown, label: string): M48ReviewTracklet { + const item = objectValue(value, label); + keys(item, ["object_id", "first_sequence", "last_sequence", "keyframes", "state_segments", "notes"], label); + const first = integer(item.first_sequence, `${label}.first_sequence`, 1); + const last = integer(item.last_sequence, `${label}.last_sequence`, first); + const keyframes = arrayValue(item.keyframes, `${label}.keyframes`).map((raw, index) => { + const row = objectValue(raw, `${label}.keyframes[${index}]`); + keys(row, ["sequence", "extent_xyxy", "visibility"], `${label}.keyframes[${index}]`); + return { sequence: integer(row.sequence, `${label}.keyframes[${index}].sequence`, first, last), extentXyxy: extent(row.extent_xyxy, `${label}.keyframes[${index}].extent_xyxy`), visibility: enumValue(row.visibility, ["visible", "partial", "occluded"], `${label}.keyframes[${index}].visibility`) }; + }); + const segments = arrayValue(item.state_segments, `${label}.state_segments`).map((raw, index) => { + const row = objectValue(raw, `${label}.state_segments[${index}]`); + keys(row, ["start_sequence", "end_sequence", "geometry_association", "freshness", "motion", "threat", "critical_corridor_obstacle"], `${label}.state_segments[${index}]`); + return { startSequence: integer(row.start_sequence, `${label}.segments.start`, first, last), endSequence: integer(row.end_sequence, `${label}.segments.end`, first, last), geometryAssociation: enumValue(row.geometry_association, ["associated", "unavailable", "ineligible", "unknown"], `${label}.geometry`), freshness: enumValue(row.freshness, ["current", "held", "stale", "unavailable"], `${label}.freshness`), motion: enumValue(row.motion, ["moving", "static", "unknown", "unsupported"], `${label}.motion`), threat: enumValue(row.threat, ["threat", "not-threat", "unknown"], `${label}.threat`), criticalCorridorObstacle: booleanValue(row.critical_corridor_obstacle, `${label}.critical`) }; + }); + const orderedKeyframes = [...keyframes].sort((left, right) => left.sequence - right.sequence); + if (orderedKeyframes.length === 0 || orderedKeyframes[0]?.sequence !== first || orderedKeyframes.at(-1)?.sequence !== last || new Set(orderedKeyframes.map(({ sequence }) => sequence)).size !== orderedKeyframes.length || segments.length === 0 || segments[0]?.startSequence !== first || segments.at(-1)?.endSequence !== last || segments.some((segment, index) => segment.endSequence < segment.startSequence || (index > 0 && segment.startSequence !== segments[index - 1]!.endSequence + 1))) { + throw new M48ContractError(`${label}: temporal coverage нарушен.`); + } + return { objectId: identifier(item.object_id, `${label}.object_id`), firstSequence: first, lastSequence: last, keyframes: orderedKeyframes, stateSegments: segments, notes: optionalString(item.notes, `${label}.notes`, 1_000) }; +} + +export function decodeM48GateStatus(value: unknown): M48GateStatus { + assertM48BlindPayload(value); + const item = objectValue(value, "M4.8 pack status"); + keys(item, ["schema_version", "pack_id", "created_at_utc", "state", "metrics", "decision", "truth_seal_id", "quality_result_id", "blindness", "authority", "access"], "M4.8 pack status"); + if (item.schema_version !== "missioncore.m48-object-quality-pack-status/v1" || item.access !== "neutral-workflow-status-read-only") throw new M48ContractError("M4.8 pack status contract изменён."); + const metrics = objectValue(item.metrics, "M4.8.metrics"); + keys(metrics, ["clip_count", "frame_count", "seed_object_count", "correction_state", "correction_reviewed_clip_count", "correction_complete", "review_slot_count", "frozen_reviewer_count", "required_frozen_reviewer_count"], "M4.8.metrics"); + const decision = objectValue(item.decision, "M4.8.decision"); + keys(decision, ["review_collection_ready", "two_distinct_reviews_frozen", "adjudication_unlocked", "adjudication_frozen", "evaluated", "next_action"], "M4.8.decision"); + blindness(item.blindness, "M4.8.blindness"); + if (metrics.required_frozen_reviewer_count !== 2) throw new M48ContractError("M4.8 требует ровно двух рецензентов."); + return { kind: "review", packId: identity(item.pack_id, PACK_ID, "M4.8.pack_id"), createdAtUtc: utc(item.created_at_utc, "M4.8.created_at_utc"), state: enumValue(item.state, ["prepared", "two-reviewers-frozen", "adjudication-frozen", "evaluated"], "M4.8.state"), clipCount: integer(metrics.clip_count, "M4.8.clip_count", 20, 30), frameCount: integer(metrics.frame_count, "M4.8.frame_count", 1), seedObjectCount: integer(metrics.seed_object_count, "M4.8.seed_object_count"), correctionState: enumValue(metrics.correction_state, ["not-started", "draft", "saved", "frozen"], "M4.8.correction_state"), correctionReviewedClipCount: integer(metrics.correction_reviewed_clip_count, "M4.8.correction_reviewed_clip_count", 0, 30), correctionComplete: booleanValue(metrics.correction_complete, "M4.8.correction_complete"), reviewSlotCount: integer(metrics.review_slot_count, "M4.8.review_slot_count", 0, 2), frozenReviewerCount: integer(metrics.frozen_reviewer_count, "M4.8.frozen_reviewer_count", 0, 2), requiredFrozenReviewerCount: 2, reviewCollectionReady: booleanValue(decision.review_collection_ready, "M4.8.review_collection_ready"), adjudicationUnlocked: booleanValue(decision.adjudication_unlocked, "M4.8.adjudication_unlocked"), adjudicationFrozen: booleanValue(decision.adjudication_frozen, "M4.8.adjudication_frozen"), evaluated: booleanValue(decision.evaluated, "M4.8.evaluated"), truthSealId: item.truth_seal_id === null ? null : identity(item.truth_seal_id, TRUTH_ID, "M4.8.truth_seal_id"), qualityResultId: item.quality_result_id === null ? null : identity(item.quality_result_id, RESULT_ID, "M4.8.quality_result_id"), authority: authority(item.authority, "M4.8.authority") }; +} + +function evidenceCapabilities(value: unknown): M48EvidenceCapabilities { + const item = objectValue(value, "M4.8 evidence capabilities"); + const labels = objectValue(item.label_authority, "M4.8 label authority"); + keys(item, ["state", "camera_epoch_time", "current_point_cloud_body_xyz_m", "rig", "virtual_corridor", "raw_lidar", "graph_output", "graph_boxes_ids_scores", "label_authority", "fail_closed_reason"], "M4.8 evidence capabilities"); + keys(labels, ["obstacle_presence_and_extent", "geometry_association", "freshness", "motion", "threat", "critical_corridor_obstacle"], "M4.8 label authority"); + if (item.raw_lidar !== false || item.graph_output !== false || item.graph_boxes_ids_scores !== false) throw new M48ContractError("M4.8 evidence capability раскрыла raw/graph output."); + return { state: stringValue(item.state, "M4.8 evidence state", 160), cameraEpochTime: booleanValue(item.camera_epoch_time, "M4.8.camera"), currentPointCloudBodyXyzM: booleanValue(item.current_point_cloud_body_xyz_m, "M4.8.current point cloud"), rig: booleanValue(item.rig, "M4.8.rig"), virtualCorridor: booleanValue(item.virtual_corridor, "M4.8.corridor"), obstaclePresenceAndExtent: booleanValue(labels.obstacle_presence_and_extent, "M4.8.label.extent"), geometryAssociation: booleanValue(labels.geometry_association, "M4.8.label.geometry"), freshness: booleanValue(labels.freshness, "M4.8.label.freshness"), motion: booleanValue(labels.motion, "M4.8.label.motion"), threat: booleanValue(labels.threat, "M4.8.label.threat"), criticalCorridorObstacle: booleanValue(labels.critical_corridor_obstacle, "M4.8.label.critical"), unavailableReason: typeof item.fail_closed_reason === "string" ? item.fail_closed_reason : null }; +} + +function reviewTrackletContract(value: unknown): void { + const item = objectValue(value, "M4.8 tracklet contract"); + keys(item, ["contract_id", "label_unit", "semantic_classes_allowed", "extent", "extent_interpolation", "visibility", "state_segments"], "M4.8 tracklet contract"); + if ( + item.contract_id !== "m48-class-free-object-tracklet/v1" + || item.label_unit !== "clip-local-object-tracklet" + || item.semantic_classes_allowed !== false + || item.extent !== "normalized-xyxy-sparse-keyframes" + || item.extent_interpolation !== "linear-between-bounding-keyframes" + ) throw new M48ContractError("M4.8 class-free tracklet contract изменён."); + const visibility = arrayValue(item.visibility, "M4.8 visibility contract"); + if (visibility.join("|") !== "occluded|partial|visible") throw new M48ContractError("M4.8 visibility contract изменён."); + const states = objectValue(item.state_segments, "M4.8 state contract"); + keys(states, ["coverage", "geometry_association", "freshness", "motion", "threat", "critical_corridor_obstacle"], "M4.8 state contract"); + if ( + states.coverage !== "contiguous-full-tracklet-lifetime" + || states.critical_corridor_obstacle !== "boolean" + || arrayValue(states.geometry_association, "M4.8 geometry contract").join("|") !== "associated|ineligible|unavailable|unknown" + || arrayValue(states.freshness, "M4.8 freshness contract").join("|") !== "current|held|stale|unavailable" + || arrayValue(states.motion, "M4.8 motion contract").join("|") !== "moving|static|unknown|unsupported" + || arrayValue(states.threat, "M4.8 threat contract").join("|") !== "not-threat|threat|unknown" + ) throw new M48ContractError("M4.8 temporal state contract изменён."); +} + +function recordedCameraPlayback(value: unknown): M48RecordedCameraPlayback | null { + if (value === null) return null; + const item = objectValue(value, "M4.8 camera playback"); + keys(item, [ + "schema_version", + "source_id", + "label", + "manifest_url", + "manifest_generation_sha256", + "byte_length", + "media_type", + "timeline_start_seconds", + "timeline_end_seconds", + "segment_count", + "seekable", + "synchronization", + "transport", + "fragment_binding", + ], "M4.8 camera playback"); + if ( + item.schema_version !== "missioncore.laboratory-recorded-clip-camera/v1" + || item.media_type !== "video/mp4" + || item.seekable !== true + || item.synchronization !== "host-arrival-best-effort" + || item.transport !== "recorded-fmp4-manifest" + || item.fragment_binding !== "pack-frozen-sha256-verified" + ) throw new M48ContractError("M4.8 camera playback contract изменён."); + const timelineStartSeconds = numberValue( + item.timeline_start_seconds, + "M4.8 camera timeline start", + ); + const timelineEndSeconds = numberValue( + item.timeline_end_seconds, + "M4.8 camera timeline end", + ); + if (timelineEndSeconds <= timelineStartSeconds) { + throw new M48ContractError("M4.8 camera playback timeline пуст."); + } + return { + schemaVersion: "missioncore.laboratory-recorded-clip-camera/v1", + sourceId: identifier(item.source_id, "M4.8 camera source id"), + label: stringValue(item.label, "M4.8 camera label", 160), + manifestUrl: recordedMediaManifestUrl(item.manifest_url, "M4.8 camera manifest URL"), + manifestGenerationSha256: exactSha( + item.manifest_generation_sha256, + "M4.8 camera manifest generation", + ), + byteLength: integer(item.byte_length, "M4.8 camera bytes", 1), + timelineStartSeconds, + timelineEndSeconds, + segmentCount: integer(item.segment_count, "M4.8 camera segments", 1), + }; +} + +export function decodeM48ReviewSourceCatalog(value: unknown): M48ReviewSourceCatalog { + assertM48BlindPayload(value); + const item = objectValue(value, "M4.8 source"); + keys(item, ["schema_version", "pack_id", "state", "contract", "camera_playback", "clips", "clip_count", "frame_count", "strata_included", "split_included", "candidate_identity_included", "frozen_predictions_included", "model_scores_included", "semantic_class_task_included", "evidence_capabilities", "access"], "M4.8 source"); + if (item.schema_version !== "missioncore.m48-neutral-object-review-source/v2" || item.access !== "prediction-free-strata-free-source-read-only") throw new M48ContractError("M4.8 neutral source contract изменён."); + if (item.state !== "prediction-blind-neutral-source-projection") throw new M48ContractError("M4.8 neutral source state изменён."); + reviewTrackletContract(item.contract); + const cameraPlayback = recordedCameraPlayback(item.camera_playback); + const clips = arrayValue(item.clips, "M4.8 clips").map((raw, index): M48ReviewClipSource => { + const clip = objectValue(raw, `M4.8 clips[${index}]`); + keys(clip, ["clip_id", "start_sequence", "end_sequence", "frames"], `M4.8 clips[${index}]`); + const start = integer(clip.start_sequence, "M4.8 clip start", 1); + const end = integer(clip.end_sequence, "M4.8 clip end", start); + const frames = arrayValue(clip.frames, "M4.8 clip frames").map((frameRaw, frameIndex) => { + const frame = objectValue(frameRaw, `M4.8 frame[${frameIndex}]`); + keys(frame, ["sequence", "source_time_ns", "camera_fragment_sha256", "camera_url", "spatial_url"], `M4.8 frame[${frameIndex}]`); + return { sequence: integer(frame.sequence, "M4.8 frame sequence", start, end), sourceTimeNs: integer(frame.source_time_ns, "M4.8 source time"), cameraFragmentSha256: exactSha(frame.camera_fragment_sha256, "M4.8 camera fragment sha"), cameraUrl: frame.camera_url === null ? null : safeUrl(frame.camera_url, "M4.8 camera url"), spatialUrl: frame.spatial_url === null ? null : safeUrl(frame.spatial_url, "M4.8 spatial url") }; + }); + if (frames.length !== end - start + 1 || frames.some((frame, offset) => frame.sequence !== start + offset)) throw new M48ContractError("M4.8 clip frames нарушили непрерывность."); + if (frames.some((frame, frameIndex) => frameIndex > 0 && frame.sourceTimeNs <= frames[frameIndex - 1]!.sourceTimeNs)) throw new M48ContractError("M4.8 clip time не возрастает."); + return { clipId: identifier(clip.clip_id, "M4.8 clip id"), ordinal: index + 1, startSequence: start, endSequence: end, frames }; + }); + const frameCount = clips.reduce((total, clip) => total + clip.frames.length, 0); + if ( + clips.length < 20 + || clips.length > 30 + || item.clip_count !== clips.length + || item.frame_count !== frameCount + || new Set(clips.map(({ clipId }) => clipId)).size !== clips.length + || clips.some((clip, index) => index > 0 && clip.startSequence <= clips[index - 1]!.endSequence) + ) throw new M48ContractError("M4.8 source clip/frame coverage нарушено."); + const capabilities = evidenceCapabilities(item.evidence_capabilities); + if (capabilities.cameraEpochTime !== (cameraPlayback !== null)) { + throw new M48ContractError("M4.8 camera capability не совпала с playback contract."); + } + return { packId: identity(item.pack_id, PACK_ID, "M4.8.pack_id"), cameraPlayback, clips, clipCount: clips.length, frameCount, evidenceCapabilities: capabilities }; +} + +export function m48RecordedCameraSourceDescriptor( + source: M48RecordedCameraPlayback, +): ObservationSourceDescriptor { + return { + id: source.sourceId, + sourceId: source.sourceId, + semanticChannelId: "camera.video.recorded", + label: source.label, + description: "Immutable pack-bound RIGHT camera clip source", + modality: "video", + role: "primary", + availability: "available", + transport: "recording", + endpointLabel: "M4.8 frozen clip source", + previewUrl: null, + delivery: { + id: `m48:${source.sourceId}:${source.manifestGenerationSha256}`, + kind: "recorded-fmp4-manifest", + url: source.manifestUrl, + mediaType: "video/mp4", + manifestGenerationSha256: source.manifestGenerationSha256, + byteLength: source.byteLength, + timelineStartSeconds: source.timelineStartSeconds, + timelineEndSeconds: source.timelineEndSeconds, + }, + activation: null, + provider: { + pluginId: "missioncore.laboratory-recorded-clip", + pluginVersion: "1", + modelId: "pack-bound-camera", + compatibilityProfileId: null, + }, + binding: {}, + capabilities: { + overlay: true, + fullscreen: true, + resizable: true, + defaultVisible: true, + timelineMode: "recorded", + seekable: true, + sessionRecording: true, + clockId: "session_time", + spatialRegistration: "unresolved", + }, + }; +} + +export function decodeM48SpatialFrame(value: unknown): M48ReviewSpatialFrame { + assertM48BlindPayload(value); + const item = objectValue(value, "M4.8 spatial frame"); + if (item.schema_version !== "missioncore.m48-neutral-object-review-spatial-frame/v1") throw new M48ContractError("M4.8 spatial schema изменена."); + keys(item, ["schema_version", "pack_id", "clip_id", "sequence", "source_time_ns", "point_cloud_body_xyz_m", "rig", "corridor", "occupied_voxel_size_m", "source_available", "body_frame_available", "candidate_identity_included", "graph_boxes_ids_scores_included", "frozen_predictions_included", "strata_included", "authority", "access"], "M4.8 spatial frame"); + if (item.access !== "prediction-free-current-spatial-evidence-read-only") throw new M48ContractError("M4.8 spatial access изменён."); + const points = arrayValue(item.point_cloud_body_xyz_m, "M4.8 points").map((raw, index) => { + const point = arrayValue(raw, `M4.8 points[${index}]`); + if (point.length !== 3) throw new M48ContractError("M4.8 point должен быть XYZ."); + return [numberValue(point[0], "M4.8 point x", -1_000), numberValue(point[1], "M4.8 point y", -1_000), numberValue(point[2], "M4.8 point z", -1_000)] as const; + }); + const rig = objectValue(item.rig, "M4.8 rig"); + const corridor = objectValue(item.corridor, "M4.8 corridor"); + keys(rig, ["profile_id", "length_m", "width_m", "lidar_reference", "nominal_sensor_height_m", "physical_mount_claimed"], "M4.8 rig"); + keys(corridor, ["profile_id", "forward_length_m", "rear_margin_m", "lateral_clearance_m", "half_width_m", "prediction_horizon_seconds"], "M4.8 corridor"); + if (rig.physical_mount_claimed !== false) throw new M48ContractError("M4.8 spatial evidence заявило physical mount."); + authority(item.authority, "M4.8 spatial authority"); + const sourceAvailable = booleanValue(item.source_available, "M4.8 source available"); + const bodyFrameAvailable = booleanValue(item.body_frame_available, "M4.8 body frame available"); + if ((!sourceAvailable || !bodyFrameAvailable) && points.length > 0) { + throw new M48ContractError("M4.8 unavailable spatial frame раскрыл point cloud."); + } + return { packId: identity(item.pack_id, PACK_ID, "M4.8.pack_id"), clipId: identifier(item.clip_id, "M4.8.clip_id"), sequence: integer(item.sequence, "M4.8.sequence", 1), sourceTimeNs: integer(item.source_time_ns, "M4.8.source_time_ns"), sourceAvailable, bodyFrameAvailable, pointCloudBodyXyzM: points, rig: { lengthM: numberValue(rig.length_m, "M4.8 rig length"), widthM: numberValue(rig.width_m, "M4.8 rig width"), nominalSensorHeightM: numberValue(rig.nominal_sensor_height_m, "M4.8 sensor height") }, corridor: { forwardLengthM: numberValue(corridor.forward_length_m, "M4.8 corridor forward"), halfWidthM: numberValue(corridor.half_width_m, "M4.8 corridor width"), rearMarginM: numberValue(corridor.rear_margin_m, "M4.8 corridor rear") }, occupiedVoxelSizeM: numberValue(item.occupied_voxel_size_m, "M4.8 voxel", 0.001) }; +} + +function reviewSession(value: unknown): M48ReviewSession { + assertM48BlindPayload(value); + const item = objectValue(value, "M4.8 review session"); + if (item.schema_version !== "missioncore.m48-object-review-session/v1" || item.access !== "capability-protected-prediction-blind-review") throw new M48ContractError("M4.8 review session contract изменён."); + blindness(item.blindness, "M4.8 review blindness"); + const progress = objectValue(item.progress, "M4.8 review progress"); + const slot = integer(item.reviewer_slot, "M4.8 reviewer slot", 1, 2); + return { sessionId: identity(item.session_id, REVIEW_ID, "M4.8.session_id"), packId: identity(item.pack_id, PACK_ID, "M4.8.pack_id"), reviewerSlot: slot as 1 | 2, title: stringValue(item.title, "M4.8 title", 160), state: enumValue(item.state, ["draft", "saved", "frozen"], "M4.8 review state"), revision: integer(item.revision, "M4.8 revision"), reviewedClipCount: integer(progress.reviewed_clip_count, "M4.8 reviewed clips"), clipCount: integer(progress.clip_count, "M4.8 clip count", 20, 30), complete: booleanValue(progress.complete, "M4.8 complete"), clips: arrayValue(item.clips, "M4.8 clips").map((clip, index) => clipDraft(clip, `M4.8 clips[${index}]`)) }; +} + +export function decodeM48CorrectionSession(value: unknown): M48CorrectionSession { + const item = objectValue(value, "M4.8 correction session"); + keys(item, [ + "schema_version", + "pack_id", + "session_id", + "title", + "revision", + "state", + "created_at_utc", + "updated_at_utc", + "clips", + "progress", + "seed_summary", + "evidence_summary", + "reviewer_id", + "submitted_at_utc", + "submission_sha256", + "assistance", + "authority", + "access", + ], "M4.8 correction session"); + if ( + item.schema_version !== "missioncore.m48-assisted-object-correction-session/v1" + || item.access !== "capability-protected-candidate-assisted-correction" + ) throw new M48ContractError("M4.8 correction session contract изменён."); + const assistance = objectValue(item.assistance, "M4.8 correction assistance"); + keys(assistance, [ + "mode", + "candidate_predictions_seen", + "model_scores_seen", + "semantic_class_task_seen", + "independent_truth_eligible", + ], "M4.8 correction assistance"); + if ( + assistance.mode !== "frozen-candidate-seeded" + || assistance.candidate_predictions_seen !== true + || assistance.model_scores_seen !== false + || assistance.semantic_class_task_seen !== false + || assistance.independent_truth_eligible !== false + ) throw new M48ContractError("M4.8 correction assistance изменилась."); + const progress = objectValue(item.progress, "M4.8 correction progress"); + const seed = objectValue(item.seed_summary, "M4.8 correction seed"); + keys(seed, ["worker_id", "clip_count", "frame_count", "object_count", "prediction_rows_sha256"], "M4.8 correction seed"); + if (seed.worker_id !== "006") throw new M48ContractError("M4.8 correction worker identity изменилась."); + exactSha(seed.prediction_rows_sha256, "M4.8 correction prediction rows"); + authority(item.authority, "M4.8 correction authority"); + const parseEvidence = (raw: unknown): M48CorrectionEvidenceSummary | null => { + if (raw === null) return null; + const evidence = objectValue(raw, "M4.8 correction evidence"); + keys(evidence, [ + "seed_object_count", + "corrected_object_count", + "confirmed_candidate_count", + "unchanged_candidate_count", + "modified_candidate_count", + "false_positive_removed_count", + "missed_object_added_count", + "candidate_confirmation_rate", + "assisted_recall_proxy", + "independent_truth", + ], "M4.8 correction evidence"); + if (evidence.independent_truth !== false) throw new M48ContractError("M4.8 assisted evidence выдано за independent truth."); + return { + seedObjectCount: integer(evidence.seed_object_count, "M4.8 seed objects"), + correctedObjectCount: integer(evidence.corrected_object_count, "M4.8 corrected objects"), + confirmedCandidateCount: integer(evidence.confirmed_candidate_count, "M4.8 confirmed candidates"), + unchangedCandidateCount: integer(evidence.unchanged_candidate_count, "M4.8 unchanged candidates"), + modifiedCandidateCount: integer(evidence.modified_candidate_count, "M4.8 modified candidates"), + falsePositiveRemovedCount: integer(evidence.false_positive_removed_count, "M4.8 removed false positives"), + missedObjectAddedCount: integer(evidence.missed_object_added_count, "M4.8 added misses"), + candidateConfirmationRate: numberValue(evidence.candidate_confirmation_rate, "M4.8 confirmation rate"), + assistedRecallProxy: numberValue(evidence.assisted_recall_proxy, "M4.8 assisted recall proxy"), + independentTruth: false, + }; + }; + return { + sessionId: identity(item.session_id, CORRECTION_ID, "M4.8 correction session id"), + packId: identity(item.pack_id, PACK_ID, "M4.8 correction pack id"), + title: stringValue(item.title, "M4.8 correction title", 160), + state: enumValue(item.state, ["draft", "saved", "frozen"], "M4.8 correction state"), + revision: integer(item.revision, "M4.8 correction revision"), + reviewedClipCount: integer(progress.reviewed_clip_count, "M4.8 corrected clips"), + clipCount: integer(progress.clip_count, "M4.8 correction clip count", 1, 64), + complete: booleanValue(progress.complete, "M4.8 correction complete"), + clips: arrayValue(item.clips, "M4.8 correction clips").map((clip, index) => ( + clipDraft(clip, `M4.8 correction clips[${index}]`, true) + )), + seedWorkerId: "006", + seedFrameCount: integer(seed.frame_count, "M4.8 correction seed frames", 1), + seedObjectCount: integer(seed.object_count, "M4.8 correction seed objects"), + evidenceSummary: parseEvidence(item.evidence_summary), + }; +} + +function adjudicationSession(value: unknown): M48AdjudicationSession { + assertM48BlindPayload(value); + const item = objectValue(value, "M4.8 adjudication"); + if (item.schema_version !== "missioncore.m48-object-adjudication-session/v1" || item.access !== "capability-protected-prediction-blind-adjudication") throw new M48ContractError("M4.8 adjudication contract изменён."); + const progress = objectValue(item.progress, "M4.8 adjudication progress"); + const reviewInputs = arrayValue(item.review_inputs, "M4.8 review inputs").map((raw, index) => { + const row = objectValue(raw, `M4.8 review input[${index}]`); + const slot = integer(row.reviewer_slot, "M4.8 reviewer slot", 1, 2); + return { reviewerSlot: slot as 1 | 2, submissionSha256: exactSha(row.submission_sha256, "M4.8 submission sha"), clips: arrayValue(row.clips, "M4.8 input clips").map((clip, clipIndex) => clipDraft(clip, `M4.8 input clips[${clipIndex}]`)) }; + }); + return { sessionId: identity(item.session_id, ADJUDICATION_ID, "M4.8 session id"), packId: identity(item.pack_id, PACK_ID, "M4.8 pack id"), title: stringValue(item.title, "M4.8 title", 160), state: enumValue(item.state, ["draft", "saved", "adjudication-frozen", "evaluated"], "M4.8 adjudication state"), revision: integer(item.revision, "M4.8 revision"), resolvedClipCount: integer(progress.reviewed_clip_count, "M4.8 resolved clips"), clipCount: integer(progress.clip_count, "M4.8 clip count", 20, 30), complete: booleanValue(progress.complete, "M4.8 complete"), clips: arrayValue(item.clips, "M4.8 adjudication clips").map((clip, index) => clipDraft(clip, `M4.8 adjudication clips[${index}]`)), reviewInputs, truthSealId: item.truth_seal_id === null ? null : identity(item.truth_seal_id, TRUTH_ID, "M4.8 truth id"), qualityResultId: item.quality_result_id === null ? null : identity(item.quality_result_id, RESULT_ID, "M4.8 result id"), evaluationReceiptId: item.evaluation_receipt_id === null ? null : identity(item.evaluation_receipt_id, RECEIPT_ID, "M4.8 receipt id") }; +} + +export function decodeM48QualityResult(value: unknown): M48QualityResult { + const item = objectValue(value, "M4.8 result"); + if (item.schema_version !== "missioncore.m48-object-centric-quality-result-view/v1") throw new M48ContractError("M4.8 result view schema изменена."); + const metrics = objectValue(item.metrics, "M4.8 metrics"); + const metric = (key: string) => numberValue(metrics[key], `M4.8 metrics.${key}`); + const gates = Object.fromEntries(Object.entries(objectValue(item.gates, "M4.8 gates")).map(([key, gate]) => [identifier(key, "M4.8 gate"), booleanValue(gate, `M4.8 gate ${key}`)])); + const unknown = Object.fromEntries(Object.entries(objectValue(item.unknown_causes, "M4.8 unknown causes")).map(([key, count]) => [identifier(key, "M4.8 unknown cause"), integer(count, `M4.8 unknown ${key}`)])); + return { kind: "result", resultId: identity(item.result_id, RESULT_ID, "M4.8 result id"), packId: identity(item.pack_id, PACK_ID, "M4.8 pack id"), truthSealId: identity(item.truth_seal_id, TRUTH_ID, "M4.8 truth id"), createdAtUtc: utc(item.created_at_utc, "M4.8 created at"), status: enumValue(item.status, ["accepted-object-centric-source-quality", "failed-object-centric-source-quality"], "M4.8 status"), accepted: booleanValue(item.accepted, "M4.8 accepted"), metrics: { terminalOutcomeAccounting: metric("terminal_outcome_accounting"), falseFreeSpaceClaims: metric("false_free_space_claims"), obstaclePresencePrecision: metric("obstacle_presence_precision"), obstaclePresenceRecall: metric("obstacle_presence_recall"), criticalCorridorObstacleRecall: metric("critical_corridor_obstacle_recall"), geometryAssociationCorrectness: metric("geometry_association_correctness"), freshnessCorrectness: metric("freshness_correctness"), motionDecisionCorrectness: metric("motion_decision_correctness"), criticalNotThreatCount: metric("critical_threat_not_threat"), unknownPredictionCount: metric("unknown_prediction_count"), failureCaseCount: metric("failure_case_count") }, gates, unknownCauses: unknown, authority: authority(item.authority, "M4.8 authority") }; +} + +function failureSummary(value: unknown): M48FailureCaseSummary { + const item = objectValue(value, "M4.8 failure"); + return { caseId: identifier(item.failure_case_id ?? item.case_id, "M4.8 case id"), clipId: identifier(item.clip_id, "M4.8 clip id"), split: enumValue(item.split, ["development", "validation"], "M4.8 failure split"), sequence: integer(item.sequence, "M4.8 sequence", 1), severity: enumValue(item.severity, ["critical", "high", "medium"], "M4.8 severity"), failures: arrayValue(item.causes ?? item.failures, "M4.8 failures").map((cause) => identifier(cause, "M4.8 failure cause")) }; +} + +export function decodeM48FailureAtlas(value: unknown): readonly M48FailureCaseSummary[] { + const item = objectValue(value, "M4.8 atlas"); + if (item.schema_version !== "missioncore.m48-object-quality-failure-atlas-view/v1") throw new M48ContractError("M4.8 atlas schema изменена."); + identity(item.result_id, RESULT_ID, "M4.8 result id"); + return arrayValue(item.cases, "M4.8 cases").map(failureSummary); +} + +export function decodeM48FailureCase(value: unknown): M48FailureCase { + const item = objectValue(value, "M4.8 failure case"); + if (item.schema_version !== "missioncore.m48-object-quality-failure-case-view/v1") throw new M48ContractError("M4.8 failure case schema изменена."); + const frameRaw = objectValue(item.frame, "M4.8 frame"); + const parseObject = (raw: unknown): M48FailureObject => { + const row = objectValue(raw, "M4.8 object"); + return { objectId: identifier(row.object_id, "M4.8 object id"), extentXyxy: extent(row.extent_xyxy, "M4.8 extent"), geometryAssociation: enumValue(row.geometry_association, ["associated", "unavailable", "ineligible", "unknown"], "M4.8 geometry"), freshness: enumValue(row.freshness, ["current", "held", "stale", "unavailable"], "M4.8 freshness"), motion: enumValue(row.motion, ["moving", "static", "unknown", "unsupported"], "M4.8 motion"), threat: enumValue(row.threat, ["threat", "not-threat", "unknown"], "M4.8 threat") }; + }; + return { ...failureSummary(item.case), resultId: identity(item.result_id, RESULT_ID, "M4.8 result id"), frame: { sequence: integer(frameRaw.sequence, "M4.8 sequence", 1), sourceTimeNs: integer(frameRaw.source_time_ns, "M4.8 source time"), cameraFragmentSha256: exactSha(frameRaw.camera_fragment_sha256, "M4.8 camera fragment sha"), cameraUrl: frameRaw.camera_url === null ? null : safeUrl(frameRaw.camera_url, "M4.8 camera url"), spatialUrl: frameRaw.spatial_url === null ? null : safeUrl(frameRaw.spatial_url, "M4.8 spatial url") }, truth: arrayValue(item.truth, "M4.8 truth").map(parseObject), graph: arrayValue(item.graph, "M4.8 graph").map(parseObject) }; +} + +async function json(response: Response, label: string): Promise { + if (!response.ok) throw new M48ContractError(`${label}: HTTP ${response.status}.`); + return response.json(); +} + +function base(packId: string): string { + identity(packId, PACK_ID, "M4.8 pack id"); + return `/api/v1/laboratory/m48/packs/${encodeURIComponent(packId)}`; +} + +function capabilityKey(kind: "review" | "correction" | "adjudication", packId: string, sessionId: string): string { + return `missioncore:m48:${kind}:${packId}:${sessionId}`; +} + +function capabilityHeaders(kind: "review" | "correction" | "adjudication", packId: string, sessionId: string): HeadersInit { + const value = typeof sessionStorage === "undefined" ? null : sessionStorage.getItem(capabilityKey(kind, packId, sessionId)); + const header = kind === "review" + ? "X-M48-Review-Capability" + : kind === "correction" + ? "X-M48-Correction-Capability" + : "X-M48-Adjudication-Capability"; + return value ? { Accept: "application/json", [header]: value } : { Accept: "application/json" }; +} + +export async function fetchM48GateStatus(packId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(base(packId), { headers: { Accept: "application/json" }, signal: options.signal }); + return decodeM48GateStatus(await json(response, "M4.8 pack")); +} + +export async function fetchM48ReviewSourceCatalog(packId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(packId)}/source`, { headers: { Accept: "application/json" }, signal: options.signal }); + const result = decodeM48ReviewSourceCatalog(await json(response, "M4.8 source")); + if (result.packId !== packId) throw new M48ContractError("M4.8 source identity изменилась."); + return result; +} + +export async function fetchM48ReviewSpatialFrame(packId: string, clipId: string, sequence: number, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + identifier(clipId, "M4.8 clip id"); + const response = await (options.fetcher ?? fetch)(`${base(packId)}/source/clips/${encodeURIComponent(clipId)}/frames/${integer(sequence, "M4.8 sequence", 1)}/spatial`, { headers: { Accept: "application/json" }, signal: options.signal }); + const result = decodeM48SpatialFrame(await json(response, "M4.8 spatial frame")); + if (result.packId !== packId || result.clipId !== clipId || result.sequence !== sequence) throw new M48ContractError("M4.8 spatial identity изменилась."); + return result; +} + +function wireClip(item: M48ReviewClipDraft) { + return { clip_id: item.clipId, start_sequence: item.startSequence, end_sequence: item.endSequence, review_state: item.reviewState, no_object: item.noObject, tracklets: item.tracklets.map((track) => ({ object_id: track.objectId, first_sequence: track.firstSequence, last_sequence: track.lastSequence, keyframes: track.keyframes.map((keyframe) => ({ sequence: keyframe.sequence, extent_xyxy: keyframe.extentXyxy, visibility: keyframe.visibility })), state_segments: track.stateSegments.map((segment) => ({ start_sequence: segment.startSequence, end_sequence: segment.endSequence, geometry_association: segment.geometryAssociation, freshness: segment.freshness, motion: segment.motion, threat: segment.threat, critical_corridor_obstacle: segment.criticalCorridorObstacle })), notes: track.notes })), notes: item.notes }; +} + +export async function createM48ReviewSession(packId: string, idempotencyKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(packId)}/reviews`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify({ idempotency_key: identifier(idempotencyKey, "M4.8 operation key") }), signal: options.signal }); + const payload = objectValue(await json(response, "M4.8 create review"), "M4.8 create review"); + const capability = stringValue(payload.review_capability, "M4.8 review capability", 256); + delete payload.review_capability; + const session = reviewSession(payload); + if (typeof sessionStorage !== "undefined") sessionStorage.setItem(capabilityKey("review", packId, session.sessionId), capability); + return session; +} + +export async function saveM48ReviewSession(session: M48ReviewSession, title: string, clips: readonly M48ReviewClipDraft[], operationKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/reviews/${encodeURIComponent(session.sessionId)}`, { method: "PUT", headers: { ...capabilityHeaders("review", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, idempotency_key: identifier(operationKey, "M4.8 operation key"), title: stringValue(title, "M4.8 title", 160), clips: clips.map(wireClip) }), signal: options.signal }); + return reviewSession(await json(response, "M4.8 save review")); +} + +export async function freezeM48ReviewSession(session: M48ReviewSession, reviewerId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/reviews/${encodeURIComponent(session.sessionId)}/freeze`, { method: "POST", headers: { ...capabilityHeaders("review", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, reviewer_id: identifier(reviewerId, "M4.8 reviewer id"), independent_attestation: true, candidate_identity_not_seen: true, model_predictions_not_seen: true, semantic_class_task_not_seen: true }), signal: options.signal }); + return reviewSession(await json(response, "M4.8 freeze review")); +} + +export async function createM48CorrectionSession(packId: string, idempotencyKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(packId)}/corrections`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify({ idempotency_key: identifier(idempotencyKey, "M4.8 correction operation key") }), signal: options.signal }); + const payload = objectValue(await json(response, "M4.8 create correction"), "M4.8 create correction"); + const capability = stringValue(payload.correction_capability, "M4.8 correction capability", 256); + delete payload.correction_capability; + const session = decodeM48CorrectionSession(payload); + if (typeof sessionStorage !== "undefined") sessionStorage.setItem(capabilityKey("correction", packId, session.sessionId), capability); + return session; +} + +export async function saveM48CorrectionSession(session: M48CorrectionSession, title: string, clips: readonly M48ReviewClipDraft[], operationKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/corrections/${encodeURIComponent(session.sessionId)}`, { method: "PUT", headers: { ...capabilityHeaders("correction", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, idempotency_key: identifier(operationKey, "M4.8 correction operation key"), title: stringValue(title, "M4.8 correction title", 160), clips: clips.map(wireClip) }), signal: options.signal }); + return decodeM48CorrectionSession(await json(response, "M4.8 save correction")); +} + +export async function freezeM48CorrectionSession(session: M48CorrectionSession, reviewerId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/corrections/${encodeURIComponent(session.sessionId)}/freeze`, { method: "POST", headers: { ...capabilityHeaders("correction", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, reviewer_id: identifier(reviewerId, "M4.8 correction reviewer id"), all_clips_corrected: true, candidate_predictions_seen: true, semantic_class_task_not_seen: true }), signal: options.signal }); + return decodeM48CorrectionSession(await json(response, "M4.8 freeze correction")); +} + +export async function createM48AdjudicationSession(packId: string, operationKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(packId)}/adjudication`, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json" }, body: JSON.stringify({ idempotency_key: identifier(operationKey, "M4.8 operation key") }), signal: options.signal }); + const payload = objectValue(await json(response, "M4.8 create adjudication"), "M4.8 create adjudication"); + const capability = stringValue(payload.adjudication_capability, "M4.8 adjudication capability", 256); + delete payload.adjudication_capability; + const session = adjudicationSession(payload); + if (typeof sessionStorage !== "undefined") sessionStorage.setItem(capabilityKey("adjudication", packId, session.sessionId), capability); + return session; +} + +export async function saveM48AdjudicationSession(session: M48AdjudicationSession, title: string, clips: readonly M48ReviewClipDraft[], operationKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/adjudication/${encodeURIComponent(session.sessionId)}`, { method: "PUT", headers: { ...capabilityHeaders("adjudication", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, idempotency_key: identifier(operationKey, "M4.8 operation key"), title: stringValue(title, "M4.8 title", 160), clips: clips.map(wireClip) }), signal: options.signal }); + return adjudicationSession(await json(response, "M4.8 save adjudication")); +} + +export async function freezeM48AdjudicationSession(session: M48AdjudicationSession, adjudicatorId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/adjudication/${encodeURIComponent(session.sessionId)}/freeze`, { method: "POST", headers: { ...capabilityHeaders("adjudication", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, adjudicator_id: identifier(adjudicatorId, "M4.8 adjudicator id"), all_disagreements_resolved: true, model_predictions_not_seen: true }), signal: options.signal }); + return adjudicationSession(await json(response, "M4.8 freeze adjudication")); +} + +export async function evaluateM48Adjudication(session: M48AdjudicationSession, operationKey: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + const response = await (options.fetcher ?? fetch)(`${base(session.packId)}/adjudication/${encodeURIComponent(session.sessionId)}/evaluate`, { method: "POST", headers: { ...capabilityHeaders("adjudication", session.packId, session.sessionId), "Content-Type": "application/json" }, body: JSON.stringify({ expected_revision: session.revision, idempotency_key: identifier(operationKey, "M4.8 operation key") }), signal: options.signal }); + return adjudicationSession(await json(response, "M4.8 evaluate")); +} + +export async function fetchM48QualityResult(resultId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + identity(resultId, RESULT_ID, "M4.8 result id"); + const response = await (options.fetcher ?? fetch)(`/api/v1/laboratory/m48/results/${encodeURIComponent(resultId)}`, { headers: { Accept: "application/json" }, signal: options.signal }); + return decodeM48QualityResult(await json(response, "M4.8 result")); +} + +export async function fetchM48LifecycleResult( + evidenceId: string, + options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}, +): Promise { + if (RESULT_ID.test(evidenceId)) { + return fetchM48QualityResult(evidenceId, options); + } + identity(evidenceId, PACK_ID, "M4.8 evidence id"); + const gate = await fetchM48GateStatus(evidenceId, options); + return gate.evaluated && gate.qualityResultId + ? fetchM48QualityResult(gate.qualityResultId, options) + : gate; +} + +export async function fetchM48FailureAtlas(resultId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + identity(resultId, RESULT_ID, "M4.8 result id"); + const response = await (options.fetcher ?? fetch)(`/api/v1/laboratory/m48/results/${encodeURIComponent(resultId)}/atlas`, { headers: { Accept: "application/json" }, signal: options.signal }); + return decodeM48FailureAtlas(await json(response, "M4.8 atlas")); +} + +export async function fetchM48FailureCase(resultId: string, caseId: string, options: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {}): Promise { + identity(resultId, RESULT_ID, "M4.8 result id"); + identifier(caseId, "M4.8 case id"); + const response = await (options.fetcher ?? fetch)(`/api/v1/laboratory/m48/results/${encodeURIComponent(resultId)}/atlas/cases/${encodeURIComponent(caseId)}`, { headers: { Accept: "application/json" }, signal: options.signal }); + return decodeM48FailureCase(await json(response, "M4.8 failure case")); +} diff --git a/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts b/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts new file mode 100644 index 0000000..8da9beb --- /dev/null +++ b/apps/control-station/src/core/laboratory/m48SmallStaticRegression.ts @@ -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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new M48SmallStaticRegressionContractError(`${label}: ожидался объект.`); + } + return value as Record; +} + +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(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 { + 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 { + 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 { + 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), + }; +} diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 4c7a3d9..7d67b34 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -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"; diff --git a/apps/control-station/src/styles/laboratory-evidence-viewer.css b/apps/control-station/src/styles/laboratory-evidence-viewer.css new file mode 100644 index 0000000..5be6a25 --- /dev/null +++ b/apps/control-station/src/styles/laboratory-evidence-viewer.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; +} diff --git a/apps/control-station/src/styles/laboratory-recorded-clip-player.css b/apps/control-station/src/styles/laboratory-recorded-clip-player.css new file mode 100644 index 0000000..9b9c764 --- /dev/null +++ b/apps/control-station/src/styles/laboratory-recorded-clip-player.css @@ -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; + } +} diff --git a/apps/control-station/src/styles/laboratory-reporting.css b/apps/control-station/src/styles/laboratory-reporting.css index 1ab074a..de2db4a 100644 --- a/apps/control-station/src/styles/laboratory-reporting.css +++ b/apps/control-station/src/styles/laboratory-reporting.css @@ -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)); diff --git a/apps/control-station/src/styles/laboratory-review-workspace.css b/apps/control-station/src/styles/laboratory-review-workspace.css new file mode 100644 index 0000000..bd61168 --- /dev/null +++ b/apps/control-station/src/styles/laboratory-review-workspace.css @@ -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; + } +} diff --git a/apps/control-station/src/styles/laboratory.css b/apps/control-station/src/styles/laboratory.css index f0331fd..6d3b220 100644 --- a/apps/control-station/src/styles/laboratory.css +++ b/apps/control-station/src/styles/laboratory.css @@ -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; diff --git a/apps/control-station/src/styles/m48-object-centric-quality.css b/apps/control-station/src/styles/m48-object-centric-quality.css new file mode 100644 index 0000000..cc6fbb9 --- /dev/null +++ b/apps/control-station/src/styles/m48-object-centric-quality.css @@ -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; + } +} diff --git a/apps/control-station/src/styles/observation.css b/apps/control-station/src/styles/observation.css index 6a1468c..c31cd0a 100644 --- a/apps/control-station/src/styles/observation.css +++ b/apps/control-station/src/styles/observation.css @@ -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; diff --git a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx index decfe14..70f40bc 100644 --- a/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx +++ b/apps/control-station/src/workspaces/laboratory/AdvancedLaboratoryResult.tsx @@ -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 ; + } + if (workId === "m48-small-static-passage-regression" && results.m48SmallStatic) { + return ; + } if (workId === "m47-reference-graph-shadow" && results.m47Graph) { return ; } diff --git a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx index a1cb012..f21935d 100644 --- a/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx +++ b/apps/control-station/src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx @@ -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} /> - + {!m48Review.active ? {workId === "e28-local-surface" ? ( Неподтверждённый результат скрыт из лабораторного каталога. )} - + : null} {annotationWorkspace} + {m48Review.workspace} ); } diff --git a/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx b/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx new file mode 100644 index 0000000..b076394 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48FailureAtlasVisual.tsx @@ -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(() => { + 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 Точный camera-кадр failure case недоступен.; + } + return ( + + setSize({ + width: event.currentTarget.naturalWidth || 1440, + height: event.currentTarget.naturalHeight || 1080, + })} + /> + + + ); +} + +export function M48FailureAtlasVisual({ resultId }: { resultId: string }) { + const [cases, setCases] = useState([]); + const [index, setIndex] = useState(0); + const [item, setItem] = useState(null); + const [mode, setMode] = useState("overlay"); + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + setIndex((current) => (current - 1 + cases.length) % cases.length)}> + setIndex((current) => (current + 1) % cases.length)}> + > + )} + overlay={item ? {item.split.toUpperCase()} · {item.severity}{item.clipId} · frame {item.sequence}{item.split === "development" ? "Diagnostic only · " : "Validation acceptance evidence · "}{item.failures.join(" · ")} : null} + > + {loading ? Загружаем bounded failure case + : error ? {error} + : item ? + : Failure atlas пуст: ни один bounded failure case не зафиксирован.} + + ); +} + +export function M48ReviewPackVisual({ packId }: { packId: string }) { + const [catalog, setCatalog] = useState> | null>(null); + const [clipIndex, setClipIndex] = useState(0); + const [sequence, setSequence] = useState(1); + const [mode, setMode] = useState("camera"); + const [cameraVisible, setCameraVisible] = useState(true); + const [expanded, setExpanded] = useState(false); + const [error, setError] = useState(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 ( + { + if (!catalog) return; + const next = (clipIndex - 1 + catalog.clips.length) % catalog.clips.length; + setClipIndex(next); + setSequence(catalog.clips[next]!.startSequence); + }}> { + if (!catalog) return; + const next = (clipIndex + 1) % catalog.clips.length; + setClipIndex(next); + setSequence(catalog.clips[next]!.startSequence); + }}>>} + overlay={clip ? SOURCE ONLY{clip.ordinal}/{catalog?.clipCount} · {clip.clipId}0 classes · 0 candidate identity · 0 model predictions : null} + > + + {error ? {error} + : clip && catalog?.cameraPlayback ? undefined} onSelectedObjectIdChange={() => undefined} onTrackletsChange={() => undefined} /> + : catalog ? Pack-bound camera playback недоступен. + : Загружаем source-only clip pack} + {catalog ? : null} + + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M48ObjectCentricQualityResult.tsx b/apps/control-station/src/workspaces/laboratory/M48ObjectCentricQualityResult.tsx new file mode 100644 index 0000000..7f99a95 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48ObjectCentricQualityResult.tsx @@ -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 ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} + +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 ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} + +export function M48ObjectCentricQualityResultView({ + rigLabel, + result, +}: { + rigLabel: string; + result: M48AdvancedResult; +}) { + return result.kind === "review" + ? + : ; +} diff --git a/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx b/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx new file mode 100644 index 0000000..79f5d36 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48SmallStaticPassageRegressionResult.tsx @@ -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 ( + + )} + evidence={( + + + + )} + result={( + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx b/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx new file mode 100644 index 0000000..2e21f33 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/M48SmallStaticRegressionVisual.tsx @@ -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([]); + const [caseIndex, setCaseIndex] = useState(0); + const [item, setItem] = useState(null); + const [catalog, setCatalog] = useState> | null>(null); + const [sequence, setSequence] = useState(1); + const [mode, setMode] = useState("camera"); + const [cameraVisible, setCameraVisible] = useState(true); + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + setCaseIndex((current) => (current - 1 + cases.length) % cases.length)} + > + + + setCaseIndex((current) => (current + 1) % cases.length)} + > + + + > + )} + overlay={item ? ( + + + {item.comparison.matchedAtThreshold ? "WORKER RECALL" : "WORKER MISS"} + + {caseIndex + 1}/{cases.length} · {item.anchor.clipId} · кадр {item.anchor.sequence} + ASSISTED-якорь, не independent truth · best IoU {item.comparison.bestIou.toFixed(3)} + + ) : null} + > + + {loading ? ( + + + Загружаем M4.8R1 bounded case + + ) : error ? ( + {error} + ) : clip && catalog?.cameraPlayback && item ? ( + undefined} + onSelectedObjectIdChange={() => undefined} + onTrackletsChange={() => undefined} + /> + ) : ( + + Точный источник M4.8R1 недоступен. + + )} + {catalog ? ( + + ) : null} + + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx new file mode 100644 index 0000000..4a6bc52 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48AdjudicationWorkspace.tsx @@ -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(null); + const [session, setSession] = useState(null); + const [drafts, setDrafts] = useState>(new Map()); + const [clipId, setClipId] = useState(""); + const [sequence, setSequence] = useState(1); + const [reviewLayer, setReviewLayer] = useState("reviewer-a"); + const [evidenceMode, setEvidenceMode] = useState("camera"); + const [cameraVisible, setCameraVisible] = useState(true); + const [selectedObjectId, setSelectedObjectId] = useState(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(null); + const [freezeOpen, setFreezeOpen] = useState(false); + const [adjudicatorId, setAdjudicatorId] = useState(""); + const [resolvedAttested, setResolvedAttested] = useState(false); + const [blindAttested, setBlindAttested] = useState(false); + const [toasts, setToasts] = useState([]); + + const notify = useCallback((toast: Omit) => 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) => { + 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 ( + + + } disabled={busy || Boolean(session)} onClick={() => void createSession()}>Открыть adjudication + } disabled={!editable} onClick={() => setDrawing((value) => !value)}>Объект + } disabled={!editable || !dirty || busy} onClick={() => void save()}>Сохранить + } disabled={!session?.complete || dirty || busy || session.state !== "saved"} onClick={() => setFreezeOpen(true)}>Truth seal + void evaluate()}>Рассчитать gate + + + ({ 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); + }} /> + {session ? `${session.state} · ${session.resolvedClipCount}/${session.clipCount}` : "Adjudication не создана"} + + + > + )} + stage={( + + {loading ? Загружаем source-only клипы + : !catalog || !catalog.cameraPlayback || !clip ? {error ?? "Источник недоступен."} + : { + if (!decision) return; + setDecision({ ...decision, reviewState: "pending", noObject: null, tracklets }); + }} />} + {catalog ? : null} + + )} + inspector={( + <> + M4.8 · reviewer disagreement{clip ? `${clip.clipId} · frame ${sequence}` : "Источник проверяется"}Два независимых class-free review; frozen graph output остаётся скрыт. + {(error || spatialError) && catalog ? {error ?? spatialError} : null} + + {session && reviewerA && reviewerB && !["adjudication-frozen", "evaluated"].includes(session.state) ? ( + + setDecision(decisionCopy(reviewerA))}>Принять A + setDecision(decisionCopy(reviewerB))}>Принять B + {decision ? setDecision({ ...decision, reviewState: checked ? "adjudicated" : "pending", noObject: checked ? decision.tracklets.length === 0 : null })} /> : null} + + ) : null} + {editable && selectedDecisionTracklet ? ( + + {selectedDecisionTracklet.objectId} + updateDecisionTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} /> + updateDecisionTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} /> + updateDecisionTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} /> + updateDecisionTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} /> + updateDecisionTrackState({ criticalCorridorObstacle })} /> + + ) : null} + > + )} + overlays={( + <> + !busy && setFreezeOpen(false)} footer={ setFreezeOpen(false)}>Отмена void freeze()}>Freeze adjudication}> + + setAdjudicatorId(event.target.value)} /> + + + + + setToasts((current) => current.filter((item) => item.id !== id))} /> + > + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx new file mode 100644 index 0000000..094d1eb --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindClipPlayer.tsx @@ -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): Rect { + const [rect, setRect] = useState({ 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, 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(null); + const host = useHostSize(hostRef); + const [playing, setPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + const [drawStart, setDrawStart] = useState(null); + const [drawCurrent, setDrawCurrent] = useState(null); + const [boxInteraction, setBoxInteraction] = useState(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) => { + 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) => { + 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 ( + + + ПРАВАЯ КАМЕРА · СИНХРОННО · КАДР {sequence} + + { + 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 ( + { + 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, + }); + }} + > + + {tracklet.objectId} + + ); + })} + {drawStart && drawCurrent ? ( + + ) : 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]) => ( + { + 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} + + >)} + alternativeScene={( + + + {mode === "3d" ? "3D LIDAR" : "ПЛАН LIDAR"} · КАДР {sequence} + + {spatialReady && spatialFrame ? ( + + ) : ( + + {spatialLoading ? : } + {spatialLoading + ? "Подготавливаем синхронный LiDAR-кадр" + : spatialError + ? spatialError + : spatialFrame && !spatialFrame.sourceAvailable + ? "Текущий LiDAR-кадр недоступен" + : spatialFrame && !spatialFrame.bodyFrameAvailable + ? "LiDAR в системе координат корпуса для этого кадра недоступен" + : "Исходные пространственные данные для этого кадра недоступны"} + + )} + + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx new file mode 100644 index 0000000..8f5a7d0 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48BlindReviewWorkspace.tsx @@ -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 { + 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; + dirty: boolean; +} + +export function M48CorrectionWorkspace({ + gate, + returnFocusTarget, + onClose, + onChanged, +}: { + gate: M48GateStatus; + returnFocusTarget?: HTMLElement | null; + onClose: () => void; + onChanged?: () => void; +}) { + const [catalog, setCatalog] = useState(null); + const [session, setSession] = useState(null); + const [drafts, setDrafts] = useState>(new Map()); + const [selectedClipId, setSelectedClipId] = useState(""); + const [sequence, setSequence] = useState(1); + const [mode, setMode] = useState("camera"); + const [cameraVisible, setCameraVisible] = useState(true); + const [selectedObjectId, setSelectedObjectId] = useState(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(null); + const [freezeOpen, setFreezeOpen] = useState(false); + const [reviewerId, setReviewerId] = useState(""); + const [candidateVisible, setCandidateVisible] = useState(false); + const [classFree, setClassFree] = useState(false); + const [toasts, setToasts] = useState([]); + const savingRef = useRef(false); + + const notify = useCallback((toast: Omit) => { + 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 = 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) => { + 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) => { + 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 ( + + + + + selectAdjacentClip(-1)} + > + + + = catalog.clips.length - 1 || busy} + onClick={() => selectAdjacentClip(1)} + > + + + + + ({ + 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} + /> + + {currentDraft && editable ? ( + + ) : null} + + + { + setSelectedObjectId(null); + setDrawing((value) => !value); + }} + > + + + + + + + + {selectedTracklet && editable ? ( + + + 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"])} /> + + + updateTrackState({ geometryAssociation: value as M48ReviewTracklet["stateSegments"][number]["geometryAssociation"] })} /> + + + updateTrackState({ freshness: value as M48ReviewTracklet["stateSegments"][number]["freshness"] })} /> + + + updateTrackState({ motion: value as M48ReviewTracklet["stateSegments"][number]["motion"] })} /> + + + updateTrackState({ threat: value as M48ReviewTracklet["stateSegments"][number]["threat"] })} /> + + + updateTrackState({ criticalCorridorObstacle })} /> + + { + setSelectedObjectId(null); + setDrawing(true); + }} + > + + + void save(drafts, "object-edit")} + > + + + { + if (!currentDraft) return; + setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets: currentDraft.tracklets.filter(({ objectId }) => objectId !== selectedTracklet.objectId) }); + setSelectedObjectId(null); + }}> + { + setSelectedObjectId(null); + setDrawing(false); + }} + > + + + + ) : null} + + )} + stage={( + + {loading ? ( + Загружаем клипы и frozen-candidate seed Worker 006 + ) : !catalog || !catalog.cameraPlayback || !clip ? ( + {error ?? "M4.8 источник недоступен."} + ) : ( + { + if (!currentDraft) return; + setCurrentDraft({ ...currentDraft, reviewState: "pending", noObject: null, tracklets }); + }} + /> + )} + {catalog ? ( + + ) : null} + {catalog ? ( + + + + {session + ? `Worker 006 · ${session.seedObjectCount.toLocaleString("ru-RU")} авторамок` + : "Загружаем авторазметку"} + + {clip ? `${clip.clipId} · кадр ${sequence}` : "Источник проверяется"} + + Исправьте авторамки; новая ручная рамка относится только к текущему кадру и не имитирует трекинг. + «Опасность» — немедленная угроза. «Проезд» — статическое ограничение, которое требует объезда или геометрического запаса. + + {busy ? "Сохраняем изменения" : dirty ? "Есть несохранённые изменения" : stateLabel(session)} + + {(error || spatialError) ? {error ?? spatialError} : null} + {session?.evidenceSummary ? ( + + Результат проверки Worker 006 + Подтверждено: {session.evidenceSummary.confirmedCandidateCount}/{session.evidenceSummary.seedObjectCount} + Исправлено: {session.evidenceSummary.modifiedCandidateCount} + Удалено лишних: {session.evidenceSummary.falsePositiveRemovedCount} + Добавлено пропущенных: {session.evidenceSummary.missedObjectAddedCount} + Это проверка авторазметки, а не независимая контрольная разметка. + + ) : null} + + ) : null} + + )} + overlays={( + <> + !busy && setFreezeOpen(false)} + footer={ setFreezeOpen(false)}>Отмена void freeze()}>{busy ? "Завершаем" : "Завершить проверку"}} + > + + setReviewerId(event.target.value)} /> + + + + + setToasts((current) => current.filter((item) => item.id !== id))} /> + > + )} + /> + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx new file mode 100644 index 0000000..20ba4b0 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/M48EvidenceModeControls.tsx @@ -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 { + 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 ( + + onCameraVisibleChange( + nextM48CameraVisibility(mode, cameraVisible), + )} + > + + + { + if (!spatialAvailable) return; + onModeChange(nextM48SpatialMode(mode, cameraVisible, "3d")); + }} + > + 3D + + { + if (!spatialAvailable) return; + onModeChange(nextM48SpatialMode(mode, cameraVisible, "plan")); + }} + > + + + + ); +} + +export function M48EvidenceModeRail(props: M48EvidenceModeControlProps) { + return ( + + + + ); +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx b/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx new file mode 100644 index 0000000..0ad230e --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/useM48ReviewCapability.tsx @@ -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(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: ( + setOpen(false)} + onChanged={refresh} + /> + ), + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts b/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts new file mode 100644 index 0000000..6d72f53 --- /dev/null +++ b/apps/control-station/src/workspaces/laboratory/annotation/useM48SpatialClipPlayback.ts @@ -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( + cache: Map, + 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()); + const errorsRef = useRef(new Map()); + const inFlightRef = useRef(new Map>()); + const controllerRef = useRef(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 => { + 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, + }; +} diff --git a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts index 06fe8e7..f0c8a6b 100644 --- a/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts +++ b/apps/control-station/src/workspaces/laboratory/laboratoryArchiveProfiles.ts @@ -63,6 +63,20 @@ interface KnownWorkDefinition { const rig = (rigLabel: string): string => rigLabel.trim() || "Сенсорный риг"; const KNOWN_WORKS: Readonly, 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`, diff --git a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts index 5412bdb..fa99040 100644 --- a/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts +++ b/apps/control-station/src/workspaces/laboratory/useAdvancedLaboratoryCatalog.ts @@ -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); diff --git a/apps/control-station/test/advancedLaboratoryResults.test.mjs b/apps/control-station/test/advancedLaboratoryResults.test.mjs index 9b2cd88..032c547 100644 --- a/apps/control-station/test/advancedLaboratoryResults.test.mjs +++ b/apps/control-station/test/advancedLaboratoryResults.test.mjs @@ -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", diff --git a/apps/control-station/test/laboratoryProductUi.test.mjs b/apps/control-station/test/laboratoryProductUi.test.mjs index 7f96729..462fcf1 100644 --- a/apps/control-station/test/laboratoryProductUi.test.mjs +++ b/apps/control-station/test/laboratoryProductUi.test.mjs @@ -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, diff --git a/apps/control-station/test/m48ObjectCentricQuality.test.mjs b/apps/control-station/test/m48ObjectCentricQuality.test.mjs new file mode 100644 index 0000000..4693109 --- /dev/null +++ b/apps/control-station/test/m48ObjectCentricQuality.test.mjs @@ -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, /3D<\/span>/); + assert.doesNotMatch(modeControls, / { + 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*\(| { + 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, /\s*Добавить объект\s*<\/Button>/); + assert.ok(review.indexOf('label="Предыдущий клип"') < review.indexOf('\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 \?
{renderError}
{description}