feat(polygon): qualify GOOSE ground providers
This commit is contained in:
parent
60ba64004b
commit
d6decbe05c
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1040px) {
|
@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 {
|
.feature-group {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
|
@ -174,6 +183,19 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@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 {
|
.dataset-purpose ol {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -775,6 +775,246 @@
|
||||||
line-height: 1.5;
|
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 {
|
.capability-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -275,10 +275,11 @@ export function DatasetGatewayWorkspace() {
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
disabled
|
variant="secondary"
|
||||||
title="Прогон станет доступен после импорта датасета"
|
title="Открыть read-only архив квалификационных прогонов"
|
||||||
|
onClick={() => window.location.assign("/?workspace=polygon-run")}
|
||||||
>
|
>
|
||||||
Создать прогон
|
Открыть прогоны
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchGroundFailurePreview,
|
||||||
|
fetchGroundQualification,
|
||||||
|
type GroundFailurePreview,
|
||||||
|
type GroundQualification,
|
||||||
|
} from "../core/polygon/groundQualification";
|
||||||
import {
|
import {
|
||||||
fetchPolygonRunCatalog,
|
fetchPolygonRunCatalog,
|
||||||
fetchPolygonRunDetail,
|
fetchPolygonRunDetail,
|
||||||
|
|
@ -13,6 +19,10 @@ import {
|
||||||
type PolygonRunRoute,
|
type PolygonRunRoute,
|
||||||
type PolygonRunState,
|
type PolygonRunState,
|
||||||
} from "../core/polygon/runArchive";
|
} from "../core/polygon/runArchive";
|
||||||
|
import {
|
||||||
|
LidarGroundPointCloud,
|
||||||
|
type LidarGroundViewMode,
|
||||||
|
} from "./LidarGroundPointCloud";
|
||||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
import { PolygonLivePanel } from "./PolygonLivePanel";
|
||||||
|
|
||||||
interface PolygonRunWorkspaceProps {
|
interface PolygonRunWorkspaceProps {
|
||||||
|
|
@ -62,6 +72,17 @@ function errorMessage(error: unknown): string {
|
||||||
return "Не удалось прочитать доказательства прогона.";
|
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) {
|
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);
|
||||||
|
|
@ -69,6 +90,12 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||||
const [loading, setLoading] = useState(route.error === null);
|
const [loading, setLoading] = useState(route.error === null);
|
||||||
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 [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(() => {
|
useEffect(() => {
|
||||||
if (route.error) {
|
if (route.error) {
|
||||||
|
|
@ -106,10 +133,71 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
|
}, [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(
|
const visibleEvents = useMemo(
|
||||||
() => detail ? [...detail.events].reverse() : [],
|
() => detail ? [...detail.events].reverse() : [],
|
||||||
[detail],
|
[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) {
|
if (loading && !detail) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -199,6 +287,133 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="polygon-run-layout">
|
||||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
<GlassSurface className="polygon-run-catalog" padding="lg">
|
||||||
<header className="polygon-run-panel-heading">
|
<header className="polygon-run-panel-heading">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,7 @@ from k1link.datasets.gateway import (
|
||||||
configured_dataset_ground_preview,
|
configured_dataset_ground_preview,
|
||||||
configured_dataset_preview,
|
configured_dataset_preview,
|
||||||
dataset_gateway_catalog,
|
dataset_gateway_catalog,
|
||||||
|
decode_semantic_kitti_frame,
|
||||||
read_dataset_admission_manifest,
|
read_dataset_admission_manifest,
|
||||||
read_dataset_ground_preview,
|
read_dataset_ground_preview,
|
||||||
read_dataset_native_scan_preview,
|
read_dataset_native_scan_preview,
|
||||||
|
|
@ -27,6 +28,16 @@ from k1link.datasets.goose_profile import (
|
||||||
GOOSE_PATCHWORK_PROFILE_SCHEMA,
|
GOOSE_PATCHWORK_PROFILE_SCHEMA,
|
||||||
GoosePatchworkProfile,
|
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__ = [
|
__all__ = [
|
||||||
"DATASET_GATEWAY_CATALOG_SCHEMA",
|
"DATASET_GATEWAY_CATALOG_SCHEMA",
|
||||||
|
|
@ -39,15 +50,24 @@ __all__ = [
|
||||||
"GOOSE_GROUND_BENCHMARK_SCHEMA",
|
"GOOSE_GROUND_BENCHMARK_SCHEMA",
|
||||||
"GOOSE_GROUND_PREVIEW_SCHEMA",
|
"GOOSE_GROUND_PREVIEW_SCHEMA",
|
||||||
"GOOSE_PATCHWORK_PROFILE_SCHEMA",
|
"GOOSE_PATCHWORK_PROFILE_SCHEMA",
|
||||||
|
"GOOSE_QUALIFICATION_FRAME_SCHEMA",
|
||||||
|
"GOOSE_QUALIFICATION_PREVIEW_SCHEMA",
|
||||||
|
"GOOSE_QUALIFICATION_PROFILE_SCHEMA",
|
||||||
|
"GOOSE_QUALIFICATION_REPORT_SCHEMA",
|
||||||
"GoosePatchworkProfile",
|
"GoosePatchworkProfile",
|
||||||
|
"GroundAcceptancePolicy",
|
||||||
|
"DegradationProfile",
|
||||||
|
"DEFAULT_DEGRADATIONS",
|
||||||
"benchmark_goose_current_ground",
|
"benchmark_goose_current_ground",
|
||||||
"benchmark_goose_patchwork_ground",
|
"benchmark_goose_patchwork_ground",
|
||||||
"configured_dataset_admission_manifest",
|
"configured_dataset_admission_manifest",
|
||||||
"configured_dataset_ground_preview",
|
"configured_dataset_ground_preview",
|
||||||
"configured_dataset_preview",
|
"configured_dataset_preview",
|
||||||
"dataset_gateway_catalog",
|
"dataset_gateway_catalog",
|
||||||
|
"decode_semantic_kitti_frame",
|
||||||
"read_dataset_admission_manifest",
|
"read_dataset_admission_manifest",
|
||||||
"read_dataset_ground_preview",
|
"read_dataset_ground_preview",
|
||||||
"read_dataset_native_scan_preview",
|
"read_dataset_native_scan_preview",
|
||||||
"read_semantic_kitti_frame",
|
"read_semantic_kitti_frame",
|
||||||
|
"qualify_goose_ground",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from k1link.datasets.goose_benchmark import (
|
||||||
benchmark_goose_current_ground,
|
benchmark_goose_current_ground,
|
||||||
benchmark_goose_patchwork_ground,
|
benchmark_goose_patchwork_ground,
|
||||||
)
|
)
|
||||||
|
from k1link.datasets.goose_qualification import qualify_goose_ground
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
add_completion=False,
|
add_completion=False,
|
||||||
|
|
@ -83,5 +84,39 @@ def benchmark_goose_patchwork_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("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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|
|
||||||
|
|
@ -67,27 +67,45 @@ def read_semantic_kitti_frame(
|
||||||
) -> DatasetPointFrame:
|
) -> DatasetPointFrame:
|
||||||
"""Read one GOOSE/SemanticKITTI XYZI + packed-label frame losslessly."""
|
"""Read one GOOSE/SemanticKITTI XYZI + packed-label frame losslessly."""
|
||||||
|
|
||||||
if maximum_points <= 0:
|
|
||||||
raise DatasetFrameError("maximum_points must be positive")
|
|
||||||
try:
|
try:
|
||||||
point_size = point_path.stat().st_size
|
point_size = point_path.stat().st_size
|
||||||
label_size = label_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:
|
except OSError as exc:
|
||||||
raise DatasetFrameError("dataset point or label file is unavailable") from exc
|
raise DatasetFrameError("dataset point or label file is unavailable") from exc
|
||||||
if point_size == 0 or point_size % 16:
|
return decode_semantic_kitti_frame(
|
||||||
raise DatasetFrameError("point frame must contain little-endian float32 XYZI tuples")
|
point_bytes,
|
||||||
if label_size % 4:
|
label_bytes,
|
||||||
raise DatasetFrameError("label frame must contain packed little-endian uint32 values")
|
maximum_points=maximum_points,
|
||||||
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:
|
def decode_semantic_kitti_frame(
|
||||||
raise DatasetFrameError("point and label counts do not match")
|
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:
|
try:
|
||||||
xyzi = np.fromfile(point_path, dtype="<f4").reshape((-1, 4))
|
xyzi = np.frombuffer(point_bytes, dtype="<f4").reshape((-1, 4))
|
||||||
packed_labels = np.fromfile(label_path, dtype="<u4")
|
packed_labels = np.frombuffer(label_bytes, dtype="<u4")
|
||||||
except (OSError, ValueError) as exc:
|
except ValueError as exc:
|
||||||
raise DatasetFrameError("dataset frame cannot be decoded") from exc
|
raise DatasetFrameError("dataset frame cannot be decoded") from exc
|
||||||
if not np.isfinite(xyzi).all():
|
if not np.isfinite(xyzi).all():
|
||||||
raise DatasetFrameError("dataset point frame contains non-finite values")
|
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)
|
instance = np.ascontiguousarray(packed_labels >> np.uint32(16), dtype=np.uint16)
|
||||||
for values in (points, remission, semantic, instance):
|
for values in (points, remission, semantic, instance):
|
||||||
values.setflags(write=False)
|
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)
|
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:
|
def _configured_dataset_root() -> Path | None:
|
||||||
raw = os.environ.get(DATASET_ROOT_ENV, "").strip()
|
raw = os.environ.get(DATASET_ROOT_ENV, "").strip()
|
||||||
return Path(raw).expanduser().absolute() if raw else None
|
return Path(raw).expanduser().absolute() if raw else None
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -44,7 +44,7 @@ from k1link.web.plugin_runtime import (
|
||||||
PluginNotFoundError,
|
PluginNotFoundError,
|
||||||
PluginRuntimeUnavailableError,
|
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
|
from k1link.web.session_api import build_session_router
|
||||||
|
|
||||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
@ -398,7 +398,12 @@ app.include_router(
|
||||||
point_color_renderers=plugin_environment.point_color_renderers,
|
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(
|
app.include_router(
|
||||||
build_lidar_router(
|
build_lidar_router(
|
||||||
root_provider=lambda: (
|
root_provider=lambda: (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -33,6 +35,13 @@ POLYGON_WORKER_CONTROL_ENV: Final = "MISSIONCORE_POLYGON_WORKER_CONTROL"
|
||||||
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
MISSION_CORE_COMMIT_ENV: Final = "MISSIONCORE_COMMIT"
|
||||||
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
CATALOG_SCHEMA: Final = "missioncore.polygon-run-catalog/v1"
|
||||||
DETAIL_SCHEMA: Final = "missioncore.polygon-run-detail/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}$")
|
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 публикует только квалификационные доказательства; "
|
||||||
|
|
@ -165,6 +174,90 @@ def build_polygon_router(
|
||||||
"limitations": list(READ_ONLY_LIMITATIONS),
|
"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")
|
@router.get("/worker")
|
||||||
def get_polygon_worker() -> dict[str, Any]:
|
def get_polygon_worker() -> dict[str, Any]:
|
||||||
gateway = worker_provider()
|
gateway = worker_provider()
|
||||||
|
|
@ -315,6 +408,83 @@ def _open_read_only_store(root_provider: RootProvider) -> QualificationRunStore:
|
||||||
) from exc
|
) 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]:
|
def _run_summary(run: QualificationRun) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"run_id": run.run_id,
|
"run_id": run.run_id,
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -191,6 +193,146 @@ def test_polygon_api_rejects_corrupt_evidence_without_partial_response(
|
||||||
assert "целостности" in failure.value.detail
|
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:
|
class _FakeWorkerGateway:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[tuple[str, str]] = []
|
self.calls: list[tuple[str, str]] = []
|
||||||
|
|
@ -286,9 +428,7 @@ class _FakeWorkerGateway:
|
||||||
"control_available": True,
|
"control_available": True,
|
||||||
"active_run_id": self.active_run_id,
|
"active_run_id": self.active_run_id,
|
||||||
"run_state": "running" if self.active_run_id else None,
|
"run_state": "running" if self.active_run_id else None,
|
||||||
"active_provider_ids": (
|
"active_provider_ids": (["px4-gazebo-stock-rover"] if self.active_run_id else []),
|
||||||
["px4-gazebo-stock-rover"] if self.active_run_id else []
|
|
||||||
),
|
|
||||||
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
"provider_profile": STOCK_ROVER_PROVIDER_PROFILE.to_dict(),
|
||||||
"isolation": {
|
"isolation": {
|
||||||
"network": "loopback-only-netns",
|
"network": "loopback-only-netns",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue