feat(polygon): add full-frame operator review

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 16:45:10 +03:00
parent cea63f9a1e
commit b037076506
10 changed files with 2080 additions and 330 deletions

View File

@ -63,6 +63,36 @@ export interface GroundFailurePreview {
patchworkDisagreement: number[]; patchworkDisagreement: number[];
} }
export interface GroundReviewFrameMetrics {
groundIou: number;
naturalGroundRecall: number;
obstacleNonGroundRecall: number;
latencyMs: number;
}
export interface GroundReviewFrameSummary {
sequence: number;
frameId: string;
sourcePointCount: number;
pointCount: number;
current: GroundReviewFrameMetrics;
patchwork: GroundReviewFrameMetrics;
groundIouDelta: number;
}
export interface GroundReview {
runId: string;
identitySha256: string;
sourceId: string;
frameCount: number;
previewPoints: number;
frames: GroundReviewFrameSummary[];
}
export interface GroundReviewFrame extends GroundFailurePreview {
intensity0To255: number[];
}
export class GroundQualificationContractError extends Error {} export class GroundQualificationContractError extends Error {}
export class GroundQualificationApiError extends Error { export class GroundQualificationApiError extends Error {
@ -130,6 +160,14 @@ function boolean(value: unknown, field: string): boolean {
return value; return value;
} }
function integer(value: unknown, field: string, minimum = 0): number {
const result = number(value, field, minimum);
if (!Number.isInteger(result)) {
throw new GroundQualificationContractError(`${field} должен быть целым числом.`);
}
return result;
}
function aggregate(value: unknown, field: string): QualificationMetricAggregate { function aggregate(value: unknown, field: string): QualificationMetricAggregate {
const source = record(value, field); const source = record(value, field);
const micro = record(source.micro, `${field}.micro`); const micro = record(source.micro, `${field}.micro`);
@ -316,6 +354,113 @@ export function decodeGroundFailurePreview(payload: unknown): GroundFailurePrevi
}; };
} }
function reviewFrameMetrics(value: unknown, field: string): GroundReviewFrameMetrics {
const source = record(value, field);
return {
groundIou: fraction(source.ground_iou, `${field}.ground_iou`),
naturalGroundRecall: fraction(
source.natural_ground_recall,
`${field}.natural_ground_recall`,
),
obstacleNonGroundRecall: fraction(
source.obstacle_non_ground_recall,
`${field}.obstacle_non_ground_recall`,
),
latencyMs: number(source.latency_ms, `${field}.latency_ms`),
};
}
export function decodeGroundReview(payload: unknown): GroundReview {
const source = record(payload, "ground review");
if (
source.schema_version !== "missioncore.polygon-ground-review/v1"
|| source.access !== "read-only"
) {
throw new GroundQualificationContractError("Неизвестная схема покадрового просмотра.");
}
const frameCount = integer(source.frame_count, "frame_count", 1);
const frames = array(source.frames, "frames", 2_000).map(
(value, index): GroundReviewFrameSummary => {
const frame = record(value, `frames[${index}]`);
const sequence = integer(frame.sequence, `frames[${index}].sequence`);
if (sequence !== index) {
throw new GroundQualificationContractError("Кадры просмотра идут не по порядку.");
}
return {
sequence,
frameId: id(frame.frame_id, `frames[${index}].frame_id`),
sourcePointCount: integer(
frame.source_point_count,
`frames[${index}].source_point_count`,
1,
),
pointCount: integer(frame.point_count, `frames[${index}].point_count`, 1),
current: reviewFrameMetrics(frame.current, `frames[${index}].current`),
patchwork: reviewFrameMetrics(
frame.patchworkpp,
`frames[${index}].patchworkpp`,
),
groundIouDelta: number(
frame.ground_iou_delta,
`frames[${index}].ground_iou_delta`,
-1,
),
};
},
);
if (frames.length !== frameCount) {
throw new GroundQualificationContractError("Индекс просмотра неполный.");
}
frames.forEach((frame, index) => {
if (frame.pointCount > frame.sourcePointCount) {
throw new GroundQualificationContractError(
`frames[${index}] содержит больше preview-точек, чем source-точек.`,
);
}
});
const identitySha256 = text(source.identity_sha256, "identity_sha256", 64);
if (!SHA256.test(identitySha256)) {
throw new GroundQualificationContractError("Review identity должен содержать SHA-256.");
}
return {
runId: id(source.run_id, "run_id"),
identitySha256,
sourceId: text(source.source_id, "source_id"),
frameCount,
previewPoints: integer(source.preview_points, "preview_points", 1),
frames,
};
}
export function decodeGroundReviewFrame(payload: unknown): GroundReviewFrame {
const source = record(payload, "ground review frame");
if (
source.schema_version !== "missioncore.polygon-ground-review-frame/v1"
|| source.access !== "read-only"
) {
throw new GroundQualificationContractError("Неизвестная схема кадра просмотра.");
}
const compatible = {
...source,
schema_version: "missioncore.polygon-ground-failure-preview/v1",
};
const decoded = decodeGroundFailurePreview(compatible);
const intensity0To255 = array(
source.intensity_0_255,
"intensity_0_255",
50_000,
).map((value, index) => integer(value, `intensity_0_255[${index}]`));
if (
intensity0To255.length !== decoded.pointCount
|| intensity0To255.some((value) => value > 255)
) {
throw new GroundQualificationContractError(
"intensity_0_255 не совпадает с point_count.",
);
}
return { ...decoded, intensity0To255 };
}
async function requestJson( async function requestJson(
url: string, url: string,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
@ -391,3 +536,48 @@ export async function fetchGroundFailurePreview(
), ),
); );
} }
export async function fetchGroundReview(
runId: string,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: QualificationFetch } = {},
): Promise<GroundReview | null> {
if (!SAFE_ID.test(runId)) {
throw new GroundQualificationContractError("Недопустимый run_id.");
}
try {
return decodeGroundReview(
await requestJson(
`/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/review`,
signal,
fetcher,
),
);
} catch (error) {
if (error instanceof GroundQualificationApiError && error.status === 404) return null;
throw error;
}
}
export async function fetchGroundReviewFrame(
runId: string,
frameId: string,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: QualificationFetch } = {},
): Promise<GroundReviewFrame> {
if (!SAFE_ID.test(runId) || !SAFE_ID.test(frameId)) {
throw new GroundQualificationContractError("Недопустимый идентификатор кадра.");
}
return decodeGroundReviewFrame(
await requestJson(
`/api/v1/polygon/runs/${encodeURIComponent(runId)}/qualification/review/frames/`
+ encodeURIComponent(frameId),
signal,
fetcher,
),
);
}

View File

@ -156,7 +156,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Прогоны", label: "Прогоны",
title: "Прогоны", title: "Прогоны",
eyebrow: "ПОЛИГОН / ПРОГОНЫ", eyebrow: "ПОЛИГОН / ПРОГОНЫ",
description: "Live Simulation Worker, история и доказательства квалификационных прогонов.", description: "Покадровый replay, сравнение алгоритмов и история квалификационных прогонов.",
icon: "activity", icon: "activity",
kind: "polygon-run", kind: "polygon-run",
groups: [], groups: [],

View File

