From d6decbe05ccecd32d6a745767ee7a795bd1c88b1 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Jul 2026 16:02:20 +0300 Subject: [PATCH] feat(polygon): qualify GOOSE ground providers --- .../src/core/polygon/groundQualification.ts | 393 ++++++ .../control-station/src/styles/responsive.css | 22 + .../control-station/src/styles/workspaces.css | 240 ++++ .../workspaces/DatasetGatewayWorkspace.tsx | 7 +- .../src/workspaces/PolygonRunWorkspace.tsx | 215 +++ .../test/groundQualification.test.mjs | 134 ++ src/k1link/datasets/__init__.py | 20 + src/k1link/datasets/cli.py | 35 + src/k1link/datasets/gateway.py | 68 +- src/k1link/datasets/goose_qualification.py | 1153 +++++++++++++++++ src/k1link/web/app.py | 9 +- src/k1link/web/polygon_api.py | 170 +++ tests/test_goose_qualification.py | 128 ++ tests/test_polygon_api.py | 146 ++- 14 files changed, 2718 insertions(+), 22 deletions(-) create mode 100644 apps/control-station/src/core/polygon/groundQualification.ts create mode 100644 apps/control-station/test/groundQualification.test.mjs create mode 100644 src/k1link/datasets/goose_qualification.py create mode 100644 tests/test_goose_qualification.py diff --git a/apps/control-station/src/core/polygon/groundQualification.ts b/apps/control-station/src/core/polygon/groundQualification.ts new file mode 100644 index 0000000..b61feeb --- /dev/null +++ b/apps/control-station/src/core/polygon/groundQualification.ts @@ -0,0 +1,393 @@ +export interface QualificationMetricAggregate { + micro: { + groundIou: number; + naturalGroundRecall: number; + obstacleNonGroundRecall: number; + }; + latencyMs: { + p50: number; + p95: number; + maximum: number; + }; + assignedFraction: number; + sourceCoverage: number; + frameCount: number; +} + +export interface QualificationCheck { + checkId: string; + observed: number; + operator: ">=" | "<="; + threshold: number; + passed: boolean; +} + +export interface QualificationWorstFrame { + frameId: string; + currentGroundIou: number; + patchworkGroundIou: number; + groundIouDelta: number; + patchworkNaturalGroundRecall: number; +} + +export interface GroundQualification { + runId: string; + identitySha256: string; + sourceId: string; + split: string; + frameCount: number; + current: QualificationMetricAggregate; + patchwork: QualificationMetricAggregate; + degradations: Record; + checks: QualificationCheck[]; + worstFrames: QualificationWorstFrame[]; + decision: { + status: "shadow-candidate" | "qualification-rejected"; + passed: boolean; + promotedToNavigationOrSafety: false; + reason: string; + }; +} + +export interface GroundFailurePreview { + runId: string; + frameId: string; + sourcePointCount: number; + pointCount: number; + pointsXyzM: Array<[number, number, number]>; + groundTruthGround: number[]; + evaluated: number[]; + currentGround: number[]; + patchworkGround: number[]; + currentDisagreement: number[]; + patchworkDisagreement: number[]; +} + +export class GroundQualificationContractError extends Error {} + +export class GroundQualificationApiError extends Error { + constructor(message: string, readonly status: number | null = null) { + super(message); + } +} + +type QualificationFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/; + +function record(value: unknown, field: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new GroundQualificationContractError(`${field} должен быть объектом.`); + } + return value as Record; +} + +function array(value: unknown, field: string, maximum: number): unknown[] { + if (!Array.isArray(value) || value.length > maximum) { + throw new GroundQualificationContractError(`${field} должен быть ограниченным массивом.`); + } + return value; +} + +function text(value: unknown, field: string, maximum = 256): string { + if (typeof value !== "string" || !value.trim() || value.length > maximum) { + throw new GroundQualificationContractError(`${field} должен быть непустой строкой.`); + } + return value; +} + +function id(value: unknown, field: string): string { + const result = text(value, field, 128); + if (!SAFE_ID.test(result)) { + throw new GroundQualificationContractError(`${field} содержит недопустимый идентификатор.`); + } + return result; +} + +function number(value: unknown, field: string, minimum = 0): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < minimum) { + throw new GroundQualificationContractError(`${field} должен быть конечным числом.`); + } + return value; +} + +function fraction(value: unknown, field: string): number { + const result = number(value, field); + if (result > 1) { + throw new GroundQualificationContractError(`${field} должен быть долей 0..1.`); + } + return result; +} + +function boolean(value: unknown, field: string): boolean { + if (typeof value !== "boolean") { + throw new GroundQualificationContractError(`${field} должен быть boolean.`); + } + return value; +} + +function aggregate(value: unknown, field: string): QualificationMetricAggregate { + const source = record(value, field); + const micro = record(source.micro, `${field}.micro`); + const latency = record(source.latency_ms, `${field}.latency_ms`); + return { + micro: { + groundIou: fraction(micro.ground_iou, `${field}.micro.ground_iou`), + naturalGroundRecall: fraction( + micro.natural_ground_recall, + `${field}.micro.natural_ground_recall`, + ), + obstacleNonGroundRecall: fraction( + micro.obstacle_non_ground_recall, + `${field}.micro.obstacle_non_ground_recall`, + ), + }, + latencyMs: { + p50: number(latency.p50, `${field}.latency_ms.p50`), + p95: number(latency.p95, `${field}.latency_ms.p95`), + maximum: number(latency.maximum, `${field}.latency_ms.maximum`), + }, + assignedFraction: fraction(source.assigned_fraction, `${field}.assigned_fraction`), + sourceCoverage: fraction(source.source_coverage, `${field}.source_coverage`), + frameCount: number(source.frame_count, `${field}.frame_count`, 1), + }; +} + +export function decodeGroundQualification(payload: unknown): GroundQualification { + const source = record(payload, "qualification"); + if ( + source.schema_version !== "missioncore.polygon-ground-qualification/v1" + || source.access !== "read-only" + ) { + throw new GroundQualificationContractError("Неизвестная схема квалификации."); + } + const runId = id(source.run_id, "run_id"); + const identitySha256 = text(source.identity_sha256, "identity_sha256", 64); + if (!SHA256.test(identitySha256)) { + throw new GroundQualificationContractError("identity_sha256 должен содержать SHA-256."); + } + const aggregates = record(source.aggregates, "aggregates"); + const degradationSource = record(source.degradations, "degradations"); + const degradations: Record = {}; + Object.entries(degradationSource).forEach(([profileId, value]) => { + if (!SAFE_ID.test(profileId) || Object.keys(degradations).length >= 32) { + throw new GroundQualificationContractError("Профили деградации имеют неверный контракт."); + } + degradations[profileId] = aggregate(value, `degradations.${profileId}`); + }); + const checks = array(source.checks, "checks", 128).map((value, index) => { + const check = record(value, `checks[${index}]`); + const rawOperator = text(check.operator, `checks[${index}].operator`, 2); + if (rawOperator !== ">=" && rawOperator !== "<=") { + throw new GroundQualificationContractError("Проверка содержит неизвестный оператор."); + } + const operator: QualificationCheck["operator"] = rawOperator; + return { + checkId: id(check.check_id, `checks[${index}].check_id`), + observed: number(check.observed, `checks[${index}].observed`, -Infinity), + operator, + threshold: number(check.threshold, `checks[${index}].threshold`, -Infinity), + passed: boolean(check.passed, `checks[${index}].passed`), + }; + }); + const worstFrames = array(source.worst_frames, "worst_frames", 20).map( + (value, index) => { + const frame = record(value, `worst_frames[${index}]`); + return { + frameId: id(frame.frame_id, `worst_frames[${index}].frame_id`), + currentGroundIou: fraction( + frame.current_ground_iou, + `worst_frames[${index}].current_ground_iou`, + ), + patchworkGroundIou: fraction( + frame.patchwork_ground_iou, + `worst_frames[${index}].patchwork_ground_iou`, + ), + groundIouDelta: number( + frame.ground_iou_delta, + `worst_frames[${index}].ground_iou_delta`, + -1, + ), + patchworkNaturalGroundRecall: fraction( + frame.patchwork_natural_ground_recall, + `worst_frames[${index}].patchwork_natural_ground_recall`, + ), + }; + }, + ); + const decision = record(source.decision, "decision"); + const status = text(decision.status, "decision.status", 64); + if (status !== "shadow-candidate" && status !== "qualification-rejected") { + throw new GroundQualificationContractError("Решение квалификации неизвестно."); + } + if (decision.promoted_to_navigation_or_safety !== false) { + throw new GroundQualificationContractError( + "Квалификация не может принимать навигацию или safety.", + ); + } + const frameCount = number(source.frame_count, "frame_count", 1); + const current = aggregate(aggregates.current, "aggregates.current"); + const patchwork = aggregate(aggregates.patchworkpp, "aggregates.patchworkpp"); + if (current.frameCount !== frameCount || patchwork.frameCount !== frameCount) { + throw new GroundQualificationContractError("Агрегаты не совпадают с числом кадров."); + } + return { + runId, + identitySha256, + sourceId: text(source.source_id, "source_id"), + split: id(source.split, "split"), + frameCount, + current, + patchwork, + degradations, + checks, + worstFrames, + decision: { + status, + passed: boolean(decision.passed, "decision.passed"), + promotedToNavigationOrSafety: false, + reason: text(decision.reason, "decision.reason", 512), + }, + }; +} + +function mask(value: unknown, field: string, expected: number): number[] { + const values = array(value, field, 50_000).map((item, index) => { + if (item !== 0 && item !== 1) { + throw new GroundQualificationContractError(`${field}[${index}] должен быть 0 или 1.`); + } + return item; + }); + if (values.length !== expected) { + throw new GroundQualificationContractError(`${field} не совпадает с point_count.`); + } + return values as number[]; +} + +export function decodeGroundFailurePreview(payload: unknown): GroundFailurePreview { + const source = record(payload, "failure preview"); + if ( + source.schema_version !== "missioncore.polygon-ground-failure-preview/v1" + || source.access !== "read-only" + ) { + throw new GroundQualificationContractError("Неизвестная схема preview."); + } + const pointCount = number(source.point_count, "point_count", 1); + const pointsXyzM = array(source.points_xyz_m, "points_xyz_m", 50_000).map( + (value, index): [number, number, number] => { + const tuple = array(value, `points_xyz_m[${index}]`, 3); + if (tuple.length !== 3) { + throw new GroundQualificationContractError("Точка должна содержать XYZ."); + } + return [ + number(tuple[0], `points_xyz_m[${index}].x`, -Infinity), + number(tuple[1], `points_xyz_m[${index}].y`, -Infinity), + number(tuple[2], `points_xyz_m[${index}].z`, -Infinity), + ]; + }, + ); + if (pointsXyzM.length !== pointCount) { + throw new GroundQualificationContractError("points_xyz_m не совпадает с point_count."); + } + return { + runId: id(source.run_id, "run_id"), + frameId: id(source.frame_id, "frame_id"), + sourcePointCount: number(source.source_point_count, "source_point_count", pointCount), + pointCount, + pointsXyzM, + groundTruthGround: mask(source.ground_truth_ground, "ground_truth_ground", pointCount), + evaluated: mask(source.evaluated, "evaluated", pointCount), + currentGround: mask(source.current_ground, "current_ground", pointCount), + patchworkGround: mask(source.patchwork_ground, "patchwork_ground", pointCount), + currentDisagreement: mask( + source.current_disagreement, + "current_disagreement", + pointCount, + ), + patchworkDisagreement: mask( + source.patchwork_disagreement, + "patchwork_disagreement", + pointCount, + ), + }; +} + +async function requestJson( + url: string, + signal: AbortSignal | undefined, + fetcher: QualificationFetch, +): Promise { + let response: Response; + try { + response = await fetcher(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") throw error; + throw new GroundQualificationApiError("Не удалось загрузить квалификацию."); + } + let body: unknown; + try { + body = await response.json(); + } catch { + throw new GroundQualificationContractError("Polygon API вернул повреждённый JSON."); + } + if (!response.ok) { + const detail = typeof body === "object" && body !== null && "detail" in body + ? String((body as { detail: unknown }).detail) + : `Polygon API HTTP ${response.status}.`; + throw new GroundQualificationApiError(detail, response.status); + } + return body; +} + +export async function fetchGroundQualification( + runId: string, + { + signal, + fetcher = globalThis.fetch, + }: { signal?: AbortSignal; fetcher?: QualificationFetch } = {}, +): Promise { + if (!SAFE_ID.test(runId)) { + throw new GroundQualificationContractError("Недопустимый run_id."); + } + try { + return decodeGroundQualification( + await requestJson( + `/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification`, + signal, + fetcher, + ), + ); + } catch (error) { + if (error instanceof GroundQualificationApiError && error.status === 404) return null; + throw error; + } +} + +export async function fetchGroundFailurePreview( + runId: string, + frameId: string, + { + signal, + fetcher = globalThis.fetch, + }: { signal?: AbortSignal; fetcher?: QualificationFetch } = {}, +): Promise { + if (!SAFE_ID.test(runId) || !SAFE_ID.test(frameId)) { + throw new GroundQualificationContractError("Недопустимый идентификатор preview."); + } + return decodeGroundFailurePreview( + await requestJson( + `/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/failures/` + + encodeURIComponent(frameId), + signal, + fetcher, + ), + ); +} diff --git a/apps/control-station/src/styles/responsive.css b/apps/control-station/src/styles/responsive.css index 8bf5cf6..7a040be 100644 --- a/apps/control-station/src/styles/responsive.css +++ b/apps/control-station/src/styles/responsive.css @@ -97,6 +97,15 @@ } @media (max-width: 1040px) { + .polygon-qualification__grid, + .polygon-qualification__failures { + grid-template-columns: 1fr; + } + + .polygon-qualification__providers dl { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .feature-group { grid-template-columns: 1fr; gap: 0.9rem; @@ -174,6 +183,19 @@ } @media (max-width: 760px) { + .polygon-qualification__heading, + .polygon-qualification__viewer > header { + display: grid; + } + + .polygon-qualification__providers { + grid-template-columns: 1fr; + } + + .polygon-qualification__degradations p { + grid-template-columns: 1fr 1fr; + } + .dataset-purpose ol { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/apps/control-station/src/styles/workspaces.css b/apps/control-station/src/styles/workspaces.css index a5f632b..f4db2f2 100644 --- a/apps/control-station/src/styles/workspaces.css +++ b/apps/control-station/src/styles/workspaces.css @@ -775,6 +775,246 @@ line-height: 1.5; } +.polygon-qualification { + display: grid; + gap: 0.75rem; + border-radius: 1.2rem; + background: var(--station-panel); + padding: 1rem; +} + +.polygon-qualification__heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; +} + +.polygon-qualification__heading h3, +.polygon-qualification__heading p { + margin: 0; +} + +.polygon-qualification__heading h3 { + margin-top: 0.35rem; + color: var(--nodedc-text-primary); + font-size: 1.15rem; + letter-spacing: -0.035em; +} + +.polygon-qualification__heading p { + max-width: 46rem; + margin-top: 0.45rem; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.5; +} + +.polygon-qualification__providers { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.6rem; +} + +.polygon-qualification__providers article, +.polygon-qualification__checks, +.polygon-qualification__degradations, +.polygon-qualification__failures > article { + border-radius: 0.9rem; + background: rgb(255 255 255 / 0.03); + padding: 0.85rem; +} + +.polygon-qualification__providers article > strong { + color: var(--nodedc-text-primary); + font-size: 0.75rem; +} + +.polygon-qualification__providers dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.6rem; + margin: 0.75rem 0 0; +} + +.polygon-qualification__providers dl > div { + display: grid; + gap: 0.22rem; +} + +.polygon-qualification__providers dt, +.polygon-qualification__providers dd { + margin: 0; +} + +.polygon-qualification__providers dt { + color: var(--nodedc-text-muted); + font-size: 0.55rem; +} + +.polygon-qualification__providers dd { + color: var(--nodedc-text-primary); + font-size: 0.88rem; + font-weight: 650; +} + +.polygon-qualification__grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 0.6rem; +} + +.polygon-qualification__checks > div, +.polygon-qualification__degradations > div { + display: grid; + gap: 0.25rem; + margin-top: 0.7rem; +} + +.polygon-qualification__checks p, +.polygon-qualification__degradations p { + display: grid; + min-width: 0; + align-items: center; + gap: 0.55rem; + margin: 0; + border-radius: 0.65rem; + background: rgb(255 255 255 / 0.025); + padding: 0.48rem 0.55rem; +} + +.polygon-qualification__checks p { + grid-template-columns: auto minmax(0, 1fr) auto; +} + +.polygon-qualification__checks i { + width: 0.42rem; + height: 0.42rem; + border-radius: 50%; + background: rgb(var(--nodedc-danger-rgb)); +} + +.polygon-qualification__checks p[data-passed="true"] i { + background: rgb(var(--nodedc-success-rgb)); +} + +.polygon-qualification__checks span, +.polygon-qualification__checks code, +.polygon-qualification__degradations strong, +.polygon-qualification__degradations span { + overflow: hidden; + font-size: 0.55rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-qualification__checks span, +.polygon-qualification__degradations strong { + color: var(--nodedc-text-secondary); +} + +.polygon-qualification__checks code, +.polygon-qualification__degradations span { + color: var(--nodedc-text-muted); +} + +.polygon-qualification__degradations p { + grid-template-columns: minmax(7rem, 1fr) repeat(3, auto); +} + +.polygon-qualification__failures { + display: grid; + grid-template-columns: minmax(14rem, 0.34fr) minmax(0, 1.66fr); + gap: 0.6rem; +} + +.polygon-qualification__failures > article:first-child > div { + display: grid; + gap: 0.3rem; + margin-top: 0.7rem; +} + +.polygon-qualification__failures button { + display: grid; + min-width: 0; + gap: 0.22rem; + border: 0; + border-radius: 0.65rem; + background: rgb(255 255 255 / 0.025); + color: var(--nodedc-text-secondary); + padding: 0.55rem; + text-align: left; + cursor: pointer; +} + +.polygon-qualification__failures button:hover, +.polygon-qualification__failures button:focus-visible, +.polygon-qualification__failures button[data-active="true"] { + outline: 0; + background: rgb(255 255 255 / 0.075); +} + +.polygon-qualification__failures button span, +.polygon-qualification__failures button code { + overflow: hidden; + font-size: 0.54rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-qualification__failures button code { + color: var(--nodedc-text-muted); +} + +.polygon-qualification__viewer { + min-width: 0; +} + +.polygon-qualification__viewer > header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.65rem; +} + +.polygon-qualification__viewer > header > div:first-child { + display: grid; + min-width: 0; + gap: 0.25rem; +} + +.polygon-qualification__viewer > header strong { + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.polygon-qualification__viewer .lidar-ground-scene { + min-height: 30rem; +} + +.polygon-qualification__viewer-empty { + display: grid; + min-height: 30rem; + place-items: center; + border-radius: 0.85rem; + background: #06070a; + color: var(--nodedc-text-muted); + font-size: 0.62rem; +} + +.polygon-qualification__error { + border-radius: 0.9rem; + background: rgb(var(--nodedc-danger-rgb) / 0.08); + color: var(--nodedc-text-muted); + padding: 0.8rem 1rem; + font-size: 0.62rem; +} + .capability-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx index af721e2..981d7e9 100644 --- a/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx +++ b/apps/control-station/src/workspaces/DatasetGatewayWorkspace.tsx @@ -275,10 +275,11 @@ export function DatasetGatewayWorkspace() { diff --git a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx index a33479c..22a997d 100644 --- a/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx +++ b/apps/control-station/src/workspaces/PolygonRunWorkspace.tsx @@ -5,6 +5,12 @@ import { StatusBadge, } from "@nodedc/ui-react"; +import { + fetchGroundFailurePreview, + fetchGroundQualification, + type GroundFailurePreview, + type GroundQualification, +} from "../core/polygon/groundQualification"; import { fetchPolygonRunCatalog, fetchPolygonRunDetail, @@ -13,6 +19,10 @@ import { type PolygonRunRoute, type PolygonRunState, } from "../core/polygon/runArchive"; +import { + LidarGroundPointCloud, + type LidarGroundViewMode, +} from "./LidarGroundPointCloud"; import { PolygonLivePanel } from "./PolygonLivePanel"; interface PolygonRunWorkspaceProps { @@ -62,6 +72,17 @@ function errorMessage(error: unknown): string { return "Не удалось прочитать доказательства прогона."; } +function formatPercent(value: number): string { + return new Intl.NumberFormat("ru-RU", { + style: "percent", + maximumFractionDigits: 1, + }).format(value); +} + +function formatMilliseconds(value: number): string { + return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`; +} + export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const [catalog, setCatalog] = useState(null); const [detail, setDetail] = useState(null); @@ -69,6 +90,12 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { const [loading, setLoading] = useState(route.error === null); const [error, setError] = useState(route.error); const [reloadGeneration, setReloadGeneration] = useState(0); + const [qualification, setQualification] = useState(null); + const [qualificationError, setQualificationError] = useState(null); + const [failurePreview, setFailurePreview] = useState(null); + const [selectedFailureFrameId, setSelectedFailureFrameId] = useState(null); + const [failureMode, setFailureMode] = + useState("candidate-disagreement"); useEffect(() => { if (route.error) { @@ -106,10 +133,71 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { return () => controller.abort(); }, [reloadGeneration, route.error, route.runId, selectedRunId]); + useEffect(() => { + const runId = detail?.run.runId; + if (!runId) { + setQualification(null); + setQualificationError(null); + return; + } + const controller = new AbortController(); + setQualification(null); + setQualificationError(null); + setFailurePreview(null); + setSelectedFailureFrameId(null); + void fetchGroundQualification(runId, { signal: controller.signal }) + .then((result) => { + if (controller.signal.aborted) return; + setQualification(result); + setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null); + }) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [detail?.run.runId]); + + useEffect(() => { + const runId = qualification?.runId; + if (!runId || !selectedFailureFrameId) { + setFailurePreview(null); + return; + } + const controller = new AbortController(); + setFailurePreview(null); + void fetchGroundFailurePreview(runId, selectedFailureFrameId, { + signal: controller.signal, + }) + .then((result) => { + if (!controller.signal.aborted) setFailurePreview(result); + }) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); + }); + return () => controller.abort(); + }, [qualification?.runId, selectedFailureFrameId]); + const visibleEvents = useMemo( () => detail ? [...detail.events].reverse() : [], [detail], ); + const failureFrame = useMemo(() => { + if (!failurePreview) return null; + return { + pointCount: failurePreview.pointCount, + pointsXyzM: failurePreview.pointsXyzM, + intensity0To255: null, + masks: { + currentGround: failurePreview.currentGround, + currentAssigned: failurePreview.evaluated, + candidateGround: failurePreview.patchworkGround, + candidateAssigned: failurePreview.evaluated, + disagreement: failurePreview.currentDisagreement, + candidateDisagreement: failurePreview.patchworkDisagreement, + groundTruthGround: failurePreview.groundTruthGround, + }, + }; + }, [failurePreview]); if (loading && !detail) { return ( @@ -199,6 +287,133 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { + {qualification ? ( +
+
+
+ GOOSE VALIDATION · {qualification.frameCount} КАДРОВ +

Current против Patchwork++

+

+ Публичная разметка проверяет переносимость ground pipeline. Результат остаётся + shadow-only и не даёт права на навигацию или safety. +

+
+ + {qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"} + +
+ +
+ {([ + ["Current", qualification.current], + ["Patchwork++", qualification.patchwork], + ] as const).map(([label, aggregate]) => ( +
+ {label} +
+
Ground IoU
{formatPercent(aggregate.micro.groundIou)}
+
Natural ground
{formatPercent(aggregate.micro.naturalGroundRecall)}
+
Obstacle recall
{formatPercent(aggregate.micro.obstacleNonGroundRecall)}
+
Latency p95
{formatMilliseconds(aggregate.latencyMs.p95)}
+
+
+ ))} +
+ +
+
+ ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES +
+ {qualification.checks.map((check) => ( +

+