feat(polygon): qualify GOOSE ground providers

This commit is contained in:
DCCONSTRUCTIONS 2026-07-25 16:02:20 +03:00
parent 60ba64004b
commit d6decbe05c
14 changed files with 2718 additions and 22 deletions

View File

@ -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<string, QualificationMetricAggregate>;
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<Response>;
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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new GroundQualificationContractError(`${field} должен быть объектом.`);
}
return value as Record<string, unknown>;
}
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<string, QualificationMetricAggregate> = {};
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<unknown> {
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<GroundQualification | null> {
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<GroundFailurePreview> {
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,
),
);
}

View File

@ -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));

View File

@ -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));

View File

@ -275,10 +275,11 @@ export function DatasetGatewayWorkspace() {
</Button>
<Button
size="compact"
disabled
title="Прогон станет доступен после импорта датасета"
variant="secondary"
title="Открыть read-only архив квалификационных прогонов"
onClick={() => window.location.assign("/?workspace=polygon-run")}
>
Создать прогон
Открыть прогоны
</Button>
</div>
</footer>

View File

@ -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<PolygonRunCatalog | null>(null);
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
@ -69,6 +90,12 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
const [loading, setLoading] = useState(route.error === null);
const [error, setError] = useState<string | null>(route.error);
const [reloadGeneration, setReloadGeneration] = useState(0);
const [qualification, setQualification] = useState<GroundQualification | null>(null);
const [qualificationError, setQualificationError] = useState<string | null>(null);
const [failurePreview, setFailurePreview] = useState<GroundFailurePreview | null>(null);
const [selectedFailureFrameId, setSelectedFailureFrameId] = useState<string | null>(null);
const [failureMode, setFailureMode] =
useState<LidarGroundViewMode>("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) {
</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>
{qualification.checks.map((check) => (
<p key={check.checkId} data-passed={check.passed ? "true" : "false"}>
<i aria-hidden="true" />
<span>{check.checkId}</span>
<code>
{check.observed.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
{" "}{check.operator}{" "}
{check.threshold.toLocaleString("ru-RU", { maximumFractionDigits: 3 })}
</code>
</p>
))}
</div>
</article>
<article className="polygon-qualification__degradations">
<span className="section-eyebrow">ДЕТЕРМИНИРОВАННЫЕ ДЕГРАДАЦИИ</span>
<div>
{Object.entries(qualification.degradations).map(([profileId, aggregate]) => (
<p key={profileId}>
<strong>{profileId}</strong>
<span>IoU {formatPercent(aggregate.micro.groundIou)}</span>
<span>Natural {formatPercent(aggregate.micro.naturalGroundRecall)}</span>
<span>p95 {formatMilliseconds(aggregate.latencyMs.p95)}</span>
</p>
))}
</div>
</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">

View File

@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodeGroundQualification;
let decodeGroundFailurePreview;
let fetchGroundQualification;
let GroundQualificationContractError;
const aggregate = {
micro: {
ground_iou: 0.75,
natural_ground_recall: 0.8,
obstacle_non_ground_recall: 0.92,
},
latency_ms: { p50: 12, p95: 18, maximum: 25 },
assigned_fraction: 1,
source_coverage: 1,
frame_count: 961,
};
const qualification = {
schema_version: "missioncore.polygon-ground-qualification/v1",
access: "read-only",
run_id: "goose-ground-abc",
identity_sha256: "a".repeat(64),
source_id: "goose-3d/v2025-08-22",
split: "validation",
frame_count: 961,
aggregates: {
current: { ...aggregate, micro: { ...aggregate.micro, ground_iou: 0.5 } },
patchworkpp: aggregate,
},
degradations: { "range-20m": { ...aggregate, source_coverage: 0.6 } },
checks: [{
check_id: "ground-iou-gain",
observed: 0.25,
operator: ">=",
threshold: 0.07,
passed: true,
}],
worst_frames: [{
frame_id: "2022-07-22_flight__0071_0001",
current_ground_iou: 0.6,
patchwork_ground_iou: 0.55,
ground_iou_delta: -0.05,
patchwork_natural_ground_recall: 0.7,
}],
decision: {
status: "shadow-candidate",
passed: true,
promoted_to_navigation_or_safety: false,
reason: "all gates passed",
},
safety: {
qualification_only: true,
actuator_authority: false,
navigation_or_safety_accepted: false,
},
};
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeGroundQualification,
decodeGroundFailurePreview,
fetchGroundQualification,
GroundQualificationContractError,
} = await server.ssrLoadModule("/src/core/polygon/groundQualification.ts"));
});
after(async () => {
await server?.close();
});
test("decodes a bounded shadow-only qualification", () => {
const decoded = decodeGroundQualification(qualification);
assert.equal(decoded.frameCount, 961);
assert.equal(decoded.patchwork.micro.groundIou, 0.75);
assert.equal(decoded.degradations["range-20m"].sourceCoverage, 0.6);
assert.equal(decoded.decision.promotedToNavigationOrSafety, false);
});
test("rejects navigation or safety promotion", () => {
assert.throws(
() => decodeGroundQualification({
...qualification,
decision: {
...qualification.decision,
promoted_to_navigation_or_safety: true,
},
}),
GroundQualificationContractError,
);
});
test("decodes point-aligned failure preview", () => {
const decoded = decodeGroundFailurePreview({
schema_version: "missioncore.polygon-ground-failure-preview/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]],
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.equal(decoded.pointCount, 2);
assert.deepEqual(decoded.patchworkGround, [1, 0]);
});
test("treats missing qualification as a generic run", async () => {
const result = await fetchGroundQualification("goose-ground-abc", {
fetcher: async () => new Response(
JSON.stringify({ detail: "not found" }),
{ status: 404, headers: { "content-type": "application/json" } },
),
});
assert.equal(result, null);
});

View File

@ -9,6 +9,7 @@ from k1link.datasets.gateway import (
configured_dataset_ground_preview,
configured_dataset_preview,
dataset_gateway_catalog,
decode_semantic_kitti_frame,
read_dataset_admission_manifest,
read_dataset_ground_preview,
read_dataset_native_scan_preview,
@ -27,6 +28,16 @@ from k1link.datasets.goose_profile import (
GOOSE_PATCHWORK_PROFILE_SCHEMA,
GoosePatchworkProfile,
)
from k1link.datasets.goose_qualification import (
DEFAULT_DEGRADATIONS,
GOOSE_QUALIFICATION_FRAME_SCHEMA,
GOOSE_QUALIFICATION_PREVIEW_SCHEMA,
GOOSE_QUALIFICATION_PROFILE_SCHEMA,
GOOSE_QUALIFICATION_REPORT_SCHEMA,
DegradationProfile,
GroundAcceptancePolicy,
qualify_goose_ground,
)
__all__ = [
"DATASET_GATEWAY_CATALOG_SCHEMA",
@ -39,15 +50,24 @@ __all__ = [
"GOOSE_GROUND_BENCHMARK_SCHEMA",
"GOOSE_GROUND_PREVIEW_SCHEMA",
"GOOSE_PATCHWORK_PROFILE_SCHEMA",
"GOOSE_QUALIFICATION_FRAME_SCHEMA",
"GOOSE_QUALIFICATION_PREVIEW_SCHEMA",
"GOOSE_QUALIFICATION_PROFILE_SCHEMA",
"GOOSE_QUALIFICATION_REPORT_SCHEMA",
"GoosePatchworkProfile",
"GroundAcceptancePolicy",
"DegradationProfile",
"DEFAULT_DEGRADATIONS",
"benchmark_goose_current_ground",
"benchmark_goose_patchwork_ground",
"configured_dataset_admission_manifest",
"configured_dataset_ground_preview",
"configured_dataset_preview",
"dataset_gateway_catalog",
"decode_semantic_kitti_frame",
"read_dataset_admission_manifest",
"read_dataset_ground_preview",
"read_dataset_native_scan_preview",
"read_semantic_kitti_frame",
"qualify_goose_ground",
]

View File

@ -13,6 +13,7 @@ from k1link.datasets.goose_benchmark import (
benchmark_goose_current_ground,
benchmark_goose_patchwork_ground,
)
from k1link.datasets.goose_qualification import qualify_goose_ground
app = typer.Typer(
add_completion=False,
@ -83,5 +84,39 @@ def benchmark_goose_patchwork_ground_command(
typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True))
@app.command("qualify-goose-ground")
def qualify_goose_ground_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", file_okay=False, resolve_path=True),
],
mission_core_commit: Annotated[
str,
typer.Option("--mission-core-commit", min=7, max=64),
],
workers: Annotated[
int,
typer.Option("--workers", min=1, max=32),
] = 8,
) -> None:
"""Run the immutable GOOSE validation-split ground qualification."""
try:
report = qualify_goose_ground(
dataset_root,
runs_root,
mission_core_commit=mission_core_commit,
parallel_workers=workers,
)
except (GooseAdmissionError, ValueError) as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(code=2) from exc
typer.echo(json.dumps(report, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
app()

View File

@ -67,27 +67,45 @@ def read_semantic_kitti_frame(
) -> DatasetPointFrame:
"""Read one GOOSE/SemanticKITTI XYZI + packed-label frame losslessly."""
if maximum_points <= 0:
raise DatasetFrameError("maximum_points must be positive")
try:
point_size = point_path.stat().st_size
label_size = label_path.stat().st_size
_validate_semantic_kitti_sizes(
point_size,
label_size,
maximum_points=maximum_points,
)
point_bytes = point_path.read_bytes()
label_bytes = label_path.read_bytes()
except OSError as exc:
raise DatasetFrameError("dataset point or label file is unavailable") from exc
if point_size == 0 or point_size % 16:
raise DatasetFrameError("point frame must contain little-endian float32 XYZI tuples")
if label_size % 4:
raise DatasetFrameError("label frame must contain packed little-endian uint32 values")
point_count = point_size // 16
if point_count > maximum_points:
raise DatasetFrameError("dataset point frame exceeds the configured safety limit")
if label_size // 4 != point_count:
raise DatasetFrameError("point and label counts do not match")
return decode_semantic_kitti_frame(
point_bytes,
label_bytes,
maximum_points=maximum_points,
)
def decode_semantic_kitti_frame(
point_bytes: bytes,
label_bytes: bytes,
*,
maximum_points: int = 2_000_000,
) -> DatasetPointFrame:
"""Decode one in-memory SemanticKITTI frame without extracting an archive."""
point_size = len(point_bytes)
label_size = len(label_bytes)
point_count = _validate_semantic_kitti_sizes(
point_size,
label_size,
maximum_points=maximum_points,
)
try:
xyzi = np.fromfile(point_path, dtype="<f4").reshape((-1, 4))
packed_labels = np.fromfile(label_path, dtype="<u4")
except (OSError, ValueError) as exc:
xyzi = np.frombuffer(point_bytes, dtype="<f4").reshape((-1, 4))
packed_labels = np.frombuffer(label_bytes, dtype="<u4")
except ValueError as exc:
raise DatasetFrameError("dataset frame cannot be decoded") from exc
if not np.isfinite(xyzi).all():
raise DatasetFrameError("dataset point frame contains non-finite values")
@ -98,9 +116,31 @@ def read_semantic_kitti_frame(
instance = np.ascontiguousarray(packed_labels >> np.uint32(16), dtype=np.uint16)
for values in (points, remission, semantic, instance):
values.setflags(write=False)
if points.shape[0] != point_count:
raise DatasetFrameError("decoded point count differs from the admitted size")
return DatasetPointFrame(points, remission, semantic, instance)
def _validate_semantic_kitti_sizes(
point_size: int,
label_size: int,
*,
maximum_points: int,
) -> int:
if maximum_points <= 0:
raise DatasetFrameError("maximum_points must be positive")
if point_size == 0 or point_size % 16:
raise DatasetFrameError("point frame must contain little-endian float32 XYZI tuples")
if label_size % 4:
raise DatasetFrameError("label frame must contain packed little-endian uint32 values")
point_count = point_size // 16
if point_count > maximum_points:
raise DatasetFrameError("dataset point frame exceeds the configured safety limit")
if label_size // 4 != point_count:
raise DatasetFrameError("point and label counts do not match")
return point_count
def _configured_dataset_root() -> Path | None:
raw = os.environ.get(DATASET_ROOT_ENV, "").strip()
return Path(raw).expanduser().absolute() if raw else None

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@ from k1link.web.plugin_runtime import (
PluginNotFoundError,
PluginRuntimeUnavailableError,
)
from k1link.web.polygon_api import build_polygon_router
from k1link.web.polygon_api import build_polygon_router, configured_polygon_runs_root
from k1link.web.session_api import build_session_router
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
@ -398,7 +398,12 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_polygon_router())
app.include_router(
build_polygon_router(
root_provider=lambda: configured_polygon_runs_root()
or REPOSITORY_ROOT / ".runtime" / "polygon-runs"
)
)
app.include_router(
build_lidar_router(
root_provider=lambda: (

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import json
import os
import re
import secrets
@ -33,6 +35,13 @@ POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/v1"
GROUND_QUALIFICATION_SCHEMA: Final = "missioncore.polygon-ground-qualification/v1"
GROUND_FAILURE_PREVIEW_SCHEMA: Final = "missioncore.polygon-ground-failure-preview/v1"
GROUND_REPORT_ARTIFACT_KIND: Final = "goose-ground-qualification-report"
GROUND_FAILURE_ARTIFACT_KIND: Final = "goose-ground-qualification-failure-preview"
GROUND_REPORT_SCHEMA: Final = "missioncore.goose-ground-qualification-report/v1"
GROUND_FAILURE_SCHEMA: Final = "missioncore.goose-ground-qualification-failure-preview/v1"
MAX_QUALIFICATION_ARTIFACT_BYTES: Final = 32 * 1024**2
COMMIT_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
READ_ONLY_LIMITATIONS: Final[tuple[str, ...]] = (
"Архивный UI-0 публикует только квалификационные доказательства; "
@ -165,6 +174,90 @@ def build_polygon_router(
"limitations": list(READ_ONLY_LIMITATIONS),
}
@router.get("/runs/{run_id}/qualification")
def get_polygon_ground_qualification(
run_id: Annotated[
str,
PathParameter(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"),
],
) -> dict[str, Any]:
store, run = _load_run(root_provider, run_id)
report = _read_registered_json(
store,
run,
artifact_kind=GROUND_REPORT_ARTIFACT_KIND,
expected_schema=GROUND_REPORT_SCHEMA,
)
required = (
"identity_sha256",
"source_id",
"split",
"frame_count",
"aggregates",
"degradations",
"checks",
"worst_frames",
"decision",
"safety",
)
if any(key not in report for key in required):
raise HTTPException(
status_code=500,
detail="Отчёт квалификации имеет неполный контракт.",
)
return {
"schema_version": GROUND_QUALIFICATION_SCHEMA,
"access": "read-only",
"run_id": run.run_id,
**{key: report[key] for key in required},
}
@router.get("/runs/{run_id}/qualification/failures/{frame_id}")
def get_polygon_ground_failure_preview(
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]:
store, run = _load_run(root_provider, run_id)
preview = _read_registered_json(
store,
run,
artifact_kind=GROUND_FAILURE_ARTIFACT_KIND,
expected_schema=GROUND_FAILURE_SCHEMA,
frame_id=frame_id,
)
required = (
"source_id",
"frame_id",
"source_point_count",
"point_count",
"sampling",
"points_xyz_m",
"ground_truth_ground",
"evaluated",
"current_ground",
"patchwork_ground",
"current_disagreement",
"patchwork_disagreement",
"safety",
)
if any(key not in preview for key in required) or preview["frame_id"] != frame_id:
raise HTTPException(
status_code=500,
detail="Preview проблемного кадра имеет неполный контракт.",
)
return {
"schema_version": GROUND_FAILURE_PREVIEW_SCHEMA,
"access": "read-only",
"run_id": run.run_id,
**{key: preview[key] for key in required},
}
@router.get("/worker")
def get_polygon_worker() -> dict[str, Any]:
gateway = worker_provider()
@ -315,6 +408,83 @@ def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
) from exc
def _load_run(
root_provider: RootProvider,
run_id: str,
) -> tuple[QualificationRunStore, QualificationRun]:
store = _open_read_only_store(root_provider)
try:
return store, store.load(run_id)
except QualificationRunNotFoundError as exc:
raise HTTPException(status_code=404, detail="Прогон Полигона не найден.") from exc
except QualificationRunIntegrityError as exc:
raise HTTPException(
status_code=500,
detail="Доказательства прогона не прошли проверку целостности.",
) from exc
def _read_registered_json(
store: QualificationRunStore,
run: QualificationRun,
*,
artifact_kind: str,
expected_schema: str,
frame_id: str | None = None,
) -> dict[str, Any]:
candidates = [artifact for artifact in run.artifacts if artifact.kind == artifact_kind]
if frame_id is not None:
candidates = [
artifact for artifact in candidates if Path(artifact.relative_path).stem == frame_id
]
if len(candidates) != 1:
raise HTTPException(
status_code=404,
detail="Квалификационный артефакт для этого прогона не найден.",
)
artifact = candidates[0]
if artifact.byte_length > MAX_QUALIFICATION_ARTIFACT_BYTES:
raise HTTPException(status_code=500, detail="Квалификационный артефакт слишком велик.")
run_root = (store.root / run.run_id).resolve()
path = run_root / artifact.relative_path
try:
resolved = path.resolve(strict=True)
resolved.relative_to(run_root)
except (OSError, ValueError) as exc:
raise HTTPException(
status_code=500,
detail="Путь квалификационного артефакта нарушает границу прогона.",
) from exc
if (
path.is_symlink()
or not resolved.is_file()
or resolved.stat().st_size != artifact.byte_length
):
raise HTTPException(
status_code=500,
detail="Квалификационный артефакт не прошёл проверку файла.",
)
digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
if digest != artifact.sha256:
raise HTTPException(
status_code=500,
detail="Квалификационный артефакт не прошёл проверку SHA-256.",
)
try:
value = json.loads(resolved.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise HTTPException(
status_code=500,
detail="Квалификационный артефакт не является допустимым JSON.",
) from exc
if not isinstance(value, dict) or value.get("schema_version") != expected_schema:
raise HTTPException(
status_code=500,
detail="Квалификационный артефакт имеет неизвестную схему.",
)
return value
def _run_summary(run: QualificationRun) -> dict[str, Any]:
return {
"run_id": run.run_id,

View File

@ -0,0 +1,128 @@
from __future__ import annotations
import json
import struct
import zipfile
from collections.abc import Mapping
from pathlib import Path
import numpy as np
import numpy.typing as npt
import pytest
from k1link.datasets import goose_qualification
from k1link.datasets.goose_qualification import (
DegradationProfile,
qualify_goose_ground,
)
from k1link.ground_segmentation import GroundSegmentation
from k1link.simulation import QualificationRunStore, RunState
class _NeverGround:
@property
def identity(self) -> Mapping[str, object]:
return {"provider_id": "test-current", "revision": "1"}
def segment(self, xyzi: npt.NDArray[np.float32]) -> GroundSegmentation:
count = xyzi.shape[0]
return GroundSegmentation(
ground_mask=np.zeros(count, dtype=np.bool_),
assigned_mask=np.ones(count, dtype=np.bool_),
latency_ms=2.0,
)
class _NegativeZGround:
@property
def identity(self) -> Mapping[str, object]:
return {"provider_id": "test-patchwork", "revision": "1"}
def segment(self, xyzi: npt.NDArray[np.float32]) -> GroundSegmentation:
count = xyzi.shape[0]
return GroundSegmentation(
ground_mask=np.asarray(xyzi[:, 2] < 0, dtype=np.bool_),
assigned_mask=np.ones(count, dtype=np.bool_),
latency_ms=3.0,
)
def _frame_bytes(offset: float) -> tuple[bytes, bytes]:
points = (
(-1.0 + offset, 0.0, -2.0, 0.1),
(0.0 + offset, 0.0, -2.0, 0.2),
(1.0 + offset, 0.0, 2.0, 0.3),
(2.0 + offset, 0.0, 2.0, 0.4),
)
labels = (1, 3, 2, 2)
return (
b"".join(struct.pack("<ffff", *point) for point in points),
b"".join(struct.pack("<I", label) for label in labels),
)
def _dataset(tmp_path: Path) -> Path:
root = tmp_path / "datasets"
archive_sha = "a" * 64
archive = root / "goose-3d/v2025-08-22/archives/goose_3d_val.zip"
archive.parent.mkdir(parents=True)
with zipfile.ZipFile(archive, "w") as target:
for index in range(2):
frame_id = f"2022-01-01_flight__0001_{index:019d}"
points, labels = _frame_bytes(index * 0.1)
target.writestr(f"goose/lidar/val/{frame_id}_vls128.bin", points)
target.writestr(f"goose/labels/val/{frame_id}_goose.label", labels)
state = root / "state/goose-3d-v2025-08-22.json"
state.parent.mkdir(parents=True)
state.write_text(json.dumps({"archive": {"sha256": archive_sha}}), encoding="utf-8")
install = root / "goose-3d/v2025-08-22/installs" / archive_sha
install.mkdir(parents=True)
(install / "goose_label_mapping.csv").write_text(
"class_name,label_key,hex\nasphalt,1,#111111\nrock,2,#222222\nsoil,3,#333333\n",
encoding="utf-8",
)
return root
def test_full_split_qualification_is_sealed_and_resumable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
root = _dataset(tmp_path)
runs_root = tmp_path / "runs"
monkeypatch.setattr(goose_qualification, "_is_worker_dataset_root", lambda _: True)
report = qualify_goose_ground(
root,
runs_root,
mission_core_commit="1" * 40,
parallel_workers=1,
expected_frame_count=2,
current_segmenter=_NeverGround(),
patchwork_segmenter=_NegativeZGround(),
degradations=(DegradationProfile("range-20m", "maximum-range-m", 20.0),),
)
assert report["frame_count"] == 2
assert report["decision"]["status"] == "shadow-candidate"
assert report["aggregates"]["current"]["micro"]["ground_iou"] == 0
assert report["aggregates"]["patchworkpp"]["micro"]["ground_iou"] == 1
assert len(report["frames"]) == 2
run = QualificationRunStore(runs_root, read_only=True).load(report["run_id"])
assert run.state is RunState.COMPLETED
assert {artifact.kind for artifact in run.artifacts} == {
"goose-ground-qualification-report",
"goose-ground-qualification-failure-preview",
}
resumed = qualify_goose_ground(
root,
runs_root,
mission_core_commit="1" * 40,
parallel_workers=1,
expected_frame_count=2,
current_segmenter=_NeverGround(),
patchwork_segmenter=_NegativeZGround(),
degradations=(DegradationProfile("range-20m", "maximum-range-m", 20.0),),
)
assert resumed["identity_sha256"] == report["identity_sha256"]

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
@ -191,6 +193,146 @@ def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
assert "целостности" in failure.value.detail
def _qualification_run(root: Path) -> tuple[QualificationRun, str]:
store = QualificationRunStore(root)
admitted = store.create(_run("goose-ground-test"))
starting = store.transition(
admitted.run_id,
RunState.STARTING,
expected_revision=0,
observed_at_utc="2026-07-24T15:35:01Z",
host_monotonic_ns=1,
)
store.transition(
admitted.run_id,
RunState.RUNNING,
expected_revision=starting.revision,
observed_at_utc="2026-07-24T15:35:02Z",
host_monotonic_ns=2,
)
evidence = root / admitted.run_id / "evidence"
failures = evidence / "failures"
failures.mkdir(parents=True)
frame_id = "2022-07-22_flight__0071_0001"
report = {
"schema_version": "missioncore.goose-ground-qualification-report/v1",
"identity_sha256": SHA_A,
"source_id": "goose-3d/v2025-08-22",
"split": "validation",
"frame_count": 961,
"aggregates": {"current": {}, "patchworkpp": {}},
"degradations": {},
"checks": [],
"worst_frames": [{"frame_id": frame_id}],
"decision": {"status": "shadow-candidate", "passed": True},
"safety": {"navigation_or_safety_accepted": False},
"frames": [{"private": "not-published"}],
}
preview = {
"schema_version": "missioncore.goose-ground-qualification-failure-preview/v1",
"source_id": "goose-3d/v2025-08-22",
"frame_id": frame_id,
"source_point_count": 2,
"point_count": 2,
"sampling": "deterministic-even-index",
"points_xyz_m": [[0, 0, 0], [1, 1, 1]],
"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},
}
for artifact_id, kind, path, value in (
(
"qualification-report",
"goose-ground-qualification-report",
evidence / "qualification.json",
report,
),
(
"failure-preview-01",
"goose-ground-qualification-failure-preview",
failures / f"{frame_id}.json",
preview,
),
):
path.write_text(json.dumps(value), encoding="utf-8")
store.register_artifact(
admitted.run_id,
QualificationArtifact(
artifact_id=artifact_id,
kind=kind,
relative_path=path.relative_to(root / admitted.run_id).as_posix(),
sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
byte_length=path.stat().st_size,
source_of_record=True,
),
)
current = store.load(admitted.run_id)
stopping = store.transition(
admitted.run_id,
RunState.STOPPING,
expected_revision=current.revision,
observed_at_utc="2026-07-24T15:35:04Z",
host_monotonic_ns=4,
)
completed = store.transition(
admitted.run_id,
RunState.COMPLETED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T15:35:05Z",
host_monotonic_ns=5,
reason="qualification-evidence-sealed",
)
return completed, frame_id
def test_polygon_api_publishes_verified_ground_qualification(
tmp_path: Path,
) -> None:
root = tmp_path / "runs"
run, frame_id = _qualification_run(root)
router = build_polygon_router(root_provider=lambda: root)
qualification = _endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification",
"GET",
)(run_id=run.run_id)
preview = _endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification/failures/{frame_id}",
"GET",
)(run_id=run.run_id, frame_id=frame_id)
assert qualification["schema_version"] == "missioncore.polygon-ground-qualification/v1"
assert qualification["frame_count"] == 961
assert "frames" not in qualification
assert preview["schema_version"] == "missioncore.polygon-ground-failure-preview/v1"
assert preview["frame_id"] == frame_id
assert str(tmp_path) not in repr({"qualification": qualification, "preview": preview})
def test_polygon_api_rejects_tampered_ground_qualification(
tmp_path: Path,
) -> None:
root = tmp_path / "runs"
run, _ = _qualification_run(root)
(root / run.run_id / "evidence/qualification.json").write_text("{}", encoding="utf-8")
router = build_polygon_router(root_provider=lambda: root)
with pytest.raises(HTTPException) as failure:
_endpoint(
router,
"/api/v1/polygon/runs/{run_id}/qualification",
"GET",
)(run_id=run.run_id)
assert failure.value.status_code == 500
assert "SHA-256" in failure.value.detail or "файла" in failure.value.detail
class _FakeWorkerGateway:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
@ -286,9 +428,7 @@ class _FakeWorkerGateway:
"control_available": True,
"active_run_id": self.active_run_id,
"run_state": "running" if self.active_run_id else None,
"active_provider_ids": (
["px4-gazebo-stock-rover"] if self.active_run_id else []
),
"active_provider_ids": (["px4-gazebo-stock-rover"] if self.active_run_id else []),
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
"isolation": {
"network": "loopback-only-netns",