@ -97,6 +97,16 @@
} }
@media (max-width: 1040px) { @media (max-width: 1040px) {
.polygon-review-summary > div:last-child,
.polygon-review-analysis__content,
.polygon-run-technical__content {
grid-template-columns: 1fr;
}
.polygon-review-frame-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.polygon-qualification__grid, .polygon-qualification__grid,
.polygon-qualification__failures { .polygon-qualification__failures {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -183,6 +193,42 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.polygon-review-lead,
.polygon-review-player__header,
.polygon-review-order,
.polygon-review-summary > header {
align-items: stretch;
flex-direction: column;
}
.polygon-review-lead > div:last-child {
justify-content: space-between;
}
.polygon-review-mode {
justify-content: flex-start;
}
.polygon-review-frame-metrics {
grid-template-columns: 1fr;
}
.polygon-review-controls {
grid-template-columns: auto auto auto minmax(0, 1fr);
}
.polygon-review-controls > span {
display: none;
}
.polygon-review-summary article {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.polygon-review-summary article small {
grid-column: 1 / -1;
}
.polygon-qualification__heading, .polygon-qualification__heading,
.polygon-qualification__viewer > header { .polygon-qualification__viewer > header {
display: grid; display: grid;

View File

@ -1015,6 +1015,504 @@
font-size: 0.62rem; font-size: 0.62rem;
} }
.polygon-review-lead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
padding: 0.15rem 0.1rem 0.25rem;
}
.polygon-review-lead h2,
.polygon-review-lead p {
margin: 0;
}
.polygon-review-lead h2 {
margin-top: 0.32rem;
color: var(--nodedc-text-primary);
font-size: 1.35rem;
letter-spacing: -0.04em;
}
.polygon-review-lead p {
margin-top: 0.32rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.polygon-review-lead > div:last-child {
display: flex;
align-items: center;
gap: 0.55rem;
}
.polygon-review-lead select {
max-width: 18rem;
border: 0;
border-radius: 999px;
outline: 0;
background: rgb(255 255 255 / 0.06);
color: var(--nodedc-text-secondary);
padding: 0.42rem 0.75rem;
font-size: 0.58rem;
}
.polygon-review-player {
overflow: hidden;
border-radius: 1.15rem;
background: var(--station-panel);
}
.polygon-review-player__header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1rem;
}
.polygon-review-player__header > div:first-child {
display: grid;
min-width: 0;
gap: 0.25rem;
}
.polygon-review-player__header strong {
overflow: hidden;
max-width: 36rem;
color: var(--nodedc-text-primary);
font-size: 0.67rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-review-mode,
.polygon-review-order > div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
}
.polygon-review-mode button,
.polygon-review-order button,
.polygon-review-controls button {
border: 0;
border-radius: 999px;
outline: 0;
background: transparent;
color: var(--nodedc-text-muted);
padding: 0.42rem 0.68rem;
font: inherit;
font-size: 0.57rem;
cursor: pointer;
}
.polygon-review-mode button:hover,
.polygon-review-mode button[data-active="true"],
.polygon-review-order button:hover,
.polygon-review-order button[data-active="true"],
.polygon-review-controls button:hover:not(:disabled) {
outline: 0;
background: rgb(255 255 255 / 0.1);
color: var(--nodedc-text-primary);
}
.polygon-review-stage {
position: relative;
background: #06070a;
}
.polygon-review-stage .lidar-ground-scene {
min-height: min(56rem, 62vh);
border-radius: 0;
}
.polygon-review-stage__loading {
display: grid;
min-height: min(56rem, 62vh);
place-items: center;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.polygon-review-stage__legend {
position: absolute;
z-index: 3;
top: 0.8rem;
left: 0.8rem;
display: grid;
max-width: 25rem;
gap: 0.18rem;
border-radius: 0.75rem;
background: rgb(7 8 10 / 0.76);
padding: 0.55rem 0.68rem;
backdrop-filter: blur(14px);
}
.polygon-review-stage__legend strong {
color: var(--nodedc-text-primary);
font-size: 0.62rem;
}
.polygon-review-stage__legend span {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.polygon-review-frame-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.28rem;
padding: 0.38rem;
}
.polygon-review-frame-metrics article {
display: grid;
min-width: 0;
gap: 0.24rem;
border-radius: 0.82rem;
background: rgb(255 255 255 / 0.035);
padding: 0.72rem 0.78rem;
}
.polygon-review-frame-metrics span,
.polygon-review-frame-metrics small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-review-frame-metrics strong {
color: var(--nodedc-text-primary);
font-size: 1.02rem;
font-weight: 680;
}
.polygon-review-frame-metrics article[data-positive="false"] strong {
color: rgb(var(--nodedc-danger-rgb));
}
.polygon-review-controls {
display: grid;
grid-template-columns: auto auto auto minmax(8rem, 1fr) auto;
align-items: center;
gap: 0.38rem;
padding: 0.3rem 0.55rem 0.15rem;
}
.polygon-review-controls button {
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
}
.polygon-review-controls button:disabled {
opacity: 0.3;
cursor: default;
}
.polygon-review-controls input {
width: 100%;
accent-color: #f0f0ec;
}
.polygon-review-controls > span {
min-width: 4.5rem;
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.55rem;
text-align: right;
}
.polygon-review-order {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.2rem 0.55rem 0.45rem;
}
.polygon-review-order > div:last-child button {
color: var(--nodedc-text-secondary);
}
.polygon-review-timeline {
display: flex;
height: 2.4rem;
align-items: stretch;
gap: 1px;
overflow: hidden;
margin: 0 0.65rem;
border-radius: 0.42rem;
background: rgb(255 255 255 / 0.025);
padding: 0.32rem 0;
}
.polygon-review-timeline button {
min-width: 1px;
flex: 1 1 1px;
border: 0;
border-radius: 0;
outline: 0;
background: var(--frame-color);
padding: 0;
cursor: pointer;
opacity: 0.62;
}
.polygon-review-timeline button:hover,
.polygon-review-timeline button[data-active="true"] {
min-width: 3px;
opacity: 1;
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.86);
}
.polygon-review-timeline-note {
margin: 0;
padding: 0.25rem 0.75rem 0.78rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
line-height: 1.45;
}
.polygon-review-summary,
.polygon-review-unavailable,
.polygon-review-analysis,
.polygon-run-technical {
border-radius: 1rem;
background: var(--station-panel);
}
.polygon-review-summary {
display: grid;
gap: 0.7rem;
padding: 0.9rem 1rem;
}
.polygon-review-summary > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.polygon-review-summary h3,
.polygon-review-summary p {
margin: 0;
}
.polygon-review-summary h3 {
margin-top: 0.28rem;
color: var(--nodedc-text-primary);
font-size: 0.94rem;
}
.polygon-review-summary > p {
max-width: 52rem;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.55;
}
.polygon-review-summary > div:last-child {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.38rem;
}
.polygon-review-summary article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
align-items: center;
gap: 0.58rem;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.03);
padding: 0.65rem 0.72rem;
}
.polygon-review-summary article span,
.polygon-review-summary article small {
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.polygon-review-summary article strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
}
.polygon-review-summary article i {
color: var(--nodedc-text-muted);
font-style: normal;
}
.polygon-review-analysis,
.polygon-run-technical {
overflow: hidden;
}
.polygon-review-analysis > summary,
.polygon-run-technical > summary {
padding: 0.8rem 0.95rem;
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
cursor: pointer;
list-style: none;
}
.polygon-review-analysis > summary::-webkit-details-marker,
.polygon-run-technical > summary::-webkit-details-marker {
display: none;
}
.polygon-review-analysis > summary::after,
.polygon-run-technical > summary::after {
float: right;
content: "";
color: var(--nodedc-text-muted);
}
.polygon-review-analysis[open] > summary::after,
.polygon-run-technical[open] > summary::after {
content: "";
}
.polygon-review-analysis__content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0.7rem;
padding: 0 0.65rem 0.65rem;
}
.polygon-review-analysis__content section {
display: grid;
align-content: start;
gap: 0.25rem;
border-radius: 0.8rem;
background: rgb(255 255 255 / 0.025);
padding: 0.75rem;
}
.polygon-review-analysis__content p {
display: grid;
grid-template-columns: auto minmax(0, 1fr) repeat(3, auto);
align-items: center;
gap: 0.45rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.polygon-review-analysis__content p i {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: rgb(var(--nodedc-danger-rgb));
}
.polygon-review-analysis__content p[data-passed="true"] i {
background: rgb(var(--nodedc-success-rgb));
}
.polygon-review-analysis__content code {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
}
.polygon-review-unavailable {
display: grid;
min-height: 18rem;
align-content: center;
gap: 0.45rem;
padding: 1rem;
}
.polygon-review-unavailable h3,
.polygon-review-unavailable p,
.polygon-review-unavailable small {
margin: 0;
}
.polygon-review-unavailable h3 {
color: var(--nodedc-text-primary);
font-size: 1rem;
}
.polygon-review-unavailable p,
.polygon-review-unavailable small {
max-width: 44rem;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
}
.polygon-run-technical__content {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr;
gap: 0.6rem;
padding: 0 0.65rem 0.65rem;
}
.polygon-run-technical__content > * {
min-width: 0;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.025);
padding: 0.7rem;
}
.polygon-run-technical__content dl {
display: grid;
gap: 0.3rem;
margin: 0;
}
.polygon-run-technical__content dl > div,
.polygon-run-technical__content section p {
display: flex;
min-width: 0;
justify-content: space-between;
gap: 0.65rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.polygon-run-technical__content dd {
overflow: hidden;
margin: 0;
color: var(--nodedc-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-technical__content h4 {
margin: 0 0 0.55rem;
color: var(--nodedc-text-primary);
font-size: 0.62rem;
}
.polygon-run-technical__content section {
display: grid;
align-content: start;
gap: 0.4rem;
}
.polygon-run-technical__content section p strong,
.polygon-run-technical__content section p span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.polygon-run-technical__content section p strong {
color: var(--nodedc-text-secondary);
font-weight: 600;
}
.capability-summary { .capability-summary {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@ -1,15 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { import {
Button, useEffect,
GlassSurface, useMemo,
StatusBadge, useRef,
} from "@nodedc/ui-react"; useState,
type CSSProperties,
} from "react";
import { Button, StatusBadge } from "@nodedc/ui-react";
import { import {
fetchGroundFailurePreview,
fetchGroundQualification, fetchGroundQualification,
type GroundFailurePreview, fetchGroundReview,
fetchGroundReviewFrame,
type GroundQualification, type GroundQualification,
type GroundReview,
type GroundReviewFrame,
type GroundReviewFrameSummary,
} from "../core/polygon/groundQualification"; } from "../core/polygon/groundQualification";
import { import {
fetchPolygonRunCatalog, fetchPolygonRunCatalog,
@ -23,12 +28,13 @@ import {
LidarGroundPointCloud, LidarGroundPointCloud,
type LidarGroundViewMode, type LidarGroundViewMode,
} from "./LidarGroundPointCloud"; } from "./LidarGroundPointCloud";
import { PolygonLivePanel } from "./PolygonLivePanel";
interface PolygonRunWorkspaceProps { interface PolygonRunWorkspaceProps {
route: PolygonRunRoute; route: PolygonRunRoute;
} }
type ReviewOrder = "sequence" | "best" | "worst";
const stateLabels: Record<PolygonRunState, string> = { const stateLabels: Record<PolygonRunState, string> = {
admitted: "Допущен", admitted: "Допущен",
starting: "Запускается", starting: "Запускается",
@ -40,6 +46,21 @@ const stateLabels: Record<PolygonRunState, string> = {
aborted: "Прерван", aborted: "Прерван",
}; };
const viewModes: ReadonlyArray<[LidarGroundViewMode, string]> = [
["intensity", "Облако"],
["ground-truth", "Эталон"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Ошибки Current"],
["candidate-disagreement", "Ошибки Patchwork++"],
];
const orderLabels: ReadonlyArray<[ReviewOrder, string]> = [
["sequence", "По записи"],
["best", "Лучшие"],
["worst", "Худшие"],
];
function stateTone( function stateTone(
state: PolygonRunState, state: PolygonRunState,
): "success" | "accent" | "warning" | "danger" | "neutral" { ): "success" | "accent" | "warning" | "danger" | "neutral" {
@ -69,7 +90,7 @@ function formatBytes(value: number): string {
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim()) return error.message; if (error instanceof Error && error.message.trim()) return error.message;
return "Не удалось прочитать доказательства прогона."; return "Не удалось прочитать прогон.";
} }
function formatPercent(value: number): string { function formatPercent(value: number): string {
@ -79,10 +100,65 @@ function formatPercent(value: number): string {
}).format(value); }).format(value);
} }
function formatDelta(value: number): string {
const points = value * 100;
return `${points >= 0 ? "+" : ""}${points.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})} п.п.`;
}
function formatMilliseconds(value: number): string { function formatMilliseconds(value: number): string {
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`; return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 1 })} мс`;
} }
function frameScene(frame: GroundReviewFrame | null) {
if (!frame) return null;
return {
pointCount: frame.pointCount,
pointsXyzM: frame.pointsXyzM,
intensity0To255: frame.intensity0To255,
masks: {
currentGround: frame.currentGround,
currentAssigned: frame.evaluated,
candidateGround: frame.patchworkGround,
candidateAssigned: frame.evaluated,
disagreement: frame.currentDisagreement,
candidateDisagreement: frame.patchworkDisagreement,
groundTruthGround: frame.groundTruthGround,
},
};
}
function modeExplanation(mode: LidarGroundViewMode): string {
if (mode === "intensity") return "Нейтральная геометрия нативного LiDAR-кадра.";
if (mode === "ground-truth") return "Публичная GOOSE-разметка: земля и всё остальное.";
if (mode === "current") return "Что текущий алгоритм Mission Core считает землёй.";
if (mode === "candidate") return "Что Patchwork++ считает землёй.";
if (mode === "disagreement") return "Красным — точки, где Current расходится с эталоном.";
return "Красным — точки, где Patchwork++ расходится с эталоном.";
}
function frameColor(delta: number): string {
if (delta < 0) {
const opacity = Math.min(0.92, 0.42 + Math.abs(delta) * 3);
return `rgb(255 104 104 / ${opacity})`;
}
const opacity = Math.min(0.9, 0.25 + delta * 2.8);
return `rgb(210 242 188 / ${opacity})`;
}
function orderFrames(
review: GroundReview,
order: ReviewOrder,
): GroundReviewFrameSummary[] {
if (order === "sequence") return review.frames;
return [...review.frames].sort((left, right) => (
order === "best"
? right.groundIouDelta - left.groundIouDelta
: left.groundIouDelta - right.groundIouDelta
));
}
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) { export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null); const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | null>(null); const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
@ -91,11 +167,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [error, setError] = useState<string | null>(route.error); const [error, setError] = useState<string | null>(route.error);
const [reloadGeneration, setReloadGeneration] = useState(0); const [reloadGeneration, setReloadGeneration] = useState(0);
const [qualification, setQualification] = useState<GroundQualification | null>(null); const [qualification, setQualification] = useState<GroundQualification | null>(null);
const [qualificationError, setQualificationError] = useState<string | null>(null); const [review, setReview] = useState<GroundReview | null>(null);
const [failurePreview, setFailurePreview] = useState<GroundFailurePreview | null>(null); const [reviewError, setReviewError] = useState<string | null>(null);
const [selectedFailureFrameId, setSelectedFailureFrameId] = useState<string | null>(null); const [selectedFrameId, setSelectedFrameId] = useState<string | null>(null);
const [failureMode, setFailureMode] = const [reviewFrame, setReviewFrame] = useState<GroundReviewFrame | null>(null);
useState<LidarGroundViewMode>("candidate-disagreement"); const [reviewMode, setReviewMode] = useState<LidarGroundViewMode>("ground-truth");
const [reviewOrder, setReviewOrder] = useState<ReviewOrder>("sequence");
const [playing, setPlaying] = useState(false);
const frameCache = useRef(new Map<string, GroundReviewFrame>());
useEffect(() => { useEffect(() => {
if (route.error) { if (route.error) {
@ -135,79 +214,134 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
useEffect(() => { useEffect(() => {
const runId = detail?.run.runId; const runId = detail?.run.runId;
if (!runId) {
setQualification(null);
setQualificationError(null);
return;
}
const controller = new AbortController();
setQualification(null); setQualification(null);
setQualificationError(null); setReview(null);
setFailurePreview(null); setReviewFrame(null);
setSelectedFailureFrameId(null); setSelectedFrameId(null);
void fetchGroundQualification(runId, { signal: controller.signal }) setReviewError(null);
.then((result) => { setPlaying(false);
frameCache.current.clear();
if (!runId) return;
const controller = new AbortController();
void (async () => {
try {
const nextQualification = await fetchGroundQualification(runId, {
signal: controller.signal,
});
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setQualification(result); setQualification(nextQualification);
setSelectedFailureFrameId(result?.worstFrames[0]?.frameId ?? null); if (!nextQualification) return;
}) const nextReview = await fetchGroundReview(runId, {
.catch((loadError: unknown) => { signal: controller.signal,
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); });
}); if (controller.signal.aborted) return;
setReview(nextReview);
setSelectedFrameId(nextReview?.frames[0]?.frameId ?? null);
} catch (loadError) {
if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
}
})();
return () => controller.abort(); return () => controller.abort();
}, [detail?.run.runId]); }, [detail?.run.runId]);
useEffect(() => { useEffect(() => {
const runId = qualification?.runId; const runId = review?.runId;
if (!runId || !selectedFailureFrameId) { if (!runId || !selectedFrameId) {
setFailurePreview(null); setReviewFrame(null);
return;
}
const cached = frameCache.current.get(selectedFrameId);
if (cached) {
setReviewFrame(cached);
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
setFailurePreview(null); setReviewFrame(null);
void fetchGroundFailurePreview(runId, selectedFailureFrameId, { void fetchGroundReviewFrame(runId, selectedFrameId, {
signal: controller.signal, signal: controller.signal,
}) })
.then((result) => { .then((frame) => {
if (!controller.signal.aborted) setFailurePreview(result); if (controller.signal.aborted) return;
frameCache.current.set(frame.frameId, frame);
while (frameCache.current.size > 24) {
const oldest = frameCache.current.keys().next().value as string | undefined;
if (oldest) frameCache.current.delete(oldest);
else break;
}
setReviewFrame(frame);
}) })
.catch((loadError: unknown) => { .catch((loadError: unknown) => {
if (!controller.signal.aborted) setQualificationError(errorMessage(loadError)); if (!controller.signal.aborted) setReviewError(errorMessage(loadError));
}); });
return () => controller.abort(); return () => controller.abort();
}, [qualification?.runId, selectedFailureFrameId]); }, [review?.runId, selectedFrameId]);
const visibleEvents = useMemo( const orderedFrames = useMemo(
() => detail ? [...detail.events].reverse() : [], () => review ? orderFrames(review, reviewOrder) : [],
[detail], [review, reviewOrder],
); );
const failureFrame = useMemo(() => { const selectedPosition = Math.max(
if (!failurePreview) return null; 0,
return { orderedFrames.findIndex((frame) => frame.frameId === selectedFrameId),
pointCount: failurePreview.pointCount, );
pointsXyzM: failurePreview.pointsXyzM, const selectedSummary = orderedFrames[selectedPosition] ?? null;
intensity0To255: null, const bestFrame = useMemo(
masks: { () => review
currentGround: failurePreview.currentGround, ? [...review.frames].sort(
currentAssigned: failurePreview.evaluated, (left, right) => right.groundIouDelta - left.groundIouDelta,
candidateGround: failurePreview.patchworkGround, )[0]
candidateAssigned: failurePreview.evaluated, : null,
disagreement: failurePreview.currentDisagreement, [review],
candidateDisagreement: failurePreview.patchworkDisagreement, );
groundTruthGround: failurePreview.groundTruthGround, const worstFrame = useMemo(
}, () => review
}; ? [...review.frames].sort(
}, [failurePreview]); (left, right) => left.groundIouDelta - right.groundIouDelta,
)[0]
: null,
[review],
);
useEffect(() => {
if (!playing || !reviewFrame || !selectedFrameId || !orderedFrames.length) return;
if (reviewFrame.frameId !== selectedFrameId) return;
const timer = window.setTimeout(() => {
const nextPosition = selectedPosition + 1;
if (nextPosition >= orderedFrames.length) {
setPlaying(false);
return;
}
setSelectedFrameId(orderedFrames[nextPosition].frameId);
}, 520);
return () => window.clearTimeout(timer);
}, [
orderedFrames,
playing,
reviewFrame,
selectedFrameId,
selectedPosition,
]);
const scene = useMemo(() => frameScene(reviewFrame), [reviewFrame]);
const selectPosition = (position: number) => {
const frame = orderedFrames[Math.max(0, Math.min(position, orderedFrames.length - 1))];
if (frame) setSelectedFrameId(frame.frameId);
};
const changeOrder = (order: ReviewOrder) => {
setPlaying(false);
setReviewOrder(order);
};
if (loading && !detail) { if (loading && !detail) {
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel /> <section className="polygon-run-message">
<GlassSurface className="polygon-run-message" padding="lg"> <span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<StatusBadge tone="accent">Только чтение</StatusBadge> <h2>Открываем запись</h2>
<h2>Проверяем журнал прогона</h2> <p>Читаем индекс кадров и готовим покадровый просмотр.</p>
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p> </section>
</GlassSurface>
</div> </div>
); );
} }
@ -215,19 +349,18 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
if (error) { if (error) {
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel /> <section className="polygon-run-message">
<GlassSurface className="polygon-run-message" padding="lg">
<StatusBadge tone="danger">Данные недоступны</StatusBadge> <StatusBadge tone="danger">Данные недоступны</StatusBadge>
<h2>UI-0 не может открыть прогон</h2> <h2>Не удалось открыть прогон</h2>
<p>{error}</p> <p>{error}</p>
<Button <Button
size="compact" size="compact"
variant="secondary" variant="secondary"
onClick={() => setReloadGeneration((value) => value + 1)} onClick={() => setReloadGeneration((value) => value + 1)}
> >
Повторить чтение Повторить
</Button> </Button>
</GlassSurface> </section>
</div> </div>
); );
} }
@ -235,12 +368,11 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
if (!detail) { if (!detail) {
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel /> <section className="polygon-run-message">
<GlassSurface className="polygon-run-message" padding="lg"> <span className="section-eyebrow">ПОЛИГОН / ПРОГОНЫ</span>
<StatusBadge tone="neutral">Журнал пуст</StatusBadge> <h2>Записей пока нет</h2>
<h2>Квалификационных прогонов пока нет</h2> <p>Здесь появятся replay- и simulation-прогоны после публикации результатов.</p>
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p> </section>
</GlassSurface>
</div> </div>
); );
} }
@ -248,82 +380,236 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const { run } = detail; const { run } = detail;
return ( return (
<div className="standard-workspace polygon-run-workspace"> <div className="standard-workspace polygon-run-workspace">
<PolygonLivePanel /> <section className="polygon-review-lead">
<section className="workspace-lead workspace-lead--compact">
<div> <div>
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span> <span className="section-eyebrow">ПОЛИГОН / ЗАПИСЬ ДАТАСЕТА</span>
<h2>{run.runId}</h2> <h2>{qualification ? "GOOSE · Ground segmentation" : run.scenarioGeneration}</h2>
<p> <p>
Канонический архив Mission Core. Lifecycle live-контура отделён от истории; {qualification
команд физическим актуаторам и реального управления здесь нет. ? `${qualification.frameCount} кадров · Current и Patchwork++ против публичной разметки`
: "Архивный прогон без покадрового perception review."}
</p> </p>
</div> </div>
<div className="polygon-run-lead-status"> <div>
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge> <StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
<span>read-only · {run.reproducibilityTier}</span> {catalog && catalog.items.length > 1 ? (
<select
aria-label="Выбрать прогон"
value={run.runId}
onChange={(event) => setSelectedRunId(event.target.value)}
>
{catalog.items.map((item) => (
<option key={item.runId} value={item.runId}>{item.runId}</option>
))}
</select>
) : null}
</div> </div>
</section> </section>
<section className="polygon-run-metrics" aria-label="Сводка прогона"> {qualification && review ? (
<div> <>
<span>Состояние</span> <section className="polygon-review-player" aria-label="Покадровый просмотр GOOSE">
<strong>{stateLabels[run.state]}</strong> <header className="polygon-review-player__header">
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
</div>
<div>
<span>Провайдеры</span>
<strong>{run.providers.length}</strong>
<small>{run.providerIds.join(" · ")}</small>
</div>
<div>
<span>События</span>
<strong>{detail.eventsTotal}</strong>
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
</div>
<div>
<span>Команды</span>
<strong>{detail.commandCount}</strong>
<small>содержимое не публикуется UI-0</small>
</div>
</section>
{qualification ? (
<section className="polygon-qualification" aria-label="Квалификация ground provider">
<header className="polygon-qualification__heading">
<div>
<span className="section-eyebrow">GOOSE VALIDATION · {qualification.frameCount} КАДРОВ</span>
<h3>Current против Patchwork++</h3>
<p>
Публичная разметка проверяет переносимость ground pipeline. Результат остаётся
shadow-only и не даёт права на навигацию или safety.
</p>
</div>
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
{qualification.decision.passed ? "Shadow candidate" : "Gate не пройден"}
</StatusBadge>
</header>
<div className="polygon-qualification__providers">
{([
["Current", qualification.current],
["Patchwork++", qualification.patchwork],
] as const).map(([label, aggregate]) => (
<article key={label}>
<strong>{label}</strong>
<dl>
<div><dt>Ground IoU</dt><dd>{formatPercent(aggregate.micro.groundIou)}</dd></div>
<div><dt>Natural ground</dt><dd>{formatPercent(aggregate.micro.naturalGroundRecall)}</dd></div>
<div><dt>Obstacle recall</dt><dd>{formatPercent(aggregate.micro.obstacleNonGroundRecall)}</dd></div>
<div><dt>Latency p95</dt><dd>{formatMilliseconds(aggregate.latencyMs.p95)}</dd></div>
</dl>
</article>
))}
</div>
<div className="polygon-qualification__grid">
<article className="polygon-qualification__checks">
<span className="section-eyebrow">ЗАРАНЕЕ ЗАФИКСИРОВАННЫЕ GATES</span>
<div> <div>
<span className="section-eyebrow">КАДР {selectedSummary
? selectedSummary.sequence + 1
: "—"} / {review.frameCount}</span>
<strong>{selectedSummary?.frameId ?? "Загружаем запись"}</strong>
</div>
<div className="polygon-review-mode" aria-label="Режим визуализации">
{viewModes.map(([mode, label]) => (
<button
type="button"
key={mode}
data-active={reviewMode === mode ? "true" : undefined}
onClick={() => setReviewMode(mode)}
>
{label}
</button>
))}
</div>
</header>
<div className="polygon-review-stage">
{scene ? (
<LidarGroundPointCloud frame={scene} mode={reviewMode} />
) : (
<div className="polygon-review-stage__loading">Загружаем кадр</div>
)}
<div className="polygon-review-stage__legend">
<strong>{viewModes.find(([mode]) => mode === reviewMode)?.[1]}</strong>
<span>{modeExplanation(reviewMode)}</span>
</div>
</div>
{selectedSummary ? (
<div className="polygon-review-frame-metrics">
<article>
<span>Current · Ground IoU</span>
<strong>{formatPercent(selectedSummary.current.groundIou)}</strong>
<small>прогноз против эталона</small>
</article>
<article>
<span>Patchwork++ · Ground IoU</span>
<strong>{formatPercent(selectedSummary.patchwork.groundIou)}</strong>
<small>прогноз против эталона</small>
</article>
<article data-positive={selectedSummary.groundIouDelta >= 0 ? "true" : "false"}>
<span>Разница на этом кадре</span>
<strong>{formatDelta(selectedSummary.groundIouDelta)}</strong>
<small>Patchwork++ минус Current</small>
</article>
<article>
<span>Natural ground · PW++</span>
<strong>{formatPercent(
selectedSummary.patchwork.naturalGroundRecall,
)}</strong>
<small>recall на естественном грунте</small>
</article>
</div>
) : null}
<div className="polygon-review-controls">
<button
type="button"
aria-label={playing ? "Пауза" : "Воспроизвести"}
onClick={() => setPlaying((value) => !value)}
>
{playing ? "Пауза" : "Play"}
</button>
<button
type="button"
aria-label="Предыдущий кадр"
disabled={selectedPosition <= 0}
onClick={() => selectPosition(selectedPosition - 1)}
>
</button>
<button
type="button"
aria-label="Следующий кадр"
disabled={selectedPosition >= orderedFrames.length - 1}
onClick={() => selectPosition(selectedPosition + 1)}
>
</button>
<input
type="range"
min={0}
max={Math.max(0, orderedFrames.length - 1)}
value={selectedPosition}
aria-label="Позиция в записи"
onChange={(event) => selectPosition(Number(event.target.value))}
/>
<span>{selectedPosition + 1} / {orderedFrames.length}</span>
</div>
<div className="polygon-review-order">
<div>
{orderLabels.map(([order, label]) => (
<button
type="button"
key={order}
data-active={reviewOrder === order ? "true" : undefined}
onClick={() => changeOrder(order)}
>
{label}
</button>
))}
</div>
<div>
<button
type="button"
onClick={() => bestFrame && setSelectedFrameId(bestFrame.frameId)}
>
Лучший прирост
</button>
<button
type="button"
onClick={() => worstFrame && setSelectedFrameId(worstFrame.frameId)}
>
Худшее ухудшение
</button>
</div>
</div>
<div
className="polygon-review-timeline"
aria-label={`Все ${orderedFrames.length} кадров`}
>
{orderedFrames.map((frame, index) => (
<button
type="button"
key={frame.frameId}
aria-label={`Кадр ${frame.sequence + 1}: ${formatDelta(frame.groundIouDelta)}`}
title={`${frame.frameId} · ${formatDelta(frame.groundIouDelta)}`}
data-active={frame.frameId === selectedFrameId ? "true" : undefined}
style={{ "--frame-color": frameColor(frame.groundIouDelta) } as CSSProperties}
onClick={() => selectPosition(index)}
/>
))}
</div>
<p className="polygon-review-timeline-note">
Каждая риска доступный кадр. Светлая означает улучшение IoU, красная
ухудшение. Можно нажать на любую и проверить результат вручную.
</p>
</section>
<section className="polygon-review-summary">
<header>
<div>
<span className="section-eyebrow">ВЕСЬ VALIDATION SPLIT</span>
<h3>Что именно сравнивалось</h3>
</div>
<StatusBadge tone={qualification.decision.passed ? "success" : "danger"}>
{qualification.decision.passed ? "27 / 27 проверок" : "Есть проваленные проверки"}
</StatusBadge>
</header>
<p>
На каждом из {qualification.frameCount} нативных LiDAR-кадров оба алгоритма
независимо решали одну задачу: какие точки являются землёй. Их ответы
сравнивались с публичной point-aligned разметкой GOOSE.
</p>
<div>
<article>
<span>Ground IoU</span>
<strong>{formatPercent(qualification.current.micro.groundIou)}</strong>
<i></i>
<strong>{formatPercent(qualification.patchwork.micro.groundIou)}</strong>
<small>{formatDelta(
qualification.patchwork.micro.groundIou
- qualification.current.micro.groundIou,
)}</small>
</article>
<article>
<span>Natural ground recall</span>
<strong>{formatPercent(
qualification.current.micro.naturalGroundRecall,
)}</strong>
<i></i>
<strong>{formatPercent(
qualification.patchwork.micro.naturalGroundRecall,
)}</strong>
<small>{formatDelta(
qualification.patchwork.micro.naturalGroundRecall
- qualification.current.micro.naturalGroundRecall,
)}</small>
</article>
<article>
<span>Latency p95</span>
<strong>{formatMilliseconds(qualification.current.latencyMs.p95)}</strong>
<i></i>
<strong>{formatMilliseconds(qualification.patchwork.latencyMs.p95)}</strong>
<small>на кадр</small>
</article>
</div>
</section>
<details className="polygon-review-analysis">
<summary>Полные метрики и деградации</summary>
<div className="polygon-review-analysis__content">
<section>
<span className="section-eyebrow">ACCEPTANCE GATES</span>
{qualification.checks.map((check) => ( {qualification.checks.map((check) => (
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}> <p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
<i aria-hidden="true" /> <i aria-hidden="true" />
@ -335,12 +621,9 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
</code> </code>
</p> </p>
))} ))}
</div> </section>
</article> <section>
<span className="section-eyebrow">ДЕГРАДАЦИИ</span>
<article className="polygon-qualification__degradations">
<span className="section-eyebrow">ДЕТЕРМИНИРОВАННЫЕ ДЕГРАДАЦИИ</span>
<div>
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => ( {Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
<p key={profileId}> <p key={profileId}>
<strong>{profileId}</strong> <strong>{profileId}</strong>
@ -349,194 +632,59 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span> <span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
</p> </p>
))} ))}
</div> </section>
</article>
</div>
<div className="polygon-qualification__failures">
<article>
<span className="section-eyebrow">ХУДШИЕ КАДРЫ</span>
<div>
{qualification.worstFrames.slice(0, 5).map((frame) => (
<button
type="button"
key={frame.frameId}
data-active={frame.frameId === selectedFailureFrameId ? "true" : undefined}
onClick={() => setSelectedFailureFrameId(frame.frameId)}
>
<span>{frame.frameId}</span>
<code>
PW {formatPercent(frame.patchworkGroundIou)} ·
Δ {formatPercent(frame.groundIouDelta)}
</code>
</button>
))}
</div>
</article>
<article className="polygon-qualification__viewer">
<header>
<div>
<span className="section-eyebrow">РАЗБОР ОШИБКИ</span>
<strong>{failurePreview?.frameId ?? "Загружаем кадр"}</strong>
</div>
<div className="lidar-ground-modes" aria-label="Режим отображения ошибки">
{([
["ground-truth", "Ground truth"],
["current", "Current"],
["candidate", "Patchwork++"],
["disagreement", "Ошибка Current"],
["candidate-disagreement", "Ошибка PW++"],
] as const).map(([mode, label]) => (
<button
type="button"
key={mode}
data-active={failureMode === mode ? "true" : undefined}
onClick={() => setFailureMode(mode)}
>
{label}
</button>
))}
</div>
</header>
{failureFrame ? (
<LidarGroundPointCloud frame={failureFrame} mode={failureMode} />
) : (
<div className="polygon-qualification__viewer-empty">
Читаем bounded preview из sealed-артефакта прогона
</div>
)}
</article>
</div>
</section>
) : qualificationError ? (
<div className="polygon-qualification__error">
Квалификационный отчёт недоступен: {qualificationError}
</div>
) : null}
<div className="polygon-run-layout">
<GlassSurface className="polygon-run-catalog" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
<h3>{catalog?.total ?? 0} в источнике</h3>
</div> </div>
<Button </details>
size="compact" </>
variant="secondary" ) : qualification ? (
onClick={() => setReloadGeneration((value) => value + 1)} <section className="polygon-review-unavailable">
> <span className="section-eyebrow">ПОКАДРОВЫЙ ПРОСМОТР</span>
Обновить <h3>Review-pack ещё не опубликован</h3>
</Button> <p>
</header> Числовой отчёт есть, но мы не показываем его как достаточный результат:
<div className="polygon-run-list"> оператор должен иметь возможность проверить все {qualification.frameCount} кадров.
{catalog?.items.map((item) => ( </p>
<button {reviewError ? <small>{reviewError}</small> : null}
type="button" </section>
key={item.runId} ) : (
data-active={item.runId === run.runId ? "true" : undefined} <section className="polygon-review-unavailable">
onClick={() => setSelectedRunId(item.runId)} <span className="section-eyebrow">АРХИВНЫЙ ПРОГОН</span>
> <h3>В этой записи нет perception review</h3>
<i data-state={item.state} aria-hidden="true" /> <p>Технические доказательства доступны в раскрытии ниже.</p>
<span> </section>
<strong>{item.runId}</strong> )}
<small>{formatTimestamp(item.createdAtUtc)}</small>
</span>
<em>{stateLabels[item.state]}</em>
</button>
))}
</div>
</GlassSurface>
<GlassSurface className="polygon-run-identity" padding="lg"> <details className="polygon-run-technical">
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span> <summary>Технические детали прогона</summary>
<div className="polygon-run-technical__content">
<dl> <dl>
<div><dt>Run ID</dt><dd>{run.runId}</dd></div>
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div> <div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div> <div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div> <div><dt>Mission Core</dt><dd>{run.missionCoreCommit.slice(0, 12)}</dd></div>
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div> <div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div> <div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
</dl> </dl>
<div className="polygon-run-safety-boundary"> <section>
<StatusBadge tone="success">Virtual only</StatusBadge> <h4>Провайдеры</h4>
<p> {run.providers.map((provider) => (
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} · <p key={provider.identifier}>
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} · <strong>{provider.identifier}</strong>
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"} <span>{provider.version} · {provider.revision.slice(0, 16)}</span>
</p> </p>
</div> ))}
</GlassSurface> </section>
</div> <section>
<h4>Артефакты</h4>
<section className="polygon-run-providers" aria-label="Провайдеры прогона"> {detail.artifacts.map((artifact) => (
{run.providers.map((provider) => ( <p key={artifact.artifactId}>
<div key={provider.identifier}> <strong>{artifact.kind}</strong>
<i aria-hidden="true" /> <span>{formatBytes(artifact.byteLength)} · {artifact.sha256.slice(0, 12)}</span>
<span> </p>
<strong>{provider.identifier}</strong> ))}
<small>{provider.version}</small> </section>
</span>
<code>{provider.revision.slice(0, 16)}</code>
</div>
))}
</section>
<GlassSurface className="polygon-run-events" padding="lg">
<header className="polygon-run-panel-heading">
<div>
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
<h3>Последние переходы и факты</h3>
</div>
<span>revision {run.revision}</span>
</header>
<div className="polygon-run-event-list">
{visibleEvents.map((event) => (
<article key={event.sequence}>
<span className="polygon-run-event-sequence">
{String(event.sequence).padStart(3, "0")}
</span>
<div>
<strong>{event.eventType}</strong>
<small>{formatTimestamp(event.observedAtUtc)}</small>
<code>{JSON.stringify(event.payload)}</code>
</div>
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
</article>
))}
</div> </div>
</GlassSurface> </details>
<div className="polygon-run-evidence-grid">
<GlassSurface className="polygon-run-artifacts" padding="lg">
<span className="section-eyebrow">АРТЕФАКТЫ</span>
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
{detail.artifacts.length ? (
<div>
{detail.artifacts.map((artifact) => (
<article key={artifact.artifactId}>
<span>
<strong>{artifact.kind}</strong>
<small>{artifact.relativePath}</small>
</span>
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
</article>
))}
</div>
) : (
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
)}
</GlassSurface>
<GlassSurface className="polygon-run-limitations" padding="lg">
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
<h3>Что этот результат ещё не доказывает</h3>
<ul>
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
</ul>
</GlassSurface>
</div>
</div> </div>
); );
} }

