feat(lab): complete E30 evidence review gate

This commit is contained in:
DCCONSTRUCTIONS
2026-07-27 11:00:32 +03:00
parent a44d7627fd
commit 001d597a89
55 changed files with 15897 additions and 1548 deletions
@@ -0,0 +1,468 @@
import { useEffect, useRef, useState } from "react";
import { Button, Icon } from "@nodedc/ui-react";
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
interface E30EvidencePointCloudProps {
detail: E30ReviewItemDetail;
}
function tokenColor(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
): THREE.Color {
const value = getComputedStyle(host).getPropertyValue(token).trim();
if (value.startsWith("#")) {
return new THREE.Color(value);
}
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
const [red, green, blue] = channels?.length === 3
? channels
: fallback;
return new THREE.Color(red / 255, green / 255, blue / 255);
}
function createPointTexture(): THREE.CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const context = canvas.getContext("2d");
if (context) {
const gradient = context.createRadialGradient(32, 32, 2, 32, 32, 30);
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
gradient.addColorStop(0.72, "rgba(255, 255, 255, 0.94)");
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 64, 64);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
function toMapScenePositions(
pointsMapXyzM: readonly (readonly [number, number, number])[],
positionMapXyzM: readonly [number, number, number],
): Float32Array {
const positions = new Float32Array(pointsMapXyzM.length * 3);
pointsMapXyzM.forEach(([mapX, mapY, mapZ], index) => {
const offset = index * 3;
positions[offset] = mapX - positionMapXyzM[0];
positions[offset + 1] = mapZ - positionMapXyzM[2];
positions[offset + 2] = -(mapY - positionMapXyzM[1]);
});
return positions;
}
function boundsFromPositions(positions: Float32Array): THREE.Box3 {
const bounds = new THREE.Box3();
const point = new THREE.Vector3();
for (let offset = 0; offset < positions.length; offset += 3) {
point.set(positions[offset], positions[offset + 1], positions[offset + 2]);
bounds.expandByPoint(point);
}
return bounds;
}
function evidenceViewFromPositions(positions: Float32Array): {
target: THREE.Vector3;
radius: number;
} {
if (!positions.length) {
return { target: new THREE.Vector3(), radius: 0.65 };
}
const xValues: number[] = [];
const yValues: number[] = [];
const zValues: number[] = [];
for (let offset = 0; offset < positions.length; offset += 3) {
xValues.push(positions[offset]);
yValues.push(positions[offset + 1]);
zValues.push(positions[offset + 2]);
}
const target = new THREE.Vector3(
percentile(xValues, 0.5),
percentile(yValues, 0.5),
percentile(zValues, 0.5),
);
const radii = xValues.map((x, index) => Math.hypot(
x - target.x,
yValues[index] - target.y,
zValues[index] - target.z,
));
return {
target,
radius: Math.max(percentile(radii, 0.9), 0.65),
};
}
function percentile(values: readonly number[], fraction: number): number {
if (!values.length) return 0;
const sorted = [...values].sort((left, right) => left - right);
const index = Math.min(
sorted.length - 1,
Math.max(0, Math.floor((sorted.length - 1) * fraction)),
);
return sorted[index];
}
export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const contextGeometryRef = useRef<THREE.BufferGeometry | null>(null);
const rejectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
const selectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
const contextMaterialRef = useRef<THREE.PointsMaterial | null>(null);
const rejectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
const selectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const viewTargetRef = useRef(new THREE.Vector3());
const viewDistanceRef = useRef(4);
const [renderError, setRenderError] = useState<string | null>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance",
});
} catch {
setRenderError("Браузер не смог создать WebGL-сцену доказательства E30.");
return;
}
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",
"Интерактивное 3D-доказательство E30",
);
renderer.domElement.setAttribute("role", "img");
host.prepend(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500);
camera.position.set(4, 2.5, 4);
cameraRef.current = camera;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = true;
controls.enableZoom = true;
controls.screenSpacePanning = true;
controls.minDistance = 0.2;
controls.maxDistance = 300;
controls.minPolarAngle = 0.04;
controls.maxPolarAngle = Math.PI - 0.04;
controls.target.set(0, 0, 0);
controls.update();
controlsRef.current = controls;
const pointTexture = createPointTexture();
const contextGeometry = new THREE.BufferGeometry();
const contextMaterial = new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
map: pointTexture,
alphaTest: 0.04,
size: 2.2,
sizeAttenuation: false,
transparent: true,
opacity: 0.34,
depthWrite: false,
});
const contextPoints = new THREE.Points(contextGeometry, contextMaterial);
contextPoints.renderOrder = 0;
scene.add(contextPoints);
contextGeometryRef.current = contextGeometry;
contextMaterialRef.current = contextMaterial;
const rejectedGeometry = new THREE.BufferGeometry();
const rejectedMaterial = new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
map: pointTexture,
alphaTest: 0.04,
size: 5.5,
sizeAttenuation: false,
transparent: true,
opacity: 0.98,
depthWrite: true,
});
const rejectedPoints = new THREE.Points(rejectedGeometry, rejectedMaterial);
rejectedPoints.renderOrder = 1;
scene.add(rejectedPoints);
rejectedGeometryRef.current = rejectedGeometry;
rejectedMaterialRef.current = rejectedMaterial;
const selectedGeometry = new THREE.BufferGeometry();
const selectedMaterial = new THREE.PointsMaterial({
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
map: pointTexture,
alphaTest: 0.04,
size: 10.5,
sizeAttenuation: false,
transparent: true,
opacity: 1,
depthTest: false,
depthWrite: false,
});
const selectedPoints = new THREE.Points(selectedGeometry, selectedMaterial);
selectedPoints.renderOrder = 3;
scene.add(selectedPoints);
selectedGeometryRef.current = selectedGeometry;
selectedMaterialRef.current = selectedMaterial;
const grid = new THREE.GridHelper(
10,
20,
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
);
const gridMaterials = Array.isArray(grid.material)
? grid.material
: [grid.material];
gridMaterials.forEach((material) => {
material.transparent = true;
material.opacity = 0.16;
material.depthWrite = false;
});
gridRef.current = grid;
scene.add(grid);
const sensorMarkerGeometry = new THREE.RingGeometry(0.08, 0.12, 32);
const sensorMarkerMaterial = new THREE.MeshBasicMaterial({
color: tokenColor(host, "--nodedc-text-secondary", [185, 187, 192]),
transparent: true,
opacity: 0.64,
side: THREE.DoubleSide,
depthWrite: false,
});
const sensorMarker = new THREE.Mesh(
sensorMarkerGeometry,
sensorMarkerMaterial,
);
sensorMarker.rotation.x = -Math.PI / 2;
sensorMarker.renderOrder = 3;
scene.add(sensorMarker);
const resize = () => {
const width = Math.max(host.clientWidth, 1);
const height = Math.max(host.clientHeight, 1);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(host);
resize();
let animationFrame = 0;
const render = () => {
animationFrame = window.requestAnimationFrame(render);
controls.update();
renderer.render(scene, camera);
};
render();
return () => {
window.cancelAnimationFrame(animationFrame);
resizeObserver.disconnect();
controls.dispose();
contextGeometry.dispose();
contextMaterial.dispose();
rejectedGeometry.dispose();
rejectedMaterial.dispose();
selectedGeometry.dispose();
selectedMaterial.dispose();
grid.geometry.dispose();
gridMaterials.forEach((material) => material.dispose());
sensorMarkerGeometry.dispose();
sensorMarkerMaterial.dispose();
pointTexture.dispose();
renderer.dispose();
renderer.domElement.remove();
contextGeometryRef.current = null;
rejectedGeometryRef.current = null;
selectedGeometryRef.current = null;
contextMaterialRef.current = null;
rejectedMaterialRef.current = null;
selectedMaterialRef.current = null;
cameraRef.current = null;
controlsRef.current = null;
gridRef.current = null;
};
}, []);
useEffect(() => {
const host = hostRef.current;
const contextGeometry = contextGeometryRef.current;
const rejectedGeometry = rejectedGeometryRef.current;
const selectedGeometry = selectedGeometryRef.current;
const contextMaterial = contextMaterialRef.current;
const rejectedMaterial = rejectedMaterialRef.current;
const selectedMaterial = selectedMaterialRef.current;
const camera = cameraRef.current;
const controls = controlsRef.current;
const grid = gridRef.current;
if (
!host
|| !contextGeometry
|| !rejectedGeometry
|| !selectedGeometry
|| !contextMaterial
|| !rejectedMaterial
|| !selectedMaterial
|| !camera
|| !controls
|| !grid
) return;
const contextPositions = toMapScenePositions(
detail.projection.pointsMapXyzM,
detail.pose.positionMapXyzM,
);
const selectedPositions = toMapScenePositions(
detail.selected.pointsMapXyzM,
detail.pose.positionMapXyzM,
);
const selectedIndices = new Set(detail.selected.sourceIndices);
const rejectedPointsMap = detail.candidate.pointsMapXyzM.filter(
(_point, index) => !selectedIndices.has(
detail.candidate.sourceIndices[index] ?? -1,
),
);
const rejectedPositions = toMapScenePositions(
rejectedPointsMap,
detail.pose.positionMapXyzM,
);
contextGeometry.setAttribute(
"position",
new THREE.BufferAttribute(contextPositions, 3),
);
rejectedGeometry.setAttribute(
"position",
new THREE.BufferAttribute(rejectedPositions, 3),
);
selectedGeometry.setAttribute(
"position",
new THREE.BufferAttribute(selectedPositions, 3),
);
contextGeometry.computeBoundingSphere();
rejectedGeometry.computeBoundingSphere();
selectedGeometry.computeBoundingSphere();
rejectedMaterial.color.copy(
tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
);
selectedMaterial.color.copy(
tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
);
const evidencePositions = selectedPositions.length || rejectedPositions.length
? new Float32Array([...rejectedPositions, ...selectedPositions])
: contextPositions;
const evidenceBounds = boundsFromPositions(evidencePositions);
const contextBounds = boundsFromPositions(contextPositions);
const evidenceView = evidenceViewFromPositions(evidencePositions);
const target = evidenceView.target;
const evidenceRadius = evidenceView.radius;
const evidenceSize = evidenceBounds.isEmpty()
? new THREE.Vector3(1, 1, 1)
: evidenceBounds.getSize(new THREE.Vector3());
const contextSize = contextBounds.isEmpty()
? evidenceSize
: contextBounds.getSize(new THREE.Vector3());
const contextRadius = Math.max(contextSize.length() / 2, evidenceRadius);
const distance = Math.max(evidenceRadius * 2.45, 2.3);
viewTargetRef.current.copy(target);
viewDistanceRef.current = distance;
controls.target.copy(target);
camera.position.set(
target.x + distance * 0.86,
target.y + distance * 0.52,
target.z + distance * 0.86,
);
camera.near = Math.max(distance / 2_000, 0.005);
camera.far = Math.max(contextRadius * 12, distance * 40, 120);
camera.updateProjectionMatrix();
controls.maxDistance = Math.max(contextRadius * 5, distance * 5, 40);
controls.update();
const contextHeights: number[] = [];
for (let offset = 1; offset < contextPositions.length; offset += 3) {
contextHeights.push(contextPositions[offset]);
}
const groundHeight = percentile(contextHeights, 0.04);
const gridSize = THREE.MathUtils.clamp(evidenceRadius * 7, 8, 48);
grid.position.set(target.x, groundHeight, target.z);
grid.scale.setScalar(gridSize / 10);
}, [detail]);
const resetCamera = () => {
const camera = cameraRef.current;
const controls = controlsRef.current;
if (!camera || !controls) return;
const target = viewTargetRef.current;
const distance = viewDistanceRef.current;
controls.target.copy(target);
camera.position.set(
target.x + distance * 0.86,
target.y + distance * 0.52,
target.z + distance * 0.86,
);
controls.update();
};
const selectedIndices = new Set(detail.selected.sourceIndices);
const rejectedCount = detail.candidate.sourceIndices.filter(
(sourceIndex) => !selectedIndices.has(sourceIndex),
).length;
return (
<div className="e30-evidence-scene" data-testid="e30-evidence-3d">
<div ref={hostRef} className="e30-evidence-scene__viewport">
{renderError ? (
<p className="e30-evidence-scene__error">{renderError}</p>
) : null}
</div>
<div className="e30-evidence-scene__toolbar">
<Button
variant="secondary"
size="compact"
icon={<Icon name="refresh" size={14} />}
onClick={resetCamera}
>
Сбросить ракурс
</Button>
<div className="e30-evidence-scene__gestures" aria-label="Управление 3D-сценой">
<span>ЛКМ · вращение</span>
<span>Колесо · масштаб</span>
<span>ПКМ · панорама</span>
</div>
</div>
<div className="e30-evidence-scene__legend" aria-label="Легенда 3D-доказательства">
<span data-point="context">
Контекст · {detail.projection.pointsMapXyzM.length}
</span>
<span data-point="rejected">Отклонено · {rejectedCount}</span>
<span data-point="selected">
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
</span>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from "react";
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
function tokenColor(host: HTMLElement, token: string, fallback: string): string {
return getComputedStyle(host).getPropertyValue(token).trim() || fallback;
}
function tokenRgb(
host: HTMLElement,
token: string,
fallback: readonly [number, number, number],
alpha = 1,
): string {
const value = getComputedStyle(host).getPropertyValue(token).trim();
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
return `rgb(${red} ${green} ${blue} / ${alpha})`;
}
function depthColor(
depth: number,
minimumDepth: number,
maximumDepth: number,
): string {
const span = Math.max(maximumDepth - minimumDepth, 0.001);
const position = Math.min(1, Math.max(0, (depth - minimumDepth) / span));
const hue = 220 - position * 205;
return `hsl(${hue} 88% 62% / 0.78)`;
}
export function E30EvidenceProjection({
detail,
projectionWidth,
projectionHeight,
pointLayerVisible,
}: {
detail: E30ReviewItemDetail;
projectionWidth: number;
projectionHeight: number;
pointLayerVisible: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [frameState, setFrameState] = useState<
"loading" | "ready" | "unavailable"
>(detail.cameraFrame ? "loading" : "unavailable");
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
const host = canvas?.parentElement;
if (!canvas || !context || !host) return;
let cancelled = false;
const canvasWidth = 1_200;
const canvasHeight = Math.round(
canvasWidth * projectionHeight / projectionWidth,
);
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const scaleX = canvasWidth / projectionWidth;
const scaleY = canvasHeight / projectionHeight;
const depths = detail.projection.depthM.filter(Number.isFinite);
const minimumDepth = depths.length ? Math.min(...depths) : 0;
const maximumDepth = depths.length ? Math.max(...depths) : 1;
const draw = (image: HTMLImageElement | null) => {
if (cancelled) return;
context.clearRect(0, 0, canvasWidth, canvasHeight);
if (image) {
context.drawImage(image, 0, 0, canvasWidth, canvasHeight);
if (pointLayerVisible) {
context.fillStyle = "rgb(0 0 0 / 0.08)";
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
} else {
context.fillStyle = tokenColor(host, "--nodedc-canvas", "#050506");
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
if (pointLayerVisible) {
detail.projection.pixelsXy.forEach(([sourceX, sourceY], index) => {
if (
sourceX < 0
|| sourceX > projectionWidth
|| sourceY < 0
|| sourceY > projectionHeight
) return;
const selected = detail.projection.selectedMask[index] === 1;
const candidate = detail.projection.candidateMask[index] === 1;
const x = sourceX * scaleX;
const y = sourceY * scaleY;
context.fillStyle = selected
? tokenRgb(host, "--nodedc-accent-rgb", [247, 248, 244])
: candidate
? tokenRgb(host, "--nodedc-warning-rgb", [255, 209, 102])
: depthColor(
detail.projection.depthM[index] ?? minimumDepth,
minimumDepth,
maximumDepth,
);
context.beginPath();
context.arc(
x,
y,
selected ? 6.5 : candidate ? 4.25 : 2.1,
0,
Math.PI * 2,
);
context.fill();
});
}
const bbox = detail.snapshot.bboxXyxy;
if (bbox) {
context.strokeStyle = tokenRgb(
host,
detail.stratum === "conflict"
? "--nodedc-danger-rgb"
: "--nodedc-accent-rgb",
detail.stratum === "conflict"
? [255, 98, 92]
: [247, 248, 244],
);
context.lineWidth = 2.5;
context.strokeRect(
bbox[0] * scaleX,
bbox[1] * scaleY,
(bbox[2] - bbox[0]) * scaleX,
(bbox[3] - bbox[1]) * scaleY,
);
}
};
if (!detail.cameraFrame) {
setFrameState("unavailable");
draw(null);
return () => {
cancelled = true;
};
}
setFrameState("loading");
const image = new Image();
image.decoding = "async";
image.onload = () => {
if (cancelled) return;
if (
image.naturalWidth !== detail.cameraFrame?.width
|| image.naturalHeight !== detail.cameraFrame?.height
) {
setFrameState("unavailable");
draw(null);
return;
}
setFrameState("ready");
draw(image);
};
image.onerror = () => {
if (cancelled) return;
setFrameState("unavailable");
draw(null);
};
image.src = detail.cameraFrame.url;
return () => {
cancelled = true;
image.onload = null;
image.onerror = null;
image.src = "";
};
}, [detail, pointLayerVisible, projectionHeight, projectionWidth]);
const rejectedCount = detail.projection.candidateMask.reduce(
(count, candidate, index) => (
count
+ Number(
candidate === 1
&& detail.projection.selectedMask[index] !== 1,
)
),
0,
);
return (
<div className="e30-projection-scene" data-testid="e30-evidence-camera">
<canvas
ref={canvasRef}
aria-label={pointLayerVisible
? "Камерный кадр с проекцией LiDAR и выбранным наблюдением E30"
: "Исходный камерный кадр с рамкой наблюдения E30"}
/>
{frameState !== "ready" ? (
<div className="e30-projection-scene__state" role="status">
{frameState === "loading"
? "Проверяем точный кадр камеры…"
: "Точный кадр камеры не материализован"}
</div>
) : null}
{pointLayerVisible ? (
<div
className="e30-evidence-scene__legend"
aria-label="Легенда camera-LiDAR доказательства"
>
<span data-point="depth">
LiDAR · глубина · {detail.projection.pointsMapXyzM.length}
</span>
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
<span data-point="selected">
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
</span>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,283 @@
import { useEffect, useState } from "react";
import {
Button,
ConfirmationModal,
Select,
StatusBadge,
TextAreaField,
} from "@nodedc/ui-react";
import {
createOrResumeE30HumanReview,
finalizeE30HumanReview,
saveE30HumanReviewDecision,
type E30ExceptionDisposition,
type E30HumanReviewDraft,
} from "../core/laboratory/e30HumanReview";
import type { E30EngineeringGeneration } from "../core/laboratory/e30Engineering";
import type {
E30ReviewItemDetail,
E30ReviewResult,
} from "../core/laboratory/e30Review";
const DISPOSITION_OPTIONS: readonly {
value: E30ExceptionDisposition;
label: string;
}[] = [
{ value: "object-present", label: "Объект есть" },
{ value: "background-or-noise", label: "Фон или шум" },
{ value: "insufficient-evidence", label: "Недостаточно данных" },
];
const FALLBACK_REVIEW_PROMPT = {
question: "Белый кластер — самостоятельное физическое препятствие?",
focus: (
"Сопоставьте выбранные белые точки с исходным кадром и решите, "
+ "принадлежат ли они занятой геометрии реального объекта."
),
effects: {
"object-present": "Сохранить кластер как занятую геометрию.",
"background-or-noise": "Исключить кластер как фон или шум.",
"insufficient-evidence": "Оставить кейс неизвестным без настройки порогов.",
},
} as const;
function decisionFor(
review: E30HumanReviewDraft | null,
itemId: string | undefined,
) {
return itemId
? review?.decisions.find((decision) => decision.itemId === itemId) ?? null
: null;
}
export function E30HumanReviewPanel({
result,
generation,
item,
review,
onReviewChange,
onDecisionSaved,
}: {
result: E30ReviewResult;
generation: E30EngineeringGeneration;
item: E30ReviewItemDetail | null;
review: E30HumanReviewDraft | null;
onReviewChange: (review: E30HumanReviewDraft) => void;
onDecisionSaved: (review: E30HumanReviewDraft) => void;
}) {
const [disposition, setDisposition] =
useState<E30ExceptionDisposition>("object-present");
const [notes, setNotes] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [finalizeOpen, setFinalizeOpen] = useState(false);
const currentDecision = decisionFor(review, item?.itemId);
const reviewPrompt = generation.humanExceptions.find(
(exception) => exception.itemId === item?.itemId,
)?.reviewPrompt ?? FALLBACK_REVIEW_PROMPT;
useEffect(() => {
setDisposition(currentDecision?.disposition ?? "object-present");
setNotes(currentDecision?.notes ?? "");
setError(null);
}, [currentDecision, item?.itemId]);
const begin = async () => {
if (pending) return;
setPending(true);
setError(null);
try {
onReviewChange(await createOrResumeE30HumanReview(
result.resultId,
generation.generationId,
));
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Проверка недоступна.",
);
} finally {
setPending(false);
}
};
const save = async () => {
if (!review || !item || review.state !== "active" || pending) return;
setPending(true);
setError(null);
try {
const next = await saveE30HumanReviewDecision(
result.resultId,
generation.generationId,
review.draftId,
item.itemId,
{
expectedRevision: review.revision,
idempotencyKey: `ui-${crypto.randomUUID()}`,
disposition,
notes: notes.trim() || null,
},
);
onReviewChange(next);
onDecisionSaved(next);
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Решение не сохранено.",
);
} finally {
setPending(false);
}
};
const finalize = async () => {
if (!review || review.state !== "active") return;
setError(null);
try {
const finalized = await finalizeE30HumanReview(
result.resultId,
generation.generationId,
review.draftId,
review.revision,
);
onReviewChange(finalized.draft);
setFinalizeOpen(false);
} catch (caught) {
setError(
caught instanceof Error ? caught.message : "Проверка не зафиксирована.",
);
throw caught;
}
};
if (!review) {
return (
<section className="e30-human-review" aria-label="Проверка исключений">
<header className="e30-human-review__header">
<div>
<span className="section-eyebrow">ТРЕБУЕТСЯ РЕШЕНИЕ</span>
<h3>{reviewPrompt.question}</h3>
<p>{reviewPrompt.focus}</p>
</div>
<StatusBadge tone="warning">
0 / {generation.summary.humanExceptionCount}
</StatusBadge>
</header>
<div className="e30-human-review__actions">
<span>
Решение изменит только отдельную A3-коррекцию; A2 останется
неизменным.
</span>
<Button
variant="primary"
disabled={pending}
onClick={() => void begin()}
>
{pending ? "Открываем…" : "Начать проверку"}
</Button>
</div>
{error ? (
<p className="e30-human-review__error" role="alert">{error}</p>
) : null}
</section>
);
}
const finalized = review.state === "finalized";
return (
<section className="e30-human-review" aria-label="Проверка исключений">
<header className="e30-human-review__header">
<div>
<span className="section-eyebrow">ПРОВЕРКА ИСКЛЮЧЕНИЙ</span>
<h3>
{finalized ? "Проверка зафиксирована" : reviewPrompt.question}
</h3>
{!finalized ? <p>{reviewPrompt.focus}</p> : null}
</div>
<StatusBadge tone={finalized ? "success" : "accent"}>
{review.reviewedItemCount} / {review.itemCount}
</StatusBadge>
</header>
{finalized ? (
<p>
Все спорные кадры получили отдельное человеческое решение.
Исходные доказательства сохранены без изменений.
</p>
) : item ? (
<>
<div className="e30-human-review__form">
<div className="e30-human-review__field">
<span>Решение</span>
<Select
label="Что видно на выбранном кадре"
value={disposition}
options={[...DISPOSITION_OPTIONS]}
variant="split"
menuWidth="anchor"
disabled={pending}
onChange={setDisposition}
/>
</div>
<TextAreaField
label="Комментарий"
hint="необязательно"
value={notes}
rows={2}
maxLength={2_000}
disabled={pending}
onChange={(event) => setNotes(event.currentTarget.value)}
/>
</div>
<div className="e30-human-review__impact">
<span>Что изменится после решения</span>
<strong>{reviewPrompt.effects[disposition]}</strong>
</div>
<div className="e30-human-review__actions">
<span>
{currentDecision
? "Этот кадр уже решён — его можно пересмотреть."
: `${review.remainingItemCount} решений осталось.`}
</span>
<Button
variant="primary"
disabled={pending}
onClick={() => void save()}
>
{pending
? "Сохраняем…"
: currentDecision
? "Обновить кадр"
: "Сохранить кадр"}
</Button>
<Button
variant="secondary"
disabled={review.remainingItemCount !== 0 || pending}
onClick={() => setFinalizeOpen(true)}
>
Зафиксировать проверку
</Button>
</div>
</>
) : null}
{error ? (
<p className="e30-human-review__error" role="alert">{error}</p>
) : null}
<ConfirmationModal
open={finalizeOpen}
title="Зафиксировать проверку?"
description={(
<p>
Будет создан неизменяемый набор из {review.itemCount} решений.
После фиксации их нельзя будет изменить.
</p>
)}
confirmLabel="Зафиксировать"
pendingLabel="Фиксируем…"
onClose={() => setFinalizeOpen(false)}
onConfirm={finalize}
/>
</section>
);
}
@@ -0,0 +1,362 @@
import { useEffect, useState } from "react";
import {
Button,
GlassSurface,
Icon,
SegmentedControl,
StatusBadge,
} from "@nodedc/ui-react";
import { LaboratoryEvidenceViewer } from "../components/laboratory/LaboratoryEvidenceViewer";
import { E30EvidenceTelemetry } from "../components/laboratory/E30EvidenceTelemetry";
import { E30EngineeringGenerationSummary } from "../components/laboratory/E30EngineeringGenerationSummary";
import {
fetchE30EngineeringCatalog,
fetchE30EngineeringExceptions,
type E30EngineeringGeneration,
} from "../core/laboratory/e30Engineering";
import type { E30HumanReviewDraft } from "../core/laboratory/e30HumanReview";
import {
E30_STRATA,
fetchE30ReviewItemDetail,
fetchE30ReviewItems,
type E30ReviewItem,
type E30ReviewItemDetail,
type E30ReviewResult,
type E30Stratum,
} from "../core/laboratory/e30Review";
import { formatNumber } from "../presentation";
import { E30EvidencePointCloud } from "./E30EvidencePointCloud";
import { E30EvidenceProjection } from "./E30EvidenceProjection";
import { E30HumanReviewPanel } from "./E30HumanReviewPanel";
type E30EvidenceMode = "camera" | "3d";
type E30Filter = E30Stratum | "review";
const FILTER_LABELS: Record<E30Filter, string> = {
conflict: "Конфликт",
agree: "Согласовано",
"camera-only": "Только камера",
unknown: "Неизвестно",
"geometry-only": "Только геометрия",
review: "Проверка",
};
const FILTERS: readonly E30Filter[] = [...E30_STRATA, "review"];
function formatSeconds(value: number): string {
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
}
function itemTitle(item: E30ReviewItem): string {
if (item.snapshot.label) return item.snapshot.label;
return item.locatorKind === "geometry-only-cluster"
? "Геометрический кластер"
: "Семантическое наблюдение";
}
function evidenceRange(item: E30ReviewItem): string {
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
return value === null
? "Дальность недоступна"
: `${value.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
}
export function E30ReviewWorkspace({
result,
}: {
result: E30ReviewResult;
}) {
const [filter, setFilter] = useState<E30Filter>("conflict");
const [items, setItems] = useState<readonly E30ReviewItem[]>([]);
const [itemTotal, setItemTotal] = useState(result.stratumCounts.conflict);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
const [itemsLoading, setItemsLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [evidenceMode, setEvidenceMode] = useState<E30EvidenceMode>("camera");
const [pointLayerVisible, setPointLayerVisible] = useState(true);
const [viewerExpanded, setViewerExpanded] = useState(false);
const [engineeringGeneration, setEngineeringGeneration] =
useState<E30EngineeringGeneration | null>(null);
const [engineeringLoading, setEngineeringLoading] = useState(true);
const [humanReview, setHumanReview] =
useState<E30HumanReviewDraft | null>(null);
useEffect(() => {
if (filter === "review" && !engineeringGeneration) {
setItems([]);
setItemTotal(0);
setSelectedItemId(null);
setItemsLoading(engineeringLoading);
return;
}
const controller = new AbortController();
setItemsLoading(true);
setError(null);
setDetail(null);
const request = filter === "review"
? fetchE30EngineeringExceptions(
result.resultId,
engineeringGeneration!.generationId,
{ signal: controller.signal },
)
: fetchE30ReviewItems(result.resultId, filter, {
signal: controller.signal,
});
void request.then((next) => {
setItems(next.items);
setItemTotal(next.total);
setSelectedItemId((current) => (
next.items.some((item) => item.itemId === current)
? current
: next.items[0]?.itemId ?? null
));
}).catch((caught: unknown) => {
if (controller.signal.aborted) return;
setItems([]);
setSelectedItemId(null);
setError(caught instanceof Error ? caught.message : "Выборка E30 недоступна.");
}).finally(() => {
if (!controller.signal.aborted) setItemsLoading(false);
});
return () => controller.abort();
}, [
engineeringGeneration,
engineeringLoading,
filter,
result.resultId,
]);
useEffect(() => {
const controller = new AbortController();
setEngineeringLoading(true);
setEngineeringGeneration(null);
void fetchE30EngineeringCatalog(result.resultId, {
signal: controller.signal,
}).then((catalog) => {
setEngineeringGeneration(catalog.items[0] ?? null);
}).catch(() => {
if (!controller.signal.aborted) setEngineeringGeneration(null);
}).finally(() => {
if (!controller.signal.aborted) setEngineeringLoading(false);
});
return () => controller.abort();
}, [result.resultId]);
useEffect(() => {
if (!selectedItemId) {
setDetail(null);
return;
}
const controller = new AbortController();
setDetailLoading(true);
setError(null);
void fetchE30ReviewItemDetail(result.resultId, selectedItemId, {
signal: controller.signal,
}).then(setDetail).catch((caught: unknown) => {
if (controller.signal.aborted) return;
setDetail(null);
setError(caught instanceof Error ? caught.message : "Доказательство E30 недоступно.");
}).finally(() => {
if (!controller.signal.aborted) setDetailLoading(false);
});
return () => controller.abort();
}, [result.resultId, selectedItemId]);
const selectItem = (item: E30ReviewItem) => {
setSelectedItemId(item.itemId);
};
const advanceAfterDecision = (next: E30HumanReviewDraft) => {
const resolved = new Set(next.decisions.map((decision) => decision.itemId));
const currentIndex = items.findIndex((item) => item.itemId === selectedItemId);
const ordered = [
...items.slice(currentIndex + 1),
...items.slice(0, currentIndex + 1),
];
const unresolved = ordered.find((item) => !resolved.has(item.itemId));
if (unresolved) setSelectedItemId(unresolved.itemId);
};
return (
<GlassSurface
className="e30-review-workspace"
tone="soft"
padding="md"
materialRim={false}
role="region"
aria-label="Рабочее место ревью E30"
>
<header className="e30-review-workspace__header">
<div>
<span className="section-eyebrow">CAMERA-BACKED REVIEW SUBSTRATE</span>
<h2>A2 evidence · A3 engineering audit</h2>
<p>
Точный camera frame, LiDAR-проекция и синхронный 3D сохраняют A2
неизменяемым. A3 выпускает отдельные решения с явным provenance.
</p>
</div>
<StatusBadge tone={result.cameraEvidenceAvailable ? "accent" : "warning"}>
{result.cameraEvidenceAvailable
? "Camera evidence привязано"
: "Camera evidence отсутствует"}
</StatusBadge>
</header>
{engineeringGeneration ? (
<E30EngineeringGenerationSummary
generation={engineeringGeneration}
/>
) : null}
<SegmentedControl
className="e30-review-workspace__strata"
value={filter}
label="Группа E30"
items={FILTERS.map((value) => ({
value,
label: `${FILTER_LABELS[value]} · ${formatNumber(
value === "review"
? engineeringGeneration?.summary.humanExceptionCount ?? 0
: result.stratumCounts[value],
0,
)}`,
}))}
onChange={setFilter}
/>
<div className="e30-review-workspace__body">
<aside className="e30-review-workspace__items" aria-label="Кейсы выбранной страты">
<header>
<span>{FILTER_LABELS[filter]}</span>
<small>показано {items.length} из {itemTotal}</small>
</header>
<div className="e30-review-workspace__item-list">
{itemsLoading ? (
<div className="e30-review-workspace__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Проверяем индекс</span>
</div>
) : items.length ? items.map((item) => (
<Button
key={item.itemId}
variant="secondary"
size="compact"
width="full"
className="e30-review-workspace__item"
data-active={item.itemId === selectedItemId ? "true" : undefined}
aria-pressed={item.itemId === selectedItemId}
onClick={() => selectItem(item)}
>
<span>{itemTitle(item)}</span>
<strong>Кадр {formatNumber(item.sourceFrameIndex, 0)}</strong>
<small>
{formatSeconds(item.sessionSeconds)}
{" · "}
{evidenceRange(item)}
{filter === "review"
&& humanReview?.decisions.some(
(decision) => decision.itemId === item.itemId,
)
? " · Решено"
: ""}
</small>
</Button>
)) : (
<div className="e30-review-workspace__state">
<Icon name="database" size={18} />
<span>В этой страте кейсов нет</span>
</div>
)}
</div>
</aside>
<main className="e30-review-workspace__detail">
{detailLoading ? (
<div className="e30-review-workspace__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Проверяем camera-LiDAR доказательство</span>
</div>
) : error || !detail ? (
<div className="e30-review-workspace__state" role="status">
<Icon name="alert" size={18} />
<span>{error ?? "Выберите кейс."}</span>
</div>
) : (
<>
<header className="e30-review-workspace__case">
<div>
<span className="section-eyebrow">{detail.reviewKey}</span>
<h3>{itemTitle(detail)}</h3>
<p>
{detail.snapshot.geometryReason ?? "Независимый geometry-only слой"}
</p>
</div>
<StatusBadge tone={detail.stratum === "conflict" ? "danger" : "neutral"}>
{FILTER_LABELS[detail.stratum]}
</StatusBadge>
</header>
<div className="e30-review-evidence">
<LaboratoryEvidenceViewer
label="Доказательство E30"
mode={evidenceMode}
modes={[
{ value: "camera", label: "Камера" },
{ value: "3d", label: "3D" },
]}
expanded={viewerExpanded}
onModeChange={setEvidenceMode}
onExpandedChange={setViewerExpanded}
actions={evidenceMode === "camera" ? (
<Button
size="compact"
variant={pointLayerVisible ? "primary" : "secondary"}
icon={<Icon name="sliders" size={16} />}
aria-pressed={pointLayerVisible}
onClick={() => setPointLayerVisible((visible) => !visible)}
>
LiDAR
</Button>
) : undefined}
overlay={(
<E30EvidenceTelemetry
detail={detail}
mode={evidenceMode}
/>
)}
>
{evidenceMode === "camera" ? (
<E30EvidenceProjection
detail={detail}
projectionWidth={result.projection.width}
projectionHeight={result.projection.height}
pointLayerVisible={pointLayerVisible}
/>
) : (
<E30EvidencePointCloud detail={detail} />
)}
</LaboratoryEvidenceViewer>
</div>
</>
)}
</main>
</div>
{!detailLoading
&& !error
&& filter === "review"
&& engineeringGeneration
&& detail ? (
<E30HumanReviewPanel
result={result}
generation={engineeringGeneration}
item={detail}
review={humanReview}
onReviewChange={setHumanReview}
onDecisionSaved={advanceAfterDecision}
/>
) : null}
</GlassSurface>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
import type { ComponentType } from "react";
import type { ObservationSessionReplayCallbacks } from "../components/ObservationSessionSelect";
import type {
DeviceModelDefinition,
DevicePluginConnectionProps,
} from "../core/device-plugins/contracts";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
BackendStatus,
MissionRuntimeState,
} from "../core/runtime/contracts";
import type { WorkspaceDefinition } from "../productModel";
import type { SceneSettings } from "../sceneSettings";
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
activateAutomaticSpatialSource: () => void;
}
export interface WorkspaceRendererProps {
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
sourceUrl: string;
requestedPlaybackSeconds?: number | null;
recordedReplay: ObservationSessionReplayLaunch | null;
recordedSessionAdmission: RecordedSessionAdmissionController | null;
sceneSettings: SceneSettings;
accumulationSeconds: number;
onAccumulationChange: (value: number) => void;
onAccumulationCommit: () => void;
livePerceptionLayers: {
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
};
onLivePerceptionLayersChange: (next: {
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
}) => void;
observationLayout: ObservationLayoutController;
deviceLabel: string | null;
navigation: WorkspaceNavigation;
spatialControls: {
View: ComponentType<DevicePluginConnectionProps>;
model: DeviceModelDefinition;
} | null;
sessionArchive: ObservationSessionReplayCallbacks & {
disabled: boolean;
blockedReason: string | null;
};
}
@@ -0,0 +1,882 @@
import {
useEffect,
useMemo,
useState,
type ComponentType,
} from "react";
import { Icon, StatusBadge } from "@nodedc/ui-react";
import {
LaboratoryEvidence,
LaboratorySelector,
LaboratorySummary,
LaboratoryWorkTemplate,
type LaboratoryMethod,
type LaboratoryMethodComponent,
type LaboratoryOption,
} from "../../components/laboratory/LaboratoryPresentation";
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
import { useObservationSessions } from "../../core/observation/useObservationSessions";
import {
fetchE29EvidenceCatalog,
fetchE29EvidenceFrame,
type E29EvidenceFrame,
type E29EvidenceResult,
} from "../../core/laboratory/e29Evidence";
import {
fetchE30ReviewCatalog,
type E30ReviewResult,
} from "../../core/laboratory/e30Review";
import {
fetchLidarLocalSurfaces,
type LidarLocalSurfaceModel,
} from "../../core/lidar/localSurface";
import { formatNumber } from "../../presentation";
import { E30ReviewWorkspace } from "../E30ReviewWorkspace";
import { LidarQualityWorkspace } from "../LidarQualityWorkspace";
import type { WorkspaceRendererProps } from "../contracts";
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
SpatialView: ComponentType<WorkspaceRendererProps>;
};
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
type LaboratoryWorkId =
| "e28-local-surface"
| "e29-camera-geometry"
| "e30-evidence-review"
| `session:${string}`;
function digestFromContentId(value: string | null | undefined): string | null {
const digest = value?.split("-").at(-1) ?? "";
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
}
function publishedLaboratoryMethod(
session: ObservationSessionSummary,
): LaboratoryMethod {
const method = session.lab?.provenance.method;
if (method && typeof method === "object" && !Array.isArray(method)) {
const value = method as Record<string, unknown>;
const rawComponents = Array.isArray(value.components) ? value.components : [];
const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => {
if (!component || typeof component !== "object" || Array.isArray(component)) return [];
const item = component as Record<string, unknown>;
const kind = item.kind;
if (
kind !== "source"
&& kind !== "tool"
&& kind !== "model"
&& kind !== "algorithm"
&& kind !== "runtime"
) return [];
if (
typeof item.name !== "string"
|| typeof item.version !== "string"
|| typeof item.role !== "string"
) return [];
return [{
kind: kind as LaboratoryMethodComponent["kind"],
name: item.name,
version: item.version,
role: item.role,
identitySha256: typeof item.identity_sha256 === "string"
? item.identity_sha256
: null,
}];
});
const executionClass = value.execution_class;
const completeness = value.completeness;
if (
components.length
&& typeof value.pipeline_id === "string"
&& (
executionClass === "deterministic"
|| executionClass === "ai-inference"
|| executionClass === "hybrid"
)
&& (completeness === "complete" || completeness === "legacy-partial")
) {
return {
completeness,
executionClass,
pipelineId: value.pipeline_id,
components,
};
}
}
const resultKind = session.lab?.resultKind ?? "unknown";
const algorithmNames: Record<string, string> = {
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
"e21-realtime-envelope": "Bounded real-time perception replay",
"e22-temporal-stability": "Temporal 2D/3D/semantic stabilization",
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
"e24-world-motion": "World-frame motion tracking",
"e25-persistent-support-motion": "Persistent occupied-support tracking",
"e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support",
};
return {
completeness: "legacy-partial",
executionClass: "hybrid",
pipelineId: resultKind,
components: [
{
kind: "source",
name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id,
version: "immutable source evidence",
role: "read-only input",
identitySha256: digestFromContentId(session.lab?.sourceResultId),
},
{
kind: "algorithm",
name: algorithmNames[resultKind] ?? resultKind,
version: resultKind,
role: "laboratory derivative",
identitySha256: session.lab?.configSha256 ?? null,
},
],
};
}
function formatSeconds(value: number): string {
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
}
function E29LaboratoryResult({
props,
rigLabel,
result,
sourceSession,
loading,
error,
}: {
props: LaboratoryWorkspaceProps;
rigLabel: string;
result: E29EvidenceResult;
sourceSession: ObservationSessionSummary;
loading: boolean;
error: string | null;
}) {
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
result.reviewFrames[0]?.frameIndex ?? 0,
);
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
const [frameLoading, setFrameLoading] = useState(false);
const [frameError, setFrameError] = useState<string | null>(null);
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
const semantic = result.metrics.semanticObservations;
const geometryStatus = semantic.geometryStatus;
const conflicts = frame?.semanticObservations.filter(
(observation) => observation.geometryStatus === "conflict",
) ?? [];
useEffect(() => {
const controller = new AbortController();
setFrameLoading(true);
setFrameError(null);
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
signal: controller.signal,
}).then((next) => {
setFrame(next);
}).catch((caught: unknown) => {
if (controller.signal.aborted) return;
setFrame(null);
setFrameError(
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
);
}).finally(() => {
if (!controller.signal.aborted) setFrameLoading(false);
});
return () => controller.abort();
}, [result.resultId, selectedFrameIndex]);
return (
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title="LAB E29 · camera-first semantics + независимая геометрия"
description="Камера сохраняет класс и идентичность объекта, а LiDAR независимо подтверждает дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
status="Проверенные артефакты"
statusTone="success"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
{
label: "Источник",
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
},
{
label: "Наблюдений",
value: formatNumber(semantic.total, 0),
},
{
label: "Контур",
value: "Read-only · hash verified",
},
]}
method={{
completeness: "complete",
executionClass: "hybrid",
pipelineId: result.identity.profileId,
components: [
{
kind: "source",
name: result.linkedEvidence.sourceResultId,
version: "camera-first semantic observations",
role: "semantic identity and class",
identitySha256: digestFromContentId(result.linkedEvidence.sourceResultId),
},
{
kind: "algorithm",
name: "Camera/LiDAR local-surface validation",
version: result.identity.profileId,
role: "range, occupied support and conflict classification",
identitySha256: result.identity.producerSha256,
},
{
kind: "model",
name: result.linkedEvidence.localSurfaceModelId,
version: "L2.6 local surface",
role: "independent metric geometry",
identitySha256: digestFromContentId(
result.linkedEvidence.localSurfaceModelId,
),
},
],
}}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
title="LiDAR, траектория и камера RAVNOVES00"
kind="recorded-replay"
resizable
>
{replayReady ? (
<props.SpatialView {...props} />
) : (
<div className="laboratory-result-pending" role="status">
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
<strong>{loading ? "Проверяем и открываем запись" : "Исходная запись не открыта"}</strong>
<p>
{error ?? (loading
? "Viewer появится после серверной проверки неизменяемого RRD."
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
</p>
</div>
)}
</LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
</div>
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
</StatusBadge>
</header>
<div className="laboratory-result-metrics">
<div>
<span>Поддержка геометрией</span>
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
</div>
<div>
<span>Только камера</span>
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
<small>Семантика без LiDAR-подтверждения</small>
</div>
<div>
<span>Postprocess p95</span>
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
</div>
<div>
<span>Только геометрия</span>
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
</div>
</div>
<p>{result.decision.nextGate}</p>
</section>
)}
details={(
<section className="laboratory-frame-review">
<header>
<div>
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
</div>
<StatusBadge tone="warning">
{formatNumber(geometryStatus.conflict, 0)} конфликтов
</StatusBadge>
</header>
<div className="laboratory-frame-review__picker" role="list">
{result.reviewFrames.slice(0, 16).map((review) => (
<button
key={review.frameIndex}
type="button"
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
onClick={() => setSelectedFrameIndex(review.frameIndex)}
>
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
</button>
))}
</div>
{frameLoading ? (
<div className="laboratory-frame-review__state" role="status">
<span className="busy-indicator" aria-hidden="true" />
<span>Читаем подтверждённый кадр</span>
</div>
) : frameError || !frame ? (
<div className="laboratory-frame-review__state" role="status">
<Icon name="database" size={18} />
<span>{frameError ?? "Кадр недоступен."}</span>
</div>
) : (
<div className="laboratory-frame-review__detail">
<div>
<span>Кадр источника</span>
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
<small>{formatSeconds(frame.sessionSeconds)}</small>
</div>
<div>
<span>Семантические наблюдения</span>
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
</div>
<div>
<span>Geometry-only компоненты</span>
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
<small>Класс не назначается</small>
</div>
<div className="laboratory-frame-review__conflicts">
<span>Фактические конфликты</span>
{conflicts.length ? conflicts.map((observation) => (
<p key={observation.trackId}>
<strong>{observation.label} · track {observation.trackId}</strong>
<small>
{observation.geometryReason} · classified {observation.support.classifiedPoints}
{" · "}surface {observation.support.surfacePoints}
</small>
</p>
)) : <small>В этом кадре конфликт не найден.</small>}
</div>
</div>
)}
</section>
)}
/>
);
}
function E30LaboratoryResult({
rigLabel,
result,
sourceSession,
}: {
rigLabel: string;
result: E30ReviewResult;
sourceSession: ObservationSessionSummary;
}) {
return (
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title="LAB E30 · рабочее место evidence review"
description="A2 связывает каждый кейс E29 с точным camera frame, LiDAR-проекцией и frame-local индексами. Камера отвечает на вопрос «что видит детектор», синхронный 3D проверяет принадлежность и форму точек."
status="Camera evidence проверено"
statusTone="success"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · A2` },
{ label: "Источник", value: sourceSession.label },
{ label: "Кейсов", value: formatNumber(result.itemCount, 0) },
{ label: "Контур", value: "Read-only · без LAB publish" },
]}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="CAMERA + LIDAR ДОКАЗАТЕЛЬСТВО"
title="Точный кадр, проекция и синхронный 3D"
kind="diagnostic-model"
>
<E30ReviewWorkspace result={result} />
</LaboratoryEvidence>
)}
/>
);
}
function formatLaboratoryMetric(value: unknown): string {
if (typeof value === "boolean") return value ? "Да" : "Нет";
if (typeof value === "number") {
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
}
return String(value);
}
function laboratoryMetricLabel(key: string): string {
return key
.replace(/_p95_ms$/, " · p95 мс")
.replaceAll("_", " ")
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
}
function laboratorySessionTitle(session: ObservationSessionSummary): string {
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
return labPrefix && session.label.startsWith(labPrefix)
? session.label.slice(labPrefix.length)
: session.label;
}
function PublishedLaboratoryResult({
props,
session,
loading,
error,
}: {
props: LaboratoryWorkspaceProps;
session: ObservationSessionSummary;
loading: boolean;
error: string | null;
}) {
const lab = session.lab;
const metrics = Object.entries(lab?.provenance ?? {})
.filter(([key, value]) => (
(typeof value === "number" || typeof value === "boolean")
&& !key.includes("sha256")
&& !key.includes("authority")
))
.slice(0, 8);
const replayReady = props.recordedReplay?.sessionId === session.id;
return (
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
description="Опубликованная работа открывается по неизменяемой записи. Viewer показывает исходные синхронные каналы, а продуктовая выжимка — только зафиксированный метод и LAB-provenance."
status="Зафиксированный результат"
statusTone="success"
facts={[
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
{ label: "Контур", value: "Диагностика · без команд" },
]}
method={publishedLaboratoryMethod(session)}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
title="Исходная запись выбранной лабораторной работы"
kind="recorded-replay"
resizable
>
{replayReady ? (
<props.SpatialView {...props} />
) : (
<div className="laboratory-result-pending" role="status">
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
<p>
{error ?? (loading
? "Связанное визуальное доказательство откроется после проверки записи."
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
</p>
</div>
)}
</LaboratoryEvidence>
)}
result={(
<section className="laboratory-result-summary">
<header>
<div>
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
<h2>Результат из LAB-provenance</h2>
</div>
<StatusBadge tone={
lab?.provenance.benchmark_passed === true ? "success" : "warning"
}>
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
</StatusBadge>
</header>
<div className="laboratory-result-metrics">
{metrics.length ? metrics.map(([key, value]) => (
<div key={key}>
<span>{laboratoryMetricLabel(key)}</span>
<strong>{formatLaboratoryMetric(value)}</strong>
<small>Immutable provenance</small>
</div>
)) : (
<div>
<span>Метрики</span>
<strong>Не опубликованы</strong>
<small>Доступна исходная запись</small>
</div>
)}
</div>
</section>
)}
/>
);
}
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
const [evidenceLoading, setEvidenceLoading] = useState(true);
const [evidenceError, setEvidenceError] = useState<string | null>(null);
const sessions = useObservationSessions({
limit: 100,
replayEnabled: props.sessionArchive.blockedReason === null,
onReplayBegin: props.sessionArchive.onReplayBegin,
onReplayAccepted: props.sessionArchive.onReplayAccepted,
onReplaySettled: props.sessionArchive.onReplaySettled,
});
const publishedWorks = useMemo(
() => sessions.items.filter((session) => (
session.lab !== null
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud")
)),
[sessions.items],
);
const sourceSessions = useMemo(
() => new Map(sessions.items.map((session) => [session.id, session])),
[sessions.items],
);
useEffect(() => {
const controller = new AbortController();
setEvidenceLoading(true);
setEvidenceError(null);
void Promise.allSettled([
fetchLidarLocalSurfaces({ signal: controller.signal }),
fetchE29EvidenceCatalog({ signal: controller.signal }),
fetchE30ReviewCatalog({ signal: controller.signal }),
]).then(([e28, e29, e30]) => {
if (controller.signal.aborted) return;
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
setE28Model(nextE28);
setE29Result(nextE29);
setE30Result(nextE30);
const failures = [
e28.status === "rejected" ? "E28" : null,
e29.status === "rejected" ? "E29" : null,
e30.status === "rejected" ? "E30" : null,
].filter(Boolean);
setEvidenceError(
failures.length
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
: null,
);
}).finally(() => {
if (!controller.signal.aborted) setEvidenceLoading(false);
});
return () => controller.abort();
}, []);
const rigLabel = useMemo(() => {
if (props.deviceLabel) return props.deviceLabel;
const coordinateFrame = publishedWorks
.map((session) => session.lab?.provenance.coordinate_frame)
.find((value) => typeof value === "string" && value.trim());
const sensorToken = typeof coordinateFrame === "string"
? coordinateFrame.split("-")[0]?.trim()
: "";
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
}, [props.deviceLabel, publishedWorks]);
const sensorWorks = useMemo(() => {
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
if (e28Model) {
items.push({
id: "e28-local-surface",
label: "LAB E28 · локальная поверхность L2.6",
});
}
if (
e29Result
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
) {
items.push({
id: "e29-camera-geometry",
label: "LAB E29 · camera-first + geometry",
});
}
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
items.push({
id: "e30-evidence-review",
label: "LAB E30 · evidence review A2",
});
}
return items;
}, [e28Model, e29Result, e30Result, sourceSessions]);
const profiles = useMemo(() => {
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
if (sensorWorks.length) {
items.push({
id: "sensor-fusion",
label: `${rigLabel} · камера + LiDAR · control plane`,
});
}
if (publishedWorks.length) {
items.push({
id: "published-perception",
label: `${rigLabel} · опубликованный perception pipeline`,
});
}
return items;
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
profileId === "sensor-fusion"
? sensorWorks
: publishedWorks.map((session) => ({
id: `session:${session.id}` as const,
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
}));
const selectedSessionId = workId.startsWith("session:")
? workId.slice("session:".length)
: null;
const selectedSession = selectedSessionId
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
: null;
const e29SourceSession = e29Result
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
: null;
const e30SourceSession = e30Result
? sourceSessions.get(e30Result.sourceSessionId) ?? null
: null;
useEffect(() => {
if (
evidenceLoading
|| sessions.state === "idle"
|| sessions.state === "loading"
) return;
if (!profiles.some((profile) => profile.id === profileId)) {
const firstProfile = profiles[0];
if (!firstProfile) return;
setProfileId(firstProfile.id);
if (firstProfile.id === "sensor-fusion") {
const firstWork = sensorWorks[0];
if (firstWork) setWorkId(firstWork.id);
} else {
const first = publishedWorks[0];
if (first) setWorkId(`session:${first.id}`);
}
return;
}
if (!workOptions.some((work) => work.id === workId)) {
const firstWork = workOptions[0];
if (firstWork) setWorkId(firstWork.id);
}
}, [
evidenceLoading,
profileId,
profiles,
publishedWorks,
sensorWorks,
sessions.state,
workId,
workOptions,
]);
const selectProfile = (next: LaboratoryProfileId) => {
setProfileId(next);
if (next === "sensor-fusion") {
const first = sensorWorks[0];
if (first) setWorkId(first.id);
return;
}
const first = publishedWorks[0];
if (!first) return;
const nextWork = `session:${first.id}` as const;
setWorkId(nextWork);
void sessions.replay(first.id);
};
const selectWork = (next: LaboratoryWorkId) => {
setWorkId(next);
if (next.startsWith("session:")) {
void sessions.replay(next.slice("session:".length));
return;
}
if (next === "e29-camera-geometry" && e29SourceSession) {
void sessions.replay(e29SourceSession.id);
return;
}
if (next === "e30-evidence-review" && e30SourceSession) {
void sessions.replay(e30SourceSession.id);
}
};
if (
evidenceLoading
|| sessions.state === "idle"
|| sessions.state === "loading"
) {
return (
<div className="laboratory-result-pending" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Ревизия лабораторных данных</strong>
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
</div>
);
}
if (!profiles.length) {
return (
<div className="laboratory-result-pending" role="status">
<Icon name="database" size={20} />
<strong>Подтверждённых лабораторных работ нет</strong>
<p>
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
</p>
</div>
);
}
const viewerFocused = Boolean(
props.observationLayout.focusedSourceId
|| props.observationLayout.maximizedFloatingSourceId,
);
return (
<div
className="lab-archive-workspace"
data-viewer-focused={viewerFocused ? "true" : undefined}
>
<LaboratorySelector
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
label="Профиль"
value={profileId}
options={profiles}
onChange={selectProfile}
/>
<LaboratorySelector
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
label="Работа"
value={workId}
options={workOptions}
disabled={workOptions.length === 0}
onChange={selectWork}
/>
<div className="laboratory-work-output">
{workId === "e28-local-surface" ? (
<LaboratoryWorkTemplate
summary={(
<LaboratorySummary
title="LAB E28 · локальная модель поверхности L2.6"
description="Запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает поверхность, препятствия, временные скачки и ошибку предсказания без изменения источника и без командного канала."
status="Проверенные артефакты"
statusTone="success"
facts={[
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
{
label: "Покрытие",
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
},
{ label: "Режим", value: "Recorded-source-paced shadow" },
{ label: "Контур", value: "Read-only · hash verified" },
]}
method={{
completeness: "complete",
executionClass: e28Model?.method.executionClass ?? "deterministic",
pipelineId: e28Model?.method.pipelineId ?? "local-surface/unavailable",
components: [
{
kind: "source",
name: e28Model?.sourcePackId ?? "Источник не загружен",
version: "immutable vendor MAP + pose",
role: "read-only LiDAR evidence",
identitySha256: digestFromContentId(e28Model?.sourcePackId),
},
{
kind: "algorithm",
name: e28Model?.method.algorithm ?? "Rolling local surface",
version: e28Model?.method.pipelineId ?? "—",
role: "robust local plane, occupancy and temporal residuals",
identitySha256: e28Model?.method.producerSha256 ?? null,
},
{
kind: "runtime",
name: "Mission Core worker D",
version: "recorded-source-paced shadow",
role: "bounded passive replay",
identitySha256: null,
},
],
}}
/>
)}
evidence={(
<LaboratoryEvidence
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
title="Диагностическая поверхность и кадры LAB E28"
kind="diagnostic-model"
>
<LidarQualityWorkspace
embedded
deviceLabel={props.deviceLabel}
onOpenObservation={() => props.navigation.openView("spatial-scene")}
/>
</LaboratoryEvidence>
)}
/>
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
<E29LaboratoryResult
props={props}
rigLabel={rigLabel}
result={e29Result}
sourceSession={e29SourceSession}
loading={sessions.replayingSessionId === e29SourceSession.id}
error={
sessions.failedSessionId === e29SourceSession.id
? sessions.error
: null
}
/>
) : workId === "e30-evidence-review" && e30Result && e30SourceSession ? (
<E30LaboratoryResult
rigLabel={rigLabel}
result={e30Result}
sourceSession={e30SourceSession}
/>
) : selectedSession ? (
<PublishedLaboratoryResult
props={props}
session={selectedSession}
loading={sessions.replayingSessionId === selectedSession.id}
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
/>
) : (
<div className="laboratory-result-pending">
<Icon name="database" size={20} />
<strong>Работа не прошла ревизию</strong>
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
</div>
)}
</div>
</div>
);
}