View File

@ -6,6 +6,8 @@ import { createServer } from "vite";
let server; let server;
let decodeGroundQualification; let decodeGroundQualification;
let decodeGroundFailurePreview; let decodeGroundFailurePreview;
let decodeGroundReview;
let decodeGroundReviewFrame;
let fetchGroundQualification; let fetchGroundQualification;
let GroundQualificationContractError; let GroundQualificationContractError;
@ -70,6 +72,8 @@ before(async () => {
({ ({
decodeGroundQualification, decodeGroundQualification,
decodeGroundFailurePreview, decodeGroundFailurePreview,
decodeGroundReview,
decodeGroundReviewFrame,
fetchGroundQualification, fetchGroundQualification,
GroundQualificationContractError, GroundQualificationContractError,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts")); } = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
@ -132,3 +136,61 @@ test("treats missing qualification as a generic run", async () => {
}); });
assert.equal(result, null); assert.equal(result, null);
}); });
test("decodes a complete ordered frame review", () => {
const decoded = decodeGroundReview({
schema_version: "missioncore.polygon-ground-review/v1",
access: "read-only",
run_id: "goose-ground-abc",
identity_sha256: "b".repeat(64),
source_id: "goose-3d/v2025-08-22",
frame_count: 2,
preview_points: 12_000,
frames: [0, 1].map((sequence) => ({
sequence,
frame_id: `2022-07-22_flight__0071_000${sequence}`,
source_point_count: 100_000,
point_count: 12_000,
current: {
ground_iou: 0.5,
natural_ground_recall: 0.55,
obstacle_non_ground_recall: 0.91,
latency_ms: 3_000,
},
patchworkpp: {
ground_iou: 0.7,
natural_ground_recall: 0.75,
obstacle_non_ground_recall: 0.94,
latency_ms: 20,
},
ground_iou_delta: 0.2,
})),
});
assert.equal(decoded.frames.length, 2);
assert.equal(decoded.frames[1].sequence, 1);
assert.equal(decoded.frames[0].patchwork.groundIou, 0.7);
});
test("decodes a point-aligned review frame with intensity", () => {
const decoded = decodeGroundReviewFrame({
schema_version: "missioncore.polygon-ground-review-frame/v1",
access: "read-only",
run_id: "goose-ground-abc",
source_id: "goose-3d/v2025-08-22",
frame_id: "2022-07-22_flight__0071_0001",
source_point_count: 2,
point_count: 2,
sampling: "deterministic-even-index",
points_xyz_m: [[0, 0, 0], [1, 1, 1]],
intensity_0_255: [12, 255],
ground_truth_ground: [1, 0],
evaluated: [1, 1],
current_ground: [0, 0],
patchwork_ground: [1, 0],
current_disagreement: [1, 0],
patchwork_disagreement: [0, 0],
safety: { navigation_or_safety_accepted: false },
});
assert.deepEqual(decoded.intensity0To255, [12, 255]);
assert.equal(decoded.frameId, "2022-07-22_flight__0071_0001");
});

View File

@ -14,6 +14,7 @@ from k1link.datasets.goose_benchmark import (
benchmark_goose_patchwork_ground, benchmark_goose_patchwork_ground,
) )
from k1link.datasets.goose_qualification import qualify_goose_ground from k1link.datasets.goose_qualification import qualify_goose_ground
from k1link.datasets.goose_review import build_goose_ground_review_pack
app = typer.Typer( app = typer.Typer(
add_completion=False, add_completion=False,
@ -118,5 +119,44 @@ def qualify_goose_ground_command(
typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True)) typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True))
@app.command("build-goose-ground-review")
def build_goose_ground_review_command(
dataset_root: Annotated[
Path,
typer.Option("--dataset-root", exists=True, file_okay=False, resolve_path=True),
],
runs_root: Annotated[
Path,
typer.Option("--runs-root", exists=True, file_okay=False, resolve_path=True),
],
run_id: Annotated[
str,
typer.Option("--run-id", min=1, max=128),
],
workers: Annotated[
int,
typer.Option("--workers", min=1, max=32),
] = 8,
preview_points: Annotated[
int,
typer.Option("--preview-points", min=1, max=20_000),
] = 12_000,
) -> None:
"""Publish bounded visual playback for every frame of a completed run."""
try:
manifest = build_goose_ground_review_pack(
dataset_root,
runs_root,
run_id=run_id,
parallel_workers=workers,
preview_points=preview_points,
)
except (GooseAdmissionError, ValueError) as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=2) from exc
typer.echo(json.dumps(manifest, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__": if __name__ == "__main__":
app() app()

View File

@ -0,0 +1,386 @@
"""Operator review pack for a completed GOOSE ground qualification.
The public dataset and the full-resolution qualification cache remain on the
Simulation Worker D drive. This derivative stores a bounded, point-aligned
preview for every validation frame so Mission Core can provide an honest
playback/review surface instead of exposing only aggregate metrics.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import tempfile
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Final
import numpy as np
from k1link.datasets.goose_admission import (
GOOSE_ARCHIVE_FILENAME,
GOOSE_SOURCE_ID,
GooseAdmissionError,
read_goose_label_mapping,
)
from k1link.datasets.goose_profile import DEFAULT_GOOSE_PATCHWORK_PROFILE
from k1link.datasets.goose_qualification import (
REPORT_ARTIFACT_KIND,
_challenge_categories,
_is_worker_dataset_root,
_read_archive_frame,
_sha256_file,
_validation_frame_members,
)
from k1link.ground_segmentation import (
DEFAULT_GROUND_BENCHMARK_PROFILE,
GroundSegmenter,
LocalPercentileGroundSegmenter,
PatchworkPPGroundSegmenter,
)
from k1link.simulation import QualificationRunStore, RunState
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1"
DEFAULT_REVIEW_POINTS: Final = 12_000
MAX_REVIEW_POINTS: Final = 20_000
_WORKER_PATCHWORK: GroundSegmenter | None = None
def build_goose_ground_review_pack(
dataset_root: Path,
runs_root: Path,
*,
run_id: str,
parallel_workers: int = 8,
preview_points: int = DEFAULT_REVIEW_POINTS,
patchwork_module_name: str = "pypatchworkpp",
) -> dict[str, Any]:
"""Build or resume a compact visual review derivative for all run frames."""
dataset = dataset_root.expanduser().absolute()
runs = runs_root.expanduser().absolute()
if not _is_worker_dataset_root(dataset):
raise GooseAdmissionError("GOOSE review requires the canonical worker D root")
if not 1 <= parallel_workers <= 32:
raise GooseAdmissionError("parallel worker count is outside the admitted range")
if not 1 <= preview_points <= MAX_REVIEW_POINTS:
raise GooseAdmissionError("review point count is outside the admitted range")
store = QualificationRunStore(runs, read_only=True)
try:
run = store.load(run_id)
except Exception as exc:
raise GooseAdmissionError("qualification run is unavailable") from exc
if run.state is not RunState.COMPLETED:
raise GooseAdmissionError("qualification run must be completed before review export")
report_artifacts = [
artifact for artifact in run.artifacts if artifact.kind == REPORT_ARTIFACT_KIND
]
if len(report_artifacts) != 1:
raise GooseAdmissionError("qualification run has no unique report artifact")
report_artifact = report_artifacts[0]
report_path = runs / run_id / report_artifact.relative_path
if _sha256_file(report_path) != report_artifact.sha256:
raise GooseAdmissionError("qualification report digest differs")
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GooseAdmissionError("qualification report is invalid") from exc
if (
not isinstance(report, dict)
or report.get("run_id") != run_id
or not isinstance(report.get("frames"), list)
):
raise GooseAdmissionError("qualification report has no frame evidence")
admission_path = dataset / "state/goose-3d-v2025-08-22.json"
try:
admission = json.loads(admission_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GooseAdmissionError("GOOSE admission manifest is unavailable") from exc
archive_sha256 = str(admission.get("archive", {}).get("sha256", ""))
if len(archive_sha256) != 64:
raise GooseAdmissionError("GOOSE archive identity is invalid")
install_root = dataset / "goose-3d/v2025-08-22/installs" / archive_sha256
archive_path = dataset / "goose-3d/v2025-08-22/archives" / GOOSE_ARCHIVE_FILENAME
label_mapping = read_goose_label_mapping(install_root / "goose_label_mapping.csv")
members = _validation_frame_members(archive_path)
report_frames = {
str(frame["frame_id"]): frame
for frame in report["frames"]
if isinstance(frame, dict) and isinstance(frame.get("frame_id"), str)
}
if len(members) != len(report_frames):
raise GooseAdmissionError("review source differs from the qualification frame set")
identity_document = {
"schema_version": GOOSE_REVIEW_PACK_SCHEMA,
"source_run_id": run_id,
"qualification_report_sha256": report_artifact.sha256,
"archive_sha256": archive_sha256,
"preview_points": preview_points,
"sampling": "deterministic-even-index",
"coordinate_codec": "signed-centimetres-int16",
"mask_codec": "bitset-u8-v1",
}
identity_sha256 = _canonical_sha256(identity_document)
pack_root = runs.parent / "polygon-review-packs" / run_id / f"review-{identity_sha256}"
manifest_path = pack_root / "manifest.json"
if manifest_path.is_file():
return _read_completed_manifest(manifest_path, run_id, identity_sha256)
frame_root = pack_root / "frames"
frame_root.mkdir(parents=True, exist_ok=True)
pending: list[tuple[int, str, str, str, Path]] = []
completed: dict[str, dict[str, Any]] = {}
for sequence, (frame_id, point_member, label_member) in enumerate(members):
target = frame_root / f"{frame_id}.npz"
metadata = _read_review_frame_metadata(target, frame_id, preview_points)
if metadata is None:
pending.append((sequence, frame_id, point_member, label_member, target))
else:
completed[frame_id] = metadata
if pending:
with ProcessPoolExecutor(
max_workers=parallel_workers,
initializer=_initialize_patchwork_worker,
initargs=(patchwork_module_name,),
) as executor:
futures = {
executor.submit(
_build_review_frame_worker,
str(archive_path),
label_mapping,
frame_id,
point_member,
label_member,
str(target),
preview_points,
): frame_id
for _, frame_id, point_member, label_member, target in pending
}
for future in as_completed(futures):
frame_id = futures[future]
completed[frame_id] = future.result()
frames: list[dict[str, Any]] = []
for sequence, (frame_id, _, _) in enumerate(members):
source = report_frames[frame_id]
nominal = source.get("nominal")
if not isinstance(nominal, dict):
raise GooseAdmissionError("qualification frame has no nominal metrics")
current = _review_metrics(nominal.get("current"))
patchwork = _review_metrics(nominal.get("patchworkpp"))
item = completed[frame_id]
frames.append(
{
"sequence": sequence,
"frame_id": frame_id,
"source_point_count": item["source_point_count"],
"point_count": item["point_count"],
"relative_path": f"frames/{frame_id}.npz",
"sha256": item["sha256"],
"byte_length": item["byte_length"],
"current": current,
"patchworkpp": patchwork,
"ground_iou_delta": (patchwork["ground_iou"] - current["ground_iou"]),
}
)
manifest = {
**identity_document,
"identity_sha256": identity_sha256,
"source_id": GOOSE_SOURCE_ID,
"frame_count": len(frames),
"frames": frames,
"safety": {
"visualization_only": True,
"actuator_authority": False,
"navigation_or_safety_accepted": False,
},
}
_write_json_once(manifest_path, manifest)
return _read_completed_manifest(manifest_path, run_id, identity_sha256)
def _initialize_patchwork_worker(module_name: str) -> None:
global _WORKER_PATCHWORK
_WORKER_PATCHWORK = PatchworkPPGroundSegmenter.load(
DEFAULT_GOOSE_PATCHWORK_PROFILE,
module_name=module_name,
)
def _build_review_frame_worker(
archive_path: str,
label_mapping: dict[int, dict[str, Any]],
frame_id: str,
point_member: str,
label_member: str,
target: str,
preview_points: int,
) -> dict[str, Any]:
if _WORKER_PATCHWORK is None:
raise GooseAdmissionError("Patchwork++ review worker was not initialized")
frame = _read_archive_frame(Path(archive_path), point_member, label_member)
categories = _challenge_categories(frame, label_mapping)
evaluated = categories != 0
ground_truth = (categories == 2) | (categories == 3)
xyzi = np.column_stack((frame.points_xyz_m, frame.remission)).astype(
np.float32,
copy=False,
)
current = LocalPercentileGroundSegmenter(DEFAULT_GROUND_BENCHMARK_PROFILE).segment(xyzi)
patchwork = _WORKER_PATCHWORK.segment(xyzi)
if current.ground_mask.shape != (frame.point_count,) or patchwork.ground_mask.shape != (
frame.point_count,
):
raise GooseAdmissionError("review provider result is not point-aligned")
sample_count = min(frame.point_count, preview_points)
indices = np.linspace(0, frame.point_count - 1, sample_count, dtype=np.int64)
points_cm = np.rint(frame.points_xyz_m[indices] * 100.0)
if not np.all(np.isfinite(points_cm)) or np.any(np.abs(points_cm) > 32_767):
raise GooseAdmissionError("review point escaped the signed-centimetre envelope")
flags = (
evaluated[indices].astype(np.uint8)
| (ground_truth[indices].astype(np.uint8) << 1)
| (current.ground_mask[indices].astype(np.uint8) << 2)
| (patchwork.ground_mask[indices].astype(np.uint8) << 3)
)
remission = np.asarray(frame.remission[indices], dtype=np.float32)
remission_max = float(np.max(remission)) if remission.size else 0.0
if remission_max <= 1.0:
remission = remission * 255.0
remission_u8 = np.clip(np.rint(remission), 0, 255).astype(np.uint8)
path = Path(target)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
temporary_path = Path(temporary.name)
np.savez_compressed(
temporary,
schema=np.asarray([GOOSE_REVIEW_FRAME_SCHEMA]),
frame_id=np.asarray([frame_id]),
source_point_count=np.asarray([frame.point_count], dtype=np.int32),
xyz_cm=np.ascontiguousarray(points_cm, dtype="<i2"),
remission_u8=remission_u8,
flags=flags,
)
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_path, path)
return {
"source_point_count": frame.point_count,
"point_count": sample_count,
"sha256": _sha256_file(path),
"byte_length": path.stat().st_size,
}
def _read_review_frame_metadata(
path: Path,
frame_id: str,
maximum_points: int,
) -> dict[str, Any] | None:
if not path.is_file():
return None
try:
with np.load(path, allow_pickle=False) as source:
schema = str(source["schema"][0])
stored_frame_id = str(source["frame_id"][0])
source_point_count = int(source["source_point_count"][0])
xyz_cm = source["xyz_cm"]
remission = source["remission_u8"]
flags = source["flags"]
point_count = int(xyz_cm.shape[0])
if (
schema != GOOSE_REVIEW_FRAME_SCHEMA
or stored_frame_id != frame_id
or xyz_cm.dtype != np.dtype("<i2")
or xyz_cm.shape != (point_count, 3)
or remission.dtype != np.uint8
or remission.shape != (point_count,)
or flags.dtype != np.uint8
or flags.shape != (point_count,)
or not 1 <= point_count <= maximum_points
or source_point_count < point_count
):
return None
except (OSError, ValueError, KeyError, IndexError):
return None
return {
"source_point_count": source_point_count,
"point_count": point_count,
"sha256": _sha256_file(path),
"byte_length": path.stat().st_size,
}
def _review_metrics(value: object) -> dict[str, float]:
if not isinstance(value, dict) or not isinstance(value.get("metrics"), dict):
raise GooseAdmissionError("qualification frame metrics are unavailable")
metrics = value["metrics"]
return {
"ground_iou": _finite_fraction(metrics.get("ground_iou")),
"natural_ground_recall": _finite_fraction(metrics.get("natural_ground_recall")),
"obstacle_non_ground_recall": _finite_fraction(metrics.get("obstacle_non_ground_recall")),
"latency_ms": _finite_nonnegative(value.get("latency_ms")),
}
def _finite_fraction(value: object) -> float:
number = _finite_nonnegative(value)
if number > 1:
raise GooseAdmissionError("qualification metric is outside 0..1")
return number
def _finite_nonnegative(value: object) -> float:
if not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
raise GooseAdmissionError("qualification metric is invalid")
return float(value)
def _read_completed_manifest(
path: Path,
run_id: str,
identity_sha256: str,
) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GooseAdmissionError("GOOSE review manifest is invalid") from exc
if (
not isinstance(value, dict)
or value.get("schema_version") != GOOSE_REVIEW_PACK_SCHEMA
or value.get("source_run_id") != run_id
or value.get("identity_sha256") != identity_sha256
or not isinstance(value.get("frames"), list)
or value.get("frame_count") != len(value["frames"])
):
raise GooseAdmissionError("GOOSE review manifest contract differs")
return value
def _canonical_sha256(value: dict[str, Any]) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
def _write_json_once(path: Path, value: dict[str, Any]) -> None:
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + b"\n"
if path.exists():
if path.is_file() and path.read_bytes() == encoded:
return
raise GooseAdmissionError("immutable GOOSE review manifest already exists")
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
temporary_path = Path(temporary.name)
temporary.write(encoded)
temporary.flush()
os.fsync(temporary.fileno())
os.replace(temporary_path, path)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import math
import os import os
import re import re
import secrets import secrets
@ -10,6 +11,7 @@ from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Final from typing import Annotated, Any, Final
import numpy as np
from fastapi import APIRouter, Header, HTTPException, Query from fastapi import APIRouter, Header, HTTPException, Query
from fastapi import Path as PathParameter from fastapi import Path as PathParameter
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -30,6 +32,7 @@ from k1link.simulation.worker_gateway import (
) )
POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT" POLYGON_RUNS_ROOT_ENV: Final = "MISSIONCORE_POLYGON_RUNS_ROOT"
POLYGON_REVIEW_ROOT_ENV: Final = "MISSIONCORE_POLYGON_REVIEW_ROOT"
POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET" POLYGON_WORKER_SOCKET_ENV: Final = "MISSIONCORE_POLYGON_WORKER_SOCKET"
POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL" POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT" MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
@ -41,7 +44,15 @@ GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report"
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview" GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1" GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1" GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
GROUND_REVIEW_SCHEMA: Final = "missioncore.polygon-ground-review/v1"
GROUND_REVIEW_FRAME_SCHEMA: Final = "missioncore.polygon-ground-review-frame/v1"
GOOSE_REVIEW_PACK_SCHEMA: Final = "missioncore.goose-ground-review-pack/v1"
GOOSE_REVIEW_FRAME_SCHEMA: Final = "missioncore.goose-ground-review-frame/v1"
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2 MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
MAX_REVIEW_MANIFEST_BYTES: Final = 8 * 1024**2
MAX_REVIEW_FRAME_BYTES: Final = 2 * 1024**2
MAX_REVIEW_FRAMES: Final = 2_000
MAX_REVIEW_POINTS: Final = 20_000
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = ( READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"Архивный UI-0 публикует только квалификационные доказательства; " "Архивный UI-0 публикует только квалификационные доказательства; "
@ -258,6 +269,121 @@ def build_polygon_router(
**{key: preview[key] for key in required}, **{key: preview[key] for key in required},
} }
@router.get("/runs/{run_id}/qualification/review")
def get_polygon_ground_review(
run_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
) -> dict[str, Any]:
_, run = _load_run(root_provider, run_id)
_, manifest = _read_ground_review_manifest(root_provider, run.run_id)
return {
"schema_version": GROUND_REVIEW_SCHEMA,
"access": "read-only",
"run_id": run.run_id,
"identity_sha256": manifest["identity_sha256"],
"source_id": manifest["source_id"],
"frame_count": manifest["frame_count"],
"preview_points": manifest["preview_points"],
"frames": [
{
key: frame[key]
for key in (
"sequence",
"frame_id",
"source_point_count",
"point_count",
"current",
"patchworkpp",
"ground_iou_delta",
)
}
for frame in manifest["frames"]
],
"safety": manifest["safety"],
}
@router.get("/runs/{run_id}/qualification/review/frames/{frame_id}")
def get_polygon_ground_review_frame(
run_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
frame_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
) -> dict[str, Any]:
_, run = _load_run(root_provider, run_id)
pack_root, manifest = _read_ground_review_manifest(root_provider, run.run_id)
matches = [frame for frame in manifest["frames"] if frame.get("frame_id") == frame_id]
if len(matches) != 1:
raise HTTPException(
status_code=404,
detail="Кадр визуального прогона не найден.",
)
frame = matches[0]
path = _verified_review_frame_path(pack_root, frame)
try:
with np.load(path, allow_pickle=False) as source:
schema = str(source["schema"][0])
stored_frame_id = str(source["frame_id"][0])
source_point_count = int(source["source_point_count"][0])
xyz_cm = np.asarray(source["xyz_cm"])
remission = np.asarray(source["remission_u8"])
flags = np.asarray(source["flags"])
except (OSError, ValueError, KeyError, IndexError) as exc:
raise HTTPException(
status_code=500,
detail="Кадр визуального прогона повреждён.",
) from exc
point_count = int(frame["point_count"])
if (
schema != GOOSE_REVIEW_FRAME_SCHEMA
or stored_frame_id != frame_id
or source_point_count != frame["source_point_count"]
or xyz_cm.dtype != np.dtype("<i2")
or xyz_cm.shape != (point_count, 3)
or remission.dtype != np.uint8
or remission.shape != (point_count,)
or flags.dtype != np.uint8
or flags.shape != (point_count,)
or not 1 <= point_count <= MAX_REVIEW_POINTS
or np.any(flags > 15)
):
raise HTTPException(
status_code=500,
detail="Кадр визуального прогона нарушает контракт.",
)
evaluated = (flags & 1) != 0
ground_truth = (flags & 2) != 0
current = (flags & 4) != 0
patchwork = (flags & 8) != 0
return {
"schema_version": GROUND_REVIEW_FRAME_SCHEMA,
"access": "read-only",
"run_id": run.run_id,
"source_id": manifest["source_id"],
"frame_id": frame_id,
"source_point_count": source_point_count,
"point_count": point_count,
"sampling": "deterministic-even-index",
"points_xyz_m": (xyz_cm.astype(np.float32) / 100.0).tolist(),
"intensity_0_255": remission.tolist(),
"ground_truth_ground": ground_truth.astype(np.uint8).tolist(),
"evaluated": evaluated.astype(np.uint8).tolist(),
"current_ground": current.astype(np.uint8).tolist(),
"patchwork_ground": patchwork.astype(np.uint8).tolist(),
"current_disagreement": (evaluated & (current != ground_truth))
.astype(np.uint8)
.tolist(),
"patchwork_disagreement": (evaluated & (patchwork != ground_truth))
.astype(np.uint8)
.tolist(),
"safety": manifest["safety"],
}
@router.get("/worker") @router.get("/worker")
def get_polygon_worker() -> dict[str, Any]: def get_polygon_worker() -> dict[str, Any]:
gateway = worker_provider() gateway = worker_provider()
@ -485,6 +611,161 @@ def _read_registered_json(
return value return value
def _read_ground_review_manifest(
root_provider: RootProvider,
run_id: str,
) -> tuple[Path, dict[str, Any]]:
runs_root = root_provider()
if runs_root is None or not runs_root.is_absolute():
raise HTTPException(
status_code=503,
detail="Источник визуальных прогонов не настроен.",
)
configured = os.environ.get(POLYGON_REVIEW_ROOT_ENV, "").strip()
review_root = (
Path(configured).expanduser() if configured else runs_root.parent / "polygon-review-packs"
)
if not review_root.is_absolute():
raise HTTPException(
status_code=503,
detail="Источник визуальных прогонов настроен некорректно.",
)
run_review_root = review_root / run_id
try:
resolved_review_root = review_root.resolve()
resolved_run_root = run_review_root.resolve(strict=True)
resolved_run_root.relative_to(resolved_review_root)
except (OSError, ValueError) as exc:
raise HTTPException(
status_code=404,
detail="Покадровый просмотр для прогона ещё не опубликован.",
) from exc
candidates = sorted(
path
for path in resolved_run_root.iterdir()
if path.is_dir()
and not path.is_symlink()
and re.fullmatch(r"review-[a-f0-9]{64}", path.name)
and (path / "manifest.json").is_file()
)
if not candidates:
raise HTTPException(
status_code=404,
detail="Покадровый просмотр для прогона ещё не опубликован.",
)
pack_root = candidates[-1]
manifest_path = pack_root / "manifest.json"
try:
if manifest_path.is_symlink() or manifest_path.stat().st_size > MAX_REVIEW_MANIFEST_BYTES:
raise OSError
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=500,
detail="Манифест визуального прогона повреждён.",
) from exc
frames = manifest.get("frames") if isinstance(manifest, dict) else None
safety = manifest.get("safety") if isinstance(manifest, dict) else None
if (
not isinstance(manifest, dict)
or manifest.get("schema_version") != GOOSE_REVIEW_PACK_SCHEMA
or manifest.get("source_run_id") != run_id
or not isinstance(manifest.get("identity_sha256"), str)
or not re.fullmatch(r"[a-f0-9]{64}", manifest["identity_sha256"])
or pack_root.name != f"review-{manifest['identity_sha256']}"
or not isinstance(manifest.get("source_id"), str)
or not isinstance(manifest.get("preview_points"), int)
or not isinstance(frames, list)
or not 1 <= len(frames) <= MAX_REVIEW_FRAMES
or manifest.get("frame_count") != len(frames)
or not isinstance(safety, dict)
or safety.get("visualization_only") is not True
or safety.get("navigation_or_safety_accepted") is not False
):
raise HTTPException(
status_code=500,
detail="Манифест визуального прогона нарушает контракт.",
)
seen: set[str] = set()
for expected_sequence, frame in enumerate(frames):
if (
not isinstance(frame, dict)
or frame.get("sequence") != expected_sequence
or not isinstance(frame.get("frame_id"), str)
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", frame["frame_id"])
or frame["frame_id"] in seen
or not isinstance(frame.get("source_point_count"), int)
or not isinstance(frame.get("point_count"), int)
or not 1 <= frame["point_count"] <= MAX_REVIEW_POINTS
or frame["source_point_count"] < frame["point_count"]
or not isinstance(frame.get("relative_path"), str)
or not isinstance(frame.get("sha256"), str)
or not re.fullmatch(r"[a-f0-9]{64}", frame["sha256"])
or not isinstance(frame.get("byte_length"), int)
or not 1 <= frame["byte_length"] <= MAX_REVIEW_FRAME_BYTES
or not _valid_review_metrics(frame.get("current"))
or not _valid_review_metrics(frame.get("patchworkpp"))
or not isinstance(frame.get("ground_iou_delta"), (int, float))
or not math.isfinite(frame["ground_iou_delta"])
or not -1 <= frame["ground_iou_delta"] <= 1
):
raise HTTPException(
status_code=500,
detail="Индекс кадров визуального прогона нарушает контракт.",
)
seen.add(frame["frame_id"])
return pack_root, manifest
def _valid_review_metrics(value: object) -> bool:
if not isinstance(value, dict):
return False
for key in (
"ground_iou",
"natural_ground_recall",
"obstacle_non_ground_recall",
):
metric = value.get(key)
if (
not isinstance(metric, (int, float))
or not math.isfinite(metric)
or not 0 <= metric <= 1
):
return False
latency = value.get("latency_ms")
return isinstance(latency, (int, float)) and math.isfinite(latency) and latency >= 0
def _verified_review_frame_path(pack_root: Path, frame: dict[str, Any]) -> Path:
relative = Path(frame["relative_path"])
if relative.is_absolute() or relative.parts[:1] != ("frames",):
raise HTTPException(
status_code=500,
detail="Кадр визуального прогона нарушает границу.",
)
path = pack_root / relative
try:
resolved = path.resolve(strict=True)
resolved.relative_to(pack_root)
if (
path.is_symlink()
or not resolved.is_file()
or resolved.stat().st_size != frame["byte_length"]
):
raise OSError
except (OSError, ValueError) as exc:
raise HTTPException(
status_code=500,
detail="Файл визуального прогона нарушает границу.",
) from exc
if hashlib.sha256(resolved.read_bytes()).hexdigest() != frame["sha256"]:
raise HTTPException(
status_code=500,
detail="Кадр визуального прогона не прошёл SHA-256.",
)
return resolved
def _run_summary(run: QualificationRun) -> dict[str, Any]: def _run_summary(run: QualificationRun) -> dict[str, Any]:
return { return {
"run_id": run.run_id, "run_id": run.run_id,

View File

@ -6,6 +6,7 @@ from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import numpy as np
import pytest import pytest
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
@ -333,6 +334,104 @@ def test_polygon_api_rejects_tampered_ground_qualification(
assert "SHA-256" in failure.value.detail or "файла" in failure.value.detail assert "SHA-256" in failure.value.detail or "файла" in failure.value.detail
def _review_pack(root: Path, run_id: str, frame_id: str) -> Path:
identity = "d" * 64
pack = root.parent / "polygon-review-packs" / run_id / f"review-{identity}"
frames = pack / "frames"
frames.mkdir(parents=True)
frame_path = frames / f"{frame_id}.npz"
np.savez_compressed(
frame_path,
schema=np.asarray(["missioncore.goose-ground-review-frame/v1"]),
frame_id=np.asarray([frame_id]),
source_point_count=np.asarray([3], dtype=np.int32),
xyz_cm=np.asarray([[0, 0, 0], [100, -100, 50]], dtype="<i2"),
remission_u8=np.asarray([12, 255], dtype=np.uint8),
flags=np.asarray([15, 1], dtype=np.uint8),
)
metric = {
"ground_iou": 0.5,
"natural_ground_recall": 0.6,
"obstacle_non_ground_recall": 0.95,
"latency_ms": 20.0,
}
manifest = {
"schema_version": "missioncore.goose-ground-review-pack/v1",
"source_run_id": run_id,
"identity_sha256": identity,
"source_id": "goose-3d/v2025-08-22",
"preview_points": 12_000,
"frame_count": 1,
"frames": [
{
"sequence": 0,
"frame_id": frame_id,
"source_point_count": 3,
"point_count": 2,
"relative_path": f"frames/{frame_id}.npz",
"sha256": hashlib.sha256(frame_path.read_bytes()).hexdigest(),
"byte_length": frame_path.stat().st_size,
"current": metric,
"patchworkpp": {**metric, "ground_iou": 0.7},
"ground_iou_delta": 0.2,
}
],
"safety": {
"visualization_only": True,
"actuator_authority": False,
"navigation_or_safety_accepted": False,
},
}
(pack / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
return frame_path
def test_polygon_api_publishes_all_frame_review_without_paths(tmp_path: Path) -> None:
root = tmp_path / "runs"
run, frame_id = _qualification_run(root)
_review_pack(root, run.run_id, frame_id)
router = build_polygon_router(root_provider=lambda: root)
review = _endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification/review",
"GET",
)(run_id=run.run_id)
frame = _endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification/review/frames/{frame_id}",
"GET",
)(run_id=run.run_id, frame_id=frame_id)
assert review["schema_version"] == "missioncore.polygon-ground-review/v1"
assert review["frame_count"] == 1
assert review["frames"][0]["ground_iou_delta"] == pytest.approx(0.2)
assert "relative_path" not in review["frames"][0]
assert frame["schema_version"] == "missioncore.polygon-ground-review-frame/v1"
assert frame["points_xyz_m"] == [[0.0, 0.0, 0.0], [1.0, -1.0, 0.5]]
assert frame["intensity_0_255"] == [12, 255]
assert frame["ground_truth_ground"] == [1, 0]
assert frame["patchwork_ground"] == [1, 0]
assert str(tmp_path) not in repr({"review": review, "frame": frame})
def test_polygon_api_rejects_tampered_review_frame(tmp_path: Path) -> None:
root = tmp_path / "runs"
run, frame_id = _qualification_run(root)
frame_path = _review_pack(root, run.run_id, frame_id)
frame_path.write_bytes(b"tampered")
router = build_polygon_router(root_provider=lambda: root)
with pytest.raises(HTTPException) as failure:
_endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification/review/frames/{frame_id}",
"GET",
)(run_id=run.run_id, frame_id=frame_id)
assert failure.value.status_code == 500
assert "SHA-256" in failure.value.detail or "файл" in failure.value.detail.lower()
class _FakeWorkerGateway: class _FakeWorkerGateway:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, str]] = [] self.calls: list[tuple[str, str]] = []