feat(lab): add lazy E40 evidence review
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
import {
|
||||||
|
AdvancedLaboratoryContractError,
|
||||||
|
fetchOne,
|
||||||
|
parseE31,
|
||||||
|
parseE32,
|
||||||
|
parseE33,
|
||||||
|
parseE37,
|
||||||
|
parseE38,
|
||||||
|
parseE39,
|
||||||
|
type AdvancedLaboratoryResults,
|
||||||
|
type LaboratoryFetch,
|
||||||
|
} from "./advancedResults";
|
||||||
|
import { fetchE34TemporalLayerResult } from "./e34TemporalLayer";
|
||||||
|
import { fetchE35DegradationRecoveryResult } from "./e35DegradationRecovery";
|
||||||
|
import { fetchE40ProductGateResult } from "./e40ProductGate";
|
||||||
|
|
||||||
|
export type AdvancedLaboratoryWorkId =
|
||||||
|
| "e31-source-binding"
|
||||||
|
| "e32-track-geometry"
|
||||||
|
| "e33-worker-shadow"
|
||||||
|
| "e34-temporal-layer"
|
||||||
|
| "e35-degradation-recovery"
|
||||||
|
| "e37-ravnoves-acceptance"
|
||||||
|
| "e38-perception-baseline"
|
||||||
|
| "e39-perception-refinement"
|
||||||
|
| "e40-perception-product-gate";
|
||||||
|
|
||||||
|
export interface AdvancedLaboratoryIndexItem {
|
||||||
|
workId: AdvancedLaboratoryWorkId;
|
||||||
|
resultId: string;
|
||||||
|
createdAtUtc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORK_IDS: readonly AdvancedLaboratoryWorkId[] = [
|
||||||
|
"e31-source-binding",
|
||||||
|
"e32-track-geometry",
|
||||||
|
"e33-worker-shadow",
|
||||||
|
"e34-temporal-layer",
|
||||||
|
"e35-degradation-recovery",
|
||||||
|
"e37-ravnoves-acceptance",
|
||||||
|
"e38-perception-baseline",
|
||||||
|
"e39-perception-refinement",
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESULT_PREFIX: Readonly<Record<AdvancedLaboratoryWorkId, string>> = {
|
||||||
|
"e31-source-binding": "e31-source-qualification",
|
||||||
|
"e32-track-geometry": "e32-track-geometry",
|
||||||
|
"e33-worker-shadow": "e33-worker-shadow",
|
||||||
|
"e34-temporal-layer": "e34-temporal-occupied",
|
||||||
|
"e35-degradation-recovery": "e35-degradation-recovery",
|
||||||
|
"e37-ravnoves-acceptance": "e37-ravnoves-acceptance",
|
||||||
|
"e38-perception-baseline": "e38-perception-baseline",
|
||||||
|
"e39-perception-refinement": "e39-perception-refinement",
|
||||||
|
"e40-perception-product-gate": "e40-perception-product-gate",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAdvancedLaboratoryWorkId(
|
||||||
|
value: string,
|
||||||
|
): value is AdvancedLaboratoryWorkId {
|
||||||
|
return WORK_IDS.includes(value as AdvancedLaboratoryWorkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyAdvancedLaboratoryResults(): AdvancedLaboratoryResults {
|
||||||
|
return {
|
||||||
|
e31: null,
|
||||||
|
e32: null,
|
||||||
|
e33: null,
|
||||||
|
e34: null,
|
||||||
|
e35: null,
|
||||||
|
e37: null,
|
||||||
|
e38: null,
|
||||||
|
e39: null,
|
||||||
|
e40: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new AdvancedLaboratoryContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactString(value: unknown, expected: string, label: string): string {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new AdvancedLaboratoryContractError(`${label}: нарушен контракт.`);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIndexItem(value: unknown): AdvancedLaboratoryIndexItem {
|
||||||
|
const item = objectValue(value, "Индекс LAB.item");
|
||||||
|
if (
|
||||||
|
typeof item.work_id !== "string"
|
||||||
|
|| !isAdvancedLaboratoryWorkId(item.work_id)
|
||||||
|
) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Индекс LAB.work_id: неизвестная лабораторная работа.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const prefix = RESULT_PREFIX[item.work_id];
|
||||||
|
if (
|
||||||
|
typeof item.result_id !== "string"
|
||||||
|
|| !new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(item.result_id)
|
||||||
|
) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Индекс LAB.result_id: нарушена идентичность результата.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof item.created_at_utc !== "string" || !item.created_at_utc.trim()) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Индекс LAB.created_at_utc: ожидалась строка.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
exactString(item.access, "read-only", "Индекс LAB.item.access");
|
||||||
|
return {
|
||||||
|
workId: item.work_id,
|
||||||
|
resultId: item.result_id,
|
||||||
|
createdAtUtc: item.created_at_utc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdvancedLaboratoryIndex({
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {}): Promise<readonly AdvancedLaboratoryIndexItem[]> {
|
||||||
|
const response = await fetcher("/api/v1/laboratory/advanced-index", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
`Индекс LAB недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "Индекс LAB");
|
||||||
|
exactString(
|
||||||
|
payload.schema_version,
|
||||||
|
"missioncore.laboratory-advanced-index/v1",
|
||||||
|
"Индекс LAB.schema_version",
|
||||||
|
);
|
||||||
|
exactString(payload.access, "read-only", "Индекс LAB.access");
|
||||||
|
if (!Array.isArray(payload.items) || payload.items.length > WORK_IDS.length) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Индекс LAB.items: нарушен размер каталога.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = payload.items.map(parseIndexItem);
|
||||||
|
if (new Set(items.map((item) => item.workId)).size !== items.length) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Индекс LAB.items: лабораторная работа продублирована.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advancedLaboratoryResultAvailable(
|
||||||
|
workId: AdvancedLaboratoryWorkId,
|
||||||
|
results: AdvancedLaboratoryResults,
|
||||||
|
): boolean {
|
||||||
|
return workId === "e31-source-binding" ? results.e31 !== null
|
||||||
|
: workId === "e32-track-geometry" ? results.e32 !== null
|
||||||
|
: workId === "e33-worker-shadow" ? results.e33 !== null
|
||||||
|
: workId === "e34-temporal-layer" ? results.e34 !== null
|
||||||
|
: workId === "e35-degradation-recovery" ? results.e35 !== null
|
||||||
|
: workId === "e37-ravnoves-acceptance" ? results.e37 !== null
|
||||||
|
: workId === "e38-perception-baseline" ? results.e38 !== null
|
||||||
|
: workId === "e39-perception-refinement" ? results.e39 !== null
|
||||||
|
: results.e40 !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdvancedLaboratoryResult(
|
||||||
|
workId: AdvancedLaboratoryWorkId,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {},
|
||||||
|
): Promise<AdvancedLaboratoryResults> {
|
||||||
|
const results = emptyAdvancedLaboratoryResults();
|
||||||
|
if (workId === "e31-source-binding") {
|
||||||
|
results.e31 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e31/results?limit=1",
|
||||||
|
parseE31,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else if (workId === "e32-track-geometry") {
|
||||||
|
results.e32 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e32/results?limit=1",
|
||||||
|
parseE32,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else if (workId === "e33-worker-shadow") {
|
||||||
|
results.e33 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e33/results?limit=1",
|
||||||
|
parseE33,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else if (workId === "e34-temporal-layer") {
|
||||||
|
results.e34 = await fetchE34TemporalLayerResult({ fetcher, signal });
|
||||||
|
} else if (workId === "e35-degradation-recovery") {
|
||||||
|
results.e35 = await fetchE35DegradationRecoveryResult({ fetcher, signal });
|
||||||
|
} else if (workId === "e37-ravnoves-acceptance") {
|
||||||
|
results.e37 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e37/results?limit=1",
|
||||||
|
parseE37,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else if (workId === "e38-perception-baseline") {
|
||||||
|
results.e38 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e38/results?limit=1",
|
||||||
|
parseE38,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else if (workId === "e39-perception-refinement") {
|
||||||
|
results.e39 = await fetchOne(
|
||||||
|
"/api/v1/laboratory/e39/results?limit=1",
|
||||||
|
parseE39,
|
||||||
|
fetcher,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
results.e40 = await fetchE40ProductGateResult({ fetcher, signal });
|
||||||
|
}
|
||||||
|
if (!advancedLaboratoryResultAvailable(workId, results)) {
|
||||||
|
throw new AdvancedLaboratoryContractError(
|
||||||
|
"Выбранная лабораторная работа не прошла серверную проверку.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -256,7 +256,7 @@ export class AdvancedLaboratoryContractError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LaboratoryFetch = (
|
export type LaboratoryFetch = (
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
) => Promise<Response>;
|
) => Promise<Response>;
|
||||||
@@ -402,7 +402,7 @@ function parseCatalog<T>(
|
|||||||
return value.items.length ? parseItem(value.items[0]) : null;
|
return value.items.length ? parseItem(value.items[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE31(value: unknown): E31LaboratoryResult {
|
export function parseE31(value: unknown): E31LaboratoryResult {
|
||||||
const item = record(value, "E31");
|
const item = record(value, "E31");
|
||||||
const metrics = record(item.metrics, "E31.metrics");
|
const metrics = record(item.metrics, "E31.metrics");
|
||||||
diagnosticAuthority(item.authority, "E31.authority");
|
diagnosticAuthority(item.authority, "E31.authority");
|
||||||
@@ -437,7 +437,7 @@ function parseE31(value: unknown): E31LaboratoryResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE32(value: unknown): E32LaboratoryResult {
|
export function parseE32(value: unknown): E32LaboratoryResult {
|
||||||
const item = record(value, "E32");
|
const item = record(value, "E32");
|
||||||
const metrics = record(item.metrics, "E32.metrics");
|
const metrics = record(item.metrics, "E32.metrics");
|
||||||
diagnosticAuthority(item.authority, "E32.authority");
|
diagnosticAuthority(item.authority, "E32.authority");
|
||||||
@@ -470,7 +470,7 @@ function parseE32(value: unknown): E32LaboratoryResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE33(value: unknown): E33LaboratoryResult {
|
export function parseE33(value: unknown): E33LaboratoryResult {
|
||||||
const item = record(value, "E33");
|
const item = record(value, "E33");
|
||||||
const worker = record(item.worker, "E33.worker");
|
const worker = record(item.worker, "E33.worker");
|
||||||
const metrics = record(item.metrics, "E33.metrics");
|
const metrics = record(item.metrics, "E33.metrics");
|
||||||
@@ -521,7 +521,7 @@ function parseE33(value: unknown): E33LaboratoryResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE37(value: unknown): E37AcceptanceContractResult {
|
export function parseE37(value: unknown): E37AcceptanceContractResult {
|
||||||
const item = record(value, "E37");
|
const item = record(value, "E37");
|
||||||
const metrics = record(item.metrics, "E37.metrics");
|
const metrics = record(item.metrics, "E37.metrics");
|
||||||
const distributions = record(
|
const distributions = record(
|
||||||
@@ -718,7 +718,7 @@ function parseE38Dimension(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE38(value: unknown): E38PerceptionBaselineResult {
|
export function parseE38(value: unknown): E38PerceptionBaselineResult {
|
||||||
const item = record(value, "E38");
|
const item = record(value, "E38");
|
||||||
const metrics = record(item.metrics, "E38.metrics");
|
const metrics = record(item.metrics, "E38.metrics");
|
||||||
const dimensions = record(metrics.dimensions, "E38.metrics.dimensions");
|
const dimensions = record(metrics.dimensions, "E38.metrics.dimensions");
|
||||||
@@ -810,7 +810,7 @@ function parseDevelopmentDimension(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseE39(value: unknown): E39PerceptionRefinementResult {
|
export function parseE39(value: unknown): E39PerceptionRefinementResult {
|
||||||
const item = record(value, "E39");
|
const item = record(value, "E39");
|
||||||
const metrics = record(item.metrics, "E39.metrics");
|
const metrics = record(item.metrics, "E39.metrics");
|
||||||
const dimensions = record(metrics.dimensions, "E39.metrics.dimensions");
|
const dimensions = record(metrics.dimensions, "E39.metrics.dimensions");
|
||||||
@@ -944,7 +944,7 @@ function parseE39(value: unknown): E39PerceptionRefinementResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOne<T>(
|
export async function fetchOne<T>(
|
||||||
path: string,
|
path: string,
|
||||||
parser: (value: unknown) => T,
|
parser: (value: unknown) => T,
|
||||||
fetcher: LaboratoryFetch,
|
fetcher: LaboratoryFetch,
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface E30ReviewItem {
|
|||||||
materialization: {
|
materialization: {
|
||||||
framePointCount: number;
|
framePointCount: number;
|
||||||
projectedPointCount: number;
|
projectedPointCount: number;
|
||||||
|
projectionWidth: number;
|
||||||
|
projectionHeight: number;
|
||||||
candidatePointCount: number;
|
candidatePointCount: number;
|
||||||
selectedPointCount: number;
|
selectedPointCount: number;
|
||||||
rejectedCandidatePointCount: number;
|
rejectedCandidatePointCount: number;
|
||||||
@@ -428,6 +430,14 @@ function parseItem(value: unknown): E30ReviewItem {
|
|||||||
materialized.projected_point_count,
|
materialized.projected_point_count,
|
||||||
"E30 materialization.projected_point_count",
|
"E30 materialization.projected_point_count",
|
||||||
),
|
),
|
||||||
|
projectionWidth: integerValue(
|
||||||
|
materialized.projection_width,
|
||||||
|
"E30 materialization.projection_width",
|
||||||
|
),
|
||||||
|
projectionHeight: integerValue(
|
||||||
|
materialized.projection_height,
|
||||||
|
"E30 materialization.projection_height",
|
||||||
|
),
|
||||||
candidatePointCount: integerValue(
|
candidatePointCount: integerValue(
|
||||||
materialized.candidate_point_count,
|
materialized.candidate_point_count,
|
||||||
"E30 materialization.candidate_point_count",
|
"E30 materialization.candidate_point_count",
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
export const E40_CASE_DIMENSIONS = [
|
||||||
|
"presence",
|
||||||
|
"geometry_association",
|
||||||
|
"freshness",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type E40CaseDimension = (typeof E40_CASE_DIMENSIONS)[number];
|
||||||
|
export type E40CaseSeverity = "high" | "medium" | "standard";
|
||||||
|
export type E40CaseStratum =
|
||||||
|
| "agree"
|
||||||
|
| "camera-only"
|
||||||
|
| "conflict"
|
||||||
|
| "geometry-only"
|
||||||
|
| "unknown";
|
||||||
|
export type E40PredictionBasis =
|
||||||
|
| "camera-only-softmax"
|
||||||
|
| "fixed-stratum-policy";
|
||||||
|
|
||||||
|
export interface E40CaseState {
|
||||||
|
presence: "background-or-noise" | "object-present" | "occupied-environment";
|
||||||
|
geometryAssociation:
|
||||||
|
| "independent-occupied"
|
||||||
|
| "insufficient-support"
|
||||||
|
| "object-associated"
|
||||||
|
| "rejected-nonobject"
|
||||||
|
| "unknown";
|
||||||
|
freshness: "current" | "stale" | "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E40ErrorCase {
|
||||||
|
itemId: string;
|
||||||
|
sequence: number;
|
||||||
|
sourceFrameIndex: number;
|
||||||
|
sourceStratum: E40CaseStratum;
|
||||||
|
severity: E40CaseSeverity;
|
||||||
|
presenceConfidence: number;
|
||||||
|
predictionBasis: E40PredictionBasis;
|
||||||
|
reference: E40CaseState;
|
||||||
|
prediction: E40CaseState;
|
||||||
|
mismatchedDimensions: readonly E40CaseDimension[];
|
||||||
|
access: "read-only";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E40CaseCatalog {
|
||||||
|
resultId: string;
|
||||||
|
materializationId: string;
|
||||||
|
items: readonly E40ErrorCase[];
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
access: "read-only";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type E40OperatorVerdict = "confirmed-error" | "rejected-error";
|
||||||
|
|
||||||
|
export interface E40OperatorDecision {
|
||||||
|
itemId: string;
|
||||||
|
verdict: E40OperatorVerdict;
|
||||||
|
revision: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
decidedAtUtc: string;
|
||||||
|
eventId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E40OperatorReview {
|
||||||
|
reviewId: string;
|
||||||
|
resultId: string;
|
||||||
|
materializationId: string;
|
||||||
|
caseCatalogSha256: string;
|
||||||
|
itemSetSha256: string;
|
||||||
|
itemCount: number;
|
||||||
|
reviewerId: string;
|
||||||
|
revision: number;
|
||||||
|
decisions: readonly E40OperatorDecision[];
|
||||||
|
reviewedItemCount: number;
|
||||||
|
remainingItemCount: number;
|
||||||
|
updatedAtUtc: string | null;
|
||||||
|
access: "review-write";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class E40CaseReviewContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "E40CaseReviewContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LaboratoryFetch = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: ожидалась строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentId(value: unknown, prefix: string, label: string): string {
|
||||||
|
const parsed = stringValue(value, label);
|
||||||
|
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: неверный content id.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Value(value: unknown, label: string): string {
|
||||||
|
const parsed = stringValue(value, label);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(parsed)) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: неверный SHA-256.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerValue(value: unknown, label: string): number {
|
||||||
|
if (
|
||||||
|
typeof value !== "number"
|
||||||
|
|| !Number.isSafeInteger(value)
|
||||||
|
|| value < 0
|
||||||
|
) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: ожидалось целое число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown, label: string): string | null {
|
||||||
|
if (value === null) return null;
|
||||||
|
return stringValue(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probability(value: unknown, label: string): number {
|
||||||
|
if (
|
||||||
|
typeof value !== "number"
|
||||||
|
|| !Number.isFinite(value)
|
||||||
|
|| value < 0
|
||||||
|
|| value > 1
|
||||||
|
) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: ожидалась вероятность.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactString<T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
expected: T,
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
if (value !== expected) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: неверное значение.`);
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumValue<T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
allowed: readonly T[],
|
||||||
|
label: string,
|
||||||
|
): T {
|
||||||
|
if (typeof value !== "string" || !allowed.includes(value as T)) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: значение не поддерживается.`);
|
||||||
|
}
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseState(value: unknown, label: string): E40CaseState {
|
||||||
|
const source = record(value, label);
|
||||||
|
return {
|
||||||
|
presence: enumValue(
|
||||||
|
source.presence,
|
||||||
|
[
|
||||||
|
"background-or-noise",
|
||||||
|
"object-present",
|
||||||
|
"occupied-environment",
|
||||||
|
],
|
||||||
|
`${label}.presence`,
|
||||||
|
),
|
||||||
|
geometryAssociation: enumValue(
|
||||||
|
source.geometry_association,
|
||||||
|
[
|
||||||
|
"independent-occupied",
|
||||||
|
"insufficient-support",
|
||||||
|
"object-associated",
|
||||||
|
"rejected-nonobject",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
`${label}.geometry_association`,
|
||||||
|
),
|
||||||
|
freshness: enumValue(
|
||||||
|
source.freshness,
|
||||||
|
["current", "stale", "unavailable"],
|
||||||
|
`${label}.freshness`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCase(value: unknown, index: number): E40ErrorCase {
|
||||||
|
const label = `E40 case[${index}]`;
|
||||||
|
const source = record(value, label);
|
||||||
|
if (!Array.isArray(source.mismatched_dimensions)) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
`${label}.mismatched_dimensions: ожидался массив.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mismatchedDimensions = source.mismatched_dimensions.map(
|
||||||
|
(dimension, dimensionIndex) => enumValue(
|
||||||
|
dimension,
|
||||||
|
E40_CASE_DIMENSIONS,
|
||||||
|
`${label}.mismatched_dimensions[${dimensionIndex}]`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mismatchedDimensions.length) {
|
||||||
|
throw new E40CaseReviewContractError(`${label}: кейс не содержит ошибки.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||||
|
sequence: integerValue(source.sequence, `${label}.sequence`),
|
||||||
|
sourceFrameIndex: integerValue(
|
||||||
|
source.source_frame_index,
|
||||||
|
`${label}.source_frame_index`,
|
||||||
|
),
|
||||||
|
sourceStratum: enumValue(
|
||||||
|
source.source_stratum,
|
||||||
|
["agree", "camera-only", "conflict", "geometry-only", "unknown"],
|
||||||
|
`${label}.source_stratum`,
|
||||||
|
),
|
||||||
|
severity: enumValue(
|
||||||
|
source.severity,
|
||||||
|
["high", "medium", "standard"],
|
||||||
|
`${label}.severity`,
|
||||||
|
),
|
||||||
|
presenceConfidence: probability(
|
||||||
|
source.presence_confidence,
|
||||||
|
`${label}.presence_confidence`,
|
||||||
|
),
|
||||||
|
predictionBasis: enumValue(
|
||||||
|
source.prediction_basis,
|
||||||
|
["camera-only-softmax", "fixed-stratum-policy"],
|
||||||
|
`${label}.prediction_basis`,
|
||||||
|
),
|
||||||
|
reference: parseState(source.reference, `${label}.reference`),
|
||||||
|
prediction: parseState(source.prediction, `${label}.prediction`),
|
||||||
|
mismatchedDimensions,
|
||||||
|
access: exactString(source.access, "read-only", `${label}.access`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseE40CaseCatalog(payload: unknown): E40CaseCatalog {
|
||||||
|
const source = record(payload, "E40 case catalog");
|
||||||
|
exactString(
|
||||||
|
source.schema_version,
|
||||||
|
"missioncore.laboratory-e40-case-catalog/v1",
|
||||||
|
"E40 case catalog.schema_version",
|
||||||
|
);
|
||||||
|
if (!Array.isArray(source.items) || source.items.length > 64) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"E40 case catalog.items: нарушена граница.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: contentId(
|
||||||
|
source.result_id,
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
"E40 case catalog.result_id",
|
||||||
|
),
|
||||||
|
materializationId: contentId(
|
||||||
|
source.materialization_id,
|
||||||
|
"e30-materialization",
|
||||||
|
"E40 case catalog.materialization_id",
|
||||||
|
),
|
||||||
|
items: source.items.map(parseCase),
|
||||||
|
total: integerValue(source.total, "E40 case catalog.total"),
|
||||||
|
truncated: typeof source.truncated === "boolean"
|
||||||
|
? source.truncated
|
||||||
|
: (() => {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"E40 case catalog.truncated: ожидался boolean.",
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
access: exactString(
|
||||||
|
source.access,
|
||||||
|
"read-only",
|
||||||
|
"E40 case catalog.access",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOperatorDecision(
|
||||||
|
value: unknown,
|
||||||
|
index: number,
|
||||||
|
): E40OperatorDecision {
|
||||||
|
const label = `E40 operator decision[${index}]`;
|
||||||
|
const source = record(value, label);
|
||||||
|
return {
|
||||||
|
itemId: contentId(source.item_id, "e30-review-item", `${label}.item_id`),
|
||||||
|
verdict: enumValue(
|
||||||
|
source.verdict,
|
||||||
|
["confirmed-error", "rejected-error"],
|
||||||
|
`${label}.verdict`,
|
||||||
|
),
|
||||||
|
revision: integerValue(source.revision, `${label}.revision`),
|
||||||
|
idempotencyKey: stringValue(
|
||||||
|
source.idempotency_key,
|
||||||
|
`${label}.idempotency_key`,
|
||||||
|
),
|
||||||
|
decidedAtUtc: stringValue(
|
||||||
|
source.decided_at_utc,
|
||||||
|
`${label}.decided_at_utc`,
|
||||||
|
),
|
||||||
|
eventId: contentId(
|
||||||
|
source.event_id,
|
||||||
|
"e40-operator-verdict",
|
||||||
|
`${label}.event_id`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseE40OperatorReview(
|
||||||
|
payload: unknown,
|
||||||
|
): E40OperatorReview {
|
||||||
|
const source = record(payload, "E40 operator review");
|
||||||
|
exactString(
|
||||||
|
source.schema_version,
|
||||||
|
"missioncore.e40-operator-review/v1",
|
||||||
|
"E40 operator review.schema_version",
|
||||||
|
);
|
||||||
|
exactString(
|
||||||
|
source.protocol,
|
||||||
|
"sealed-error-adjudication/v1",
|
||||||
|
"E40 operator review.protocol",
|
||||||
|
);
|
||||||
|
const binding = record(source.source, "E40 operator review.source");
|
||||||
|
if (!Array.isArray(source.decisions) || source.decisions.length > 64) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"E40 operator review.decisions: нарушена граница.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decisions = source.decisions.map(parseOperatorDecision);
|
||||||
|
const reviewedItemCount = integerValue(
|
||||||
|
source.reviewed_item_count,
|
||||||
|
"E40 operator review.reviewed_item_count",
|
||||||
|
);
|
||||||
|
const remainingItemCount = integerValue(
|
||||||
|
source.remaining_item_count,
|
||||||
|
"E40 operator review.remaining_item_count",
|
||||||
|
);
|
||||||
|
const itemCount = integerValue(
|
||||||
|
binding.item_count,
|
||||||
|
"E40 operator review.source.item_count",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
reviewedItemCount !== decisions.length
|
||||||
|
|| reviewedItemCount + remainingItemCount !== itemCount
|
||||||
|
|| new Set(decisions.map((decision) => decision.itemId)).size
|
||||||
|
!== decisions.length
|
||||||
|
) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"E40 operator review: счётчики решений несовместимы.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewId: contentId(
|
||||||
|
source.review_id,
|
||||||
|
"e40-operator-review",
|
||||||
|
"E40 operator review.review_id",
|
||||||
|
),
|
||||||
|
resultId: contentId(
|
||||||
|
binding.result_id,
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
"E40 operator review.source.result_id",
|
||||||
|
),
|
||||||
|
materializationId: contentId(
|
||||||
|
binding.materialization_id,
|
||||||
|
"e30-materialization",
|
||||||
|
"E40 operator review.source.materialization_id",
|
||||||
|
),
|
||||||
|
caseCatalogSha256: sha256Value(
|
||||||
|
binding.case_catalog_sha256,
|
||||||
|
"E40 operator review.source.case_catalog_sha256",
|
||||||
|
),
|
||||||
|
itemSetSha256: sha256Value(
|
||||||
|
binding.item_set_sha256,
|
||||||
|
"E40 operator review.source.item_set_sha256",
|
||||||
|
),
|
||||||
|
itemCount,
|
||||||
|
reviewerId: stringValue(
|
||||||
|
source.reviewer_id,
|
||||||
|
"E40 operator review.reviewer_id",
|
||||||
|
),
|
||||||
|
revision: integerValue(
|
||||||
|
source.revision,
|
||||||
|
"E40 operator review.revision",
|
||||||
|
),
|
||||||
|
decisions,
|
||||||
|
reviewedItemCount,
|
||||||
|
remainingItemCount,
|
||||||
|
updatedAtUtc: nullableString(
|
||||||
|
source.updated_at_utc,
|
||||||
|
"E40 operator review.updated_at_utc",
|
||||||
|
),
|
||||||
|
access: exactString(
|
||||||
|
source.access,
|
||||||
|
"review-write",
|
||||||
|
"E40 operator review.access",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE40CaseCatalog(
|
||||||
|
resultId: string,
|
||||||
|
{
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {},
|
||||||
|
): Promise<E40CaseCatalog> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}/cases?limit=48`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
`Case-review E40 недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const catalog = parseE40CaseCatalog(await response.json());
|
||||||
|
if (catalog.resultId !== resultId) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"Case-review E40 вернул другой результат.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE40OperatorReview(
|
||||||
|
resultId: string,
|
||||||
|
{
|
||||||
|
reviewerId = "DC",
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
reviewerId?: string;
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {},
|
||||||
|
): Promise<E40OperatorReview> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||||
|
+ `/operator-review?reviewer_id=${encodeURIComponent(reviewerId)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
`Операторская проверка E40 недоступна: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const review = parseE40OperatorReview(await response.json());
|
||||||
|
if (review.resultId !== resultId || review.reviewerId !== reviewerId) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"Операторская проверка E40 вернула другой источник.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveE40OperatorVerdict(
|
||||||
|
resultId: string,
|
||||||
|
itemId: string,
|
||||||
|
{
|
||||||
|
reviewerId = "DC",
|
||||||
|
expectedRevision,
|
||||||
|
idempotencyKey,
|
||||||
|
verdict,
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
reviewerId?: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
verdict: E40OperatorVerdict;
|
||||||
|
fetcher?: LaboratoryFetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
): Promise<E40OperatorReview> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/e40/results/${encodeURIComponent(resultId)}`
|
||||||
|
+ `/operator-review/decisions/${encodeURIComponent(itemId)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
reviewer_id: reviewerId,
|
||||||
|
expected_revision: expectedRevision,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
verdict,
|
||||||
|
}),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
`Решение E40 не сохранено: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const review = parseE40OperatorReview(await response.json());
|
||||||
|
if (
|
||||||
|
review.resultId !== resultId
|
||||||
|
|| review.reviewerId !== reviewerId
|
||||||
|
|| !review.decisions.some(
|
||||||
|
(decision) => decision.itemId === itemId
|
||||||
|
&& decision.verdict === verdict,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new E40CaseReviewContractError(
|
||||||
|
"Решение E40 не подтверждено сервером.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return review;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@import "./styles/shell.css";
|
@import "./styles/shell.css";
|
||||||
@import "./styles/workspaces.css";
|
@import "./styles/workspaces.css";
|
||||||
@import "./styles/laboratory.css";
|
@import "./styles/laboratory.css";
|
||||||
|
@import "./styles/e40-case-review.css";
|
||||||
@import "./styles/laboratory-reporting.css";
|
@import "./styles/laboratory-reporting.css";
|
||||||
@import "./styles/e34-temporal-layer.css";
|
@import "./styles/e34-temporal-layer.css";
|
||||||
@import "./styles/e35-degradation-recovery.css";
|
@import "./styles/e35-degradation-recovery.css";
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
.e40-case-review {
|
||||||
|
display: grid;
|
||||||
|
height: clamp(38rem, 66vh, 52rem);
|
||||||
|
min-height: 38rem;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 0.65rem;
|
||||||
|
border-radius: var(--nodedc-radius-option);
|
||||||
|
background: var(--nodedc-glass-panel-bg-soft);
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__header {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__header > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__header strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__header small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__viewer {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__viewer > .laboratory-evidence-viewer {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 16rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-radius: var(--nodedc-radius-option);
|
||||||
|
background: var(--nodedc-canvas);
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__controls {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__viewer
|
||||||
|
.laboratory-evidence-viewer__controls:has(.e40-case-review__controls) {
|
||||||
|
right: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__pagination {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__glass-button {
|
||||||
|
background: var(--nodedc-floating-surface);
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__controls .nodedc-select-anchor,
|
||||||
|
.e40-case-review__controls .nodedc-select {
|
||||||
|
width: clamp(15rem, 30vw, 26rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__select .nodedc-select__value,
|
||||||
|
.e40-case-review__select .nodedc-select__toggle,
|
||||||
|
.e40-case-review__select-menu {
|
||||||
|
background: var(--nodedc-floating-surface);
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__layer-button {
|
||||||
|
margin-left: auto;
|
||||||
|
--nodedc-button-bg: var(--nodedc-floating-surface);
|
||||||
|
--nodedc-button-color: var(--nodedc-text-secondary);
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__layer-button[data-active="true"] {
|
||||||
|
--nodedc-button-bg: var(--nodedc-glass-control-active);
|
||||||
|
--nodedc-button-color: var(--nodedc-glass-control-active-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
left: 0.6rem;
|
||||||
|
bottom: 0.6rem;
|
||||||
|
display: grid;
|
||||||
|
width: min(46rem, calc(100% - 1.2rem));
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(14rem, 18rem);
|
||||||
|
align-items: end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-floating-surface);
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry-content {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__question {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
|
background: var(--nodedc-glass-control-bg);
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__question > span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.46rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__question > strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__question > small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.49rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry-content > span,
|
||||||
|
.e40-case-review__telemetry dt {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.49rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 6.2rem minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dt,
|
||||||
|
.e40-case-review__telemetry dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dd {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.12rem;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dd span,
|
||||||
|
.e40-case-review__telemetry dd strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry dd strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 0.28rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict > span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.49rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-error {
|
||||||
|
max-width: 12rem;
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
font-size: 0.48rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--nodedc-glass-control-bg);
|
||||||
|
padding: 0.18rem;
|
||||||
|
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-guide {
|
||||||
|
display: grid;
|
||||||
|
justify-self: stretch;
|
||||||
|
gap: 0.26rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-guide > span {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.28rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.49rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-guide > span[data-verdict="confirmed"] > svg {
|
||||||
|
color: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-guide > span[data-verdict="rejected"] > svg {
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__commit-note {
|
||||||
|
max-width: 18rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.46rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
min-width: 2rem;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-button[data-active="true"][data-verdict="confirmed"] {
|
||||||
|
background: rgb(var(--nodedc-success-rgb) / 0.12);
|
||||||
|
color: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict-button[data-active="true"][data-verdict="rejected"] {
|
||||||
|
background: rgb(var(--nodedc-danger-rgb) / 0.12);
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.e40-case-review {
|
||||||
|
height: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__controls .nodedc-select-anchor,
|
||||||
|
.e40-case-review__controls .nodedc-select {
|
||||||
|
width: clamp(10rem, 42vw, 18rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__telemetry {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.e40-case-review__verdict {
|
||||||
|
justify-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -460,7 +460,7 @@ export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
|
|||||||
</span>
|
</span>
|
||||||
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||||
<span data-point="selected">
|
<span data-point="selected">
|
||||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export function E30EvidenceProjection({
|
|||||||
</span>
|
</span>
|
||||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||||
<span data-point="selected">
|
<span data-point="selected">
|
||||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||||
|
import {
|
||||||
|
isAdvancedLaboratoryWorkId,
|
||||||
|
type AdvancedLaboratoryIndexItem,
|
||||||
|
type AdvancedLaboratoryWorkId,
|
||||||
|
} from "../../core/laboratory/advancedIndex";
|
||||||
import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
import type { AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||||
import type { WorkspaceRendererProps } from "../contracts";
|
import type { WorkspaceRendererProps } from "../contracts";
|
||||||
@@ -15,97 +20,29 @@ import { E39Result } from "./E39Result";
|
|||||||
import { E40Result } from "./E40Result";
|
import { E40Result } from "./E40Result";
|
||||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||||
|
|
||||||
export type AdvancedLaboratoryWorkId =
|
export { isAdvancedLaboratoryWorkId };
|
||||||
| "e31-source-binding"
|
export type { AdvancedLaboratoryWorkId };
|
||||||
| "e32-track-geometry"
|
|
||||||
| "e33-worker-shadow"
|
|
||||||
| "e34-temporal-layer"
|
|
||||||
| "e35-degradation-recovery"
|
|
||||||
| "e37-ravnoves-acceptance"
|
|
||||||
| "e38-perception-baseline"
|
|
||||||
| "e39-perception-refinement"
|
|
||||||
| "e40-perception-product-gate";
|
|
||||||
|
|
||||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isAdvancedLaboratoryWorkId(
|
|
||||||
value: string,
|
|
||||||
): value is AdvancedLaboratoryWorkId {
|
|
||||||
return (
|
|
||||||
value === "e31-source-binding"
|
|
||||||
|| value === "e32-track-geometry"
|
|
||||||
|| value === "e33-worker-shadow"
|
|
||||||
|| value === "e34-temporal-layer"
|
|
||||||
|| value === "e35-degradation-recovery"
|
|
||||||
|| value === "e37-ravnoves-acceptance"
|
|
||||||
|| value === "e38-perception-baseline"
|
|
||||||
|| value === "e39-perception-refinement"
|
|
||||||
|| value === "e40-perception-product-gate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function advancedLaboratoryWorkOptions(
|
export function advancedLaboratoryWorkOptions(
|
||||||
results: AdvancedLaboratoryResults,
|
index: readonly AdvancedLaboratoryIndexItem[],
|
||||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>,
|
|
||||||
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
||||||
const options: LaboratoryOption<AdvancedLaboratoryWorkId>[] = [];
|
const available = new Set(index.map(({ workId }) => workId));
|
||||||
if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) {
|
const options: readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] = [
|
||||||
options.push({
|
{ id: "e31-source-binding", label: "LAB E31 · source binding" },
|
||||||
id: "e31-source-binding",
|
{ id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" },
|
||||||
label: "LAB E31 · source binding",
|
{ id: "e33-worker-shadow", label: "LAB E33 · worker shadow 1×" },
|
||||||
});
|
{ id: "e34-temporal-layer", label: "LAB E34 · temporal occupied/unknown" },
|
||||||
}
|
{ id: "e35-degradation-recovery", label: "LAB E35 · degradation recovery" },
|
||||||
if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) {
|
{ id: "e37-ravnoves-acceptance", label: "LAB E37 · RAVNOVES00 acceptance R0" },
|
||||||
options.push({
|
{ id: "e38-perception-baseline", label: "LAB E38 · perception quality R1" },
|
||||||
id: "e32-track-geometry",
|
{ id: "e39-perception-refinement", label: "LAB E39 · perception refinement R1" },
|
||||||
label: "LAB E32 · TrackGeometry v1",
|
{ id: "e40-perception-product-gate", label: "LAB E40 · leakage-resistant product gate" },
|
||||||
});
|
];
|
||||||
}
|
return options.filter(({ id }) => available.has(id));
|
||||||
if (results.e33 && sourceSessions.has(results.e33.sourceSessionId)) {
|
|
||||||
options.push({
|
|
||||||
id: "e33-worker-shadow",
|
|
||||||
label: "LAB E33 · worker shadow 1×",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e34) {
|
|
||||||
options.push({
|
|
||||||
id: "e34-temporal-layer",
|
|
||||||
label: "LAB E34 · temporal occupied/unknown",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e35) {
|
|
||||||
options.push({
|
|
||||||
id: "e35-degradation-recovery",
|
|
||||||
label: "LAB E35 · degradation recovery",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e37) {
|
|
||||||
options.push({
|
|
||||||
id: "e37-ravnoves-acceptance",
|
|
||||||
label: "LAB E37 · RAVNOVES00 acceptance R0",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e38) {
|
|
||||||
options.push({
|
|
||||||
id: "e38-perception-baseline",
|
|
||||||
label: "LAB E38 · perception quality R1",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e39) {
|
|
||||||
options.push({
|
|
||||||
id: "e39-perception-refinement",
|
|
||||||
label: "LAB E39 · perception refinement R1",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (results.e40) {
|
|
||||||
options.push({
|
|
||||||
id: "e40-perception-product-gate",
|
|
||||||
label: "LAB E40 · leakage-resistant product gate",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function advancedLaboratorySourceSession(
|
export function advancedLaboratorySourceSession(
|
||||||
|
|||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Select,
|
||||||
|
StatusBadge,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||||
|
import {
|
||||||
|
fetchE30ReviewItemDetail,
|
||||||
|
type E30ReviewItemDetail,
|
||||||
|
} from "../../core/laboratory/e30Review";
|
||||||
|
import {
|
||||||
|
fetchE40CaseCatalog,
|
||||||
|
fetchE40OperatorReview,
|
||||||
|
saveE40OperatorVerdict,
|
||||||
|
type E40CaseCatalog,
|
||||||
|
type E40CaseDimension,
|
||||||
|
type E40CaseSeverity,
|
||||||
|
type E40CaseState,
|
||||||
|
type E40CaseStratum,
|
||||||
|
type E40ErrorCase,
|
||||||
|
type E40OperatorReview,
|
||||||
|
type E40OperatorVerdict,
|
||||||
|
} from "../../core/laboratory/e40CaseReview";
|
||||||
|
import { E30EvidencePointCloud } from "../E30EvidencePointCloud";
|
||||||
|
import { E30EvidenceProjection } from "../E30EvidenceProjection";
|
||||||
|
|
||||||
|
type EvidenceMode = "camera" | "3d";
|
||||||
|
|
||||||
|
const SEVERITY_LABELS: Record<E40CaseSeverity, string> = {
|
||||||
|
high: "Высокий приоритет",
|
||||||
|
medium: "Средний приоритет",
|
||||||
|
standard: "Стандартный приоритет",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRATUM_LABELS: Record<E40CaseStratum, string> = {
|
||||||
|
agree: "согласованные источники",
|
||||||
|
"camera-only": "только камера",
|
||||||
|
conflict: "конфликт источников",
|
||||||
|
"geometry-only": "только геометрия",
|
||||||
|
unknown: "неопределённый источник",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIMENSION_LABELS: Record<E40CaseDimension, string> = {
|
||||||
|
presence: "Присутствие",
|
||||||
|
geometry_association: "Связь с геометрией",
|
||||||
|
freshness: "Актуальность",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALUE_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
"background-or-noise": "Фон или шум",
|
||||||
|
"object-present": "Объект присутствует",
|
||||||
|
"occupied-environment": "Занятая среда",
|
||||||
|
"independent-occupied": "Независимая геометрия",
|
||||||
|
"insufficient-support": "Недостаточно опоры",
|
||||||
|
"object-associated": "Связано с объектом",
|
||||||
|
"rejected-nonobject": "Не объект",
|
||||||
|
unknown: "Не определено",
|
||||||
|
current: "Актуально",
|
||||||
|
stale: "Устарело",
|
||||||
|
unavailable: "Недоступно",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateValue(
|
||||||
|
state: E40CaseState,
|
||||||
|
dimension: E40CaseDimension,
|
||||||
|
): string {
|
||||||
|
if (dimension === "presence") return state.presence;
|
||||||
|
if (dimension === "geometry_association") {
|
||||||
|
return state.geometryAssociation;
|
||||||
|
}
|
||||||
|
return state.freshness;
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseLabel(item: E40ErrorCase): string {
|
||||||
|
const dimensions = item.mismatchedDimensions
|
||||||
|
.map((dimension) => DIMENSION_LABELS[dimension])
|
||||||
|
.join(" + ");
|
||||||
|
return `${SEVERITY_LABELS[item.severity]} · кадр ${item.sourceFrameIndex} · ${dimensions}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewPrompt(
|
||||||
|
item: E40ErrorCase,
|
||||||
|
detail: E30ReviewItemDetail,
|
||||||
|
): {
|
||||||
|
question: string;
|
||||||
|
evidence: string;
|
||||||
|
confirm: string;
|
||||||
|
reject: string;
|
||||||
|
} {
|
||||||
|
const hasSelectedPoints = detail.selected.pointsMapXyzM.length > 0;
|
||||||
|
const subject = hasSelectedPoints
|
||||||
|
? "Белый LiDAR-кластер"
|
||||||
|
: "Выделенная область";
|
||||||
|
const evidence = hasSelectedPoints
|
||||||
|
? detail.locatorKind === "geometry-only-cluster"
|
||||||
|
? (
|
||||||
|
"Белые точки — самостоятельный кластер занятой геометрии E29 без "
|
||||||
|
+ "класса. Это проверяемая LiDAR-опора, а не отметки ошибки."
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
"Белые точки — LiDAR-опора, которую E29 связал с наблюдением. "
|
||||||
|
+ "Это проверяемые исходные точки, а не отметки ошибки."
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
"Белого LiDAR-кластера в этом кейсе нет: решение принимается по "
|
||||||
|
+ "кадру и рамке наблюдения."
|
||||||
|
);
|
||||||
|
|
||||||
|
if (item.reference.presence === "object-present") {
|
||||||
|
return {
|
||||||
|
question: `${subject} относится к отдельному видимому объекту?`,
|
||||||
|
evidence,
|
||||||
|
confirm: (
|
||||||
|
"Да — объект виден; эталон верен, расхождение E40 подтверждается."
|
||||||
|
),
|
||||||
|
reject: (
|
||||||
|
"Нет — это фон или часть среды; предполагаемая ошибка отклоняется."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (item.reference.presence === "background-or-noise") {
|
||||||
|
return {
|
||||||
|
question: `${subject} является фоном или шумом, а не объектом?`,
|
||||||
|
evidence,
|
||||||
|
confirm: (
|
||||||
|
"Да — это фон или шум; эталон верен, расхождение E40 подтверждается."
|
||||||
|
),
|
||||||
|
reject: (
|
||||||
|
"Нет — отдельный объект есть; предполагаемая ошибка отклоняется."
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
question: "Проверяемый эталон точнее описывает кадр, чем решение E40?",
|
||||||
|
evidence,
|
||||||
|
confirm: "Да — эталон верен, расхождение E40 подтверждается.",
|
||||||
|
reject: "Нет — эталон спорен, предполагаемая ошибка отклоняется.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function E40CaseReview({ resultId }: { resultId: string }) {
|
||||||
|
const [catalog, setCatalog] = useState<E40CaseCatalog | null>(null);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
|
||||||
|
const [review, setReview] = useState<E40OperatorReview | null>(null);
|
||||||
|
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [verdictPending, setVerdictPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [reviewError, setReviewError] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<EvidenceMode>("camera");
|
||||||
|
const [pointLayerVisible, setPointLayerVisible] = useState(true);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setCatalogLoading(true);
|
||||||
|
setCatalog(null);
|
||||||
|
setDetail(null);
|
||||||
|
setReview(null);
|
||||||
|
setSelectedItemId(null);
|
||||||
|
setError(null);
|
||||||
|
setReviewError(null);
|
||||||
|
void Promise.all([
|
||||||
|
fetchE40CaseCatalog(resultId, { signal: controller.signal }),
|
||||||
|
fetchE40OperatorReview(resultId, { signal: controller.signal }),
|
||||||
|
]).then(([nextCatalog, nextReview]) => {
|
||||||
|
if (
|
||||||
|
nextReview.materializationId !== nextCatalog.materializationId
|
||||||
|
|| nextReview.reviewedItemCount > nextCatalog.total
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Операторская проверка E40 не совпадает с каталогом ошибок.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setCatalog(nextCatalog);
|
||||||
|
setReview(nextReview);
|
||||||
|
setSelectedItemId(nextCatalog.items[0]?.itemId ?? null);
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(
|
||||||
|
caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: "Case-review E40 недоступен.",
|
||||||
|
);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!controller.signal.aborted) setCatalogLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [resultId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!catalog || !selectedItemId) {
|
||||||
|
setDetail(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
setError(null);
|
||||||
|
void fetchE30ReviewItemDetail(
|
||||||
|
catalog.materializationId,
|
||||||
|
selectedItemId,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
).then((next) => {
|
||||||
|
setDetail(next);
|
||||||
|
setMode(next.cameraFrame ? "camera" : "3d");
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(
|
||||||
|
caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: "Визуальное доказательство кейса недоступно.",
|
||||||
|
);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!controller.signal.aborted) setDetailLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [catalog, selectedItemId]);
|
||||||
|
|
||||||
|
const selectedCase = catalog?.items.find(
|
||||||
|
(item) => item.itemId === selectedItemId,
|
||||||
|
) ?? null;
|
||||||
|
const selectedIndex = selectedCase && catalog
|
||||||
|
? catalog.items.findIndex((item) => item.itemId === selectedCase.itemId)
|
||||||
|
: -1;
|
||||||
|
const selectedDecision = selectedCase
|
||||||
|
? review?.decisions.find(
|
||||||
|
(decision) => decision.itemId === selectedCase.itemId,
|
||||||
|
) ?? null
|
||||||
|
: null;
|
||||||
|
const prompt = selectedCase && detail
|
||||||
|
? reviewPrompt(selectedCase, detail)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const navigate = (offset: -1 | 1) => {
|
||||||
|
if (!catalog?.items.length || selectedIndex < 0) return;
|
||||||
|
const index = (
|
||||||
|
selectedIndex + offset + catalog.items.length
|
||||||
|
) % catalog.items.length;
|
||||||
|
setSelectedItemId(catalog.items[index].itemId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveVerdict = async (verdict: E40OperatorVerdict) => {
|
||||||
|
if (!review || !selectedCase || verdictPending) return;
|
||||||
|
setVerdictPending(true);
|
||||||
|
setReviewError(null);
|
||||||
|
try {
|
||||||
|
setReview(await saveE40OperatorVerdict(
|
||||||
|
resultId,
|
||||||
|
selectedCase.itemId,
|
||||||
|
{
|
||||||
|
expectedRevision: review.revision,
|
||||||
|
idempotencyKey: `ui-${crypto.randomUUID()}`,
|
||||||
|
verdict,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
} catch (caught) {
|
||||||
|
setReviewError(
|
||||||
|
caught instanceof Error
|
||||||
|
? caught.message
|
||||||
|
: "Решение оператора не сохранено.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setVerdictPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (catalogLoading) {
|
||||||
|
return (
|
||||||
|
<div className="e40-case-review__state" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Проверяем индекс исторической видимой оценки E40</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !selectedCase) {
|
||||||
|
return (
|
||||||
|
<div className="e40-case-review__state" role="status">
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog?.items.length) {
|
||||||
|
return (
|
||||||
|
<div className="e40-case-review__state" role="status">
|
||||||
|
<Icon name="check" size={18} />
|
||||||
|
<span>Расхождений исторической оценки E40 не опубликовано.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="e40-case-review">
|
||||||
|
<header className="e40-case-review__header">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">
|
||||||
|
CASE REVIEW · HISTORICAL VISIBLE EVALUATION
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
Визуальная проверка {catalog.items.length} из {catalog.total}{" "}
|
||||||
|
расхождений
|
||||||
|
</strong>
|
||||||
|
<small>
|
||||||
|
Загружается только выбранный camera/LiDAR-кейс. Проверено
|
||||||
|
оператором: {review?.reviewedItemCount ?? 0} из {catalog.total}.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{selectedCase ? (
|
||||||
|
<StatusBadge
|
||||||
|
tone={selectedCase.severity === "high"
|
||||||
|
? "danger"
|
||||||
|
: selectedCase.severity === "medium"
|
||||||
|
? "warning"
|
||||||
|
: "neutral"}
|
||||||
|
>
|
||||||
|
{SEVERITY_LABELS[selectedCase.severity]}
|
||||||
|
</StatusBadge>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="e40-case-review__viewer">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="e40-case-review__state" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Проверяем точный кадр и LiDAR-геометрию</span>
|
||||||
|
</div>
|
||||||
|
) : error || !detail || !selectedCase ? (
|
||||||
|
<div className="e40-case-review__state" role="status">
|
||||||
|
<Icon name="alert" size={18} />
|
||||||
|
<span>{error ?? "Выберите ошибку для проверки."}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LaboratoryEvidenceViewer
|
||||||
|
label="Визуальная проверка расхождения E40"
|
||||||
|
mode={mode}
|
||||||
|
modes={[
|
||||||
|
{ value: "camera", label: "Камера" },
|
||||||
|
{ value: "3d", label: "3D" },
|
||||||
|
]}
|
||||||
|
expanded={expanded}
|
||||||
|
onModeChange={setMode}
|
||||||
|
onExpandedChange={setExpanded}
|
||||||
|
actions={(
|
||||||
|
<div className="e40-case-review__controls">
|
||||||
|
<div
|
||||||
|
className="e40-case-review__pagination"
|
||||||
|
aria-label="Последовательная проверка ошибок"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
className="e40-case-review__glass-button"
|
||||||
|
label="Предыдущее расхождение"
|
||||||
|
disabled={catalog.items.length < 2}
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
className="e40-case-review__glass-button"
|
||||||
|
label="Следующее расхождение"
|
||||||
|
disabled={catalog.items.length < 2}
|
||||||
|
onClick={() => navigate(1)}
|
||||||
|
>
|
||||||
|
<Icon name="chevron-right" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
className="e40-case-review__select"
|
||||||
|
menuClassName="e40-case-review__select-menu"
|
||||||
|
label="Расхождение исторической оценки E40"
|
||||||
|
value={selectedCase.itemId}
|
||||||
|
options={catalog.items.map((item) => ({
|
||||||
|
value: item.itemId,
|
||||||
|
label: caseLabel(item),
|
||||||
|
}))}
|
||||||
|
variant="split"
|
||||||
|
menuWidth="anchor"
|
||||||
|
onChange={setSelectedItemId}
|
||||||
|
/>
|
||||||
|
{mode === "camera" ? (
|
||||||
|
<Button
|
||||||
|
className="e40-case-review__layer-button"
|
||||||
|
size="compact"
|
||||||
|
variant="secondary"
|
||||||
|
shape="pill"
|
||||||
|
icon={<Icon name="sliders" size={16} />}
|
||||||
|
data-active={pointLayerVisible ? "true" : undefined}
|
||||||
|
aria-pressed={pointLayerVisible}
|
||||||
|
onClick={() => setPointLayerVisible((visible) => !visible)}
|
||||||
|
>
|
||||||
|
LiDAR
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
overlay={(
|
||||||
|
<aside
|
||||||
|
className="e40-case-review__telemetry"
|
||||||
|
aria-label="Эталон и результат E40"
|
||||||
|
>
|
||||||
|
<div className="e40-case-review__telemetry-content">
|
||||||
|
<span>
|
||||||
|
Кадр {detail.sourceFrameIndex}
|
||||||
|
{" · "}
|
||||||
|
{STRATUM_LABELS[selectedCase.sourceStratum]}
|
||||||
|
{selectedCase.predictionBasis === "camera-only-softmax"
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
вероятность camera-only softmax{" "}
|
||||||
|
{(selectedCase.presenceConfidence * 100).toLocaleString(
|
||||||
|
"ru-RU",
|
||||||
|
{ maximumFractionDigits: 1 },
|
||||||
|
)}%
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: " · фиксированная stratum-policy, не вероятность"}
|
||||||
|
</span>
|
||||||
|
{prompt ? (
|
||||||
|
<div className="e40-case-review__question">
|
||||||
|
<span>ЧТО ПРОВЕРЯЕМ</span>
|
||||||
|
<strong>{prompt.question}</strong>
|
||||||
|
<small>{prompt.evidence}</small>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<dl>
|
||||||
|
{selectedCase.mismatchedDimensions.map((dimension) => (
|
||||||
|
<div key={dimension}>
|
||||||
|
<dt>{DIMENSION_LABELS[dimension]}</dt>
|
||||||
|
<dd>
|
||||||
|
<span>
|
||||||
|
Эталон ·{" "}
|
||||||
|
{VALUE_LABELS[
|
||||||
|
stateValue(selectedCase.reference, dimension)
|
||||||
|
]}
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{selectedCase.predictionBasis
|
||||||
|
=== "camera-only-softmax"
|
||||||
|
? "Модель E40"
|
||||||
|
: "Правило E40"}{" "}
|
||||||
|
·{" "}
|
||||||
|
{VALUE_LABELS[
|
||||||
|
stateValue(selectedCase.prediction, dimension)
|
||||||
|
]}
|
||||||
|
</strong>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="e40-case-review__verdict">
|
||||||
|
<span>
|
||||||
|
{verdictPending
|
||||||
|
? "Сохраняем…"
|
||||||
|
: selectedDecision?.verdict === "confirmed-error"
|
||||||
|
? "Ошибка подтверждена"
|
||||||
|
: selectedDecision?.verdict === "rejected-error"
|
||||||
|
? "Расхождение отклонено"
|
||||||
|
: "Решение оператора"}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="e40-case-review__verdict-buttons"
|
||||||
|
aria-label="Решение оператора"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
className="e40-case-review__verdict-button"
|
||||||
|
data-verdict="confirmed"
|
||||||
|
data-active={
|
||||||
|
selectedDecision?.verdict === "confirmed-error"
|
||||||
|
? "true"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={prompt?.confirm ?? "Подтвердить ошибку модели"}
|
||||||
|
aria-pressed={
|
||||||
|
selectedDecision?.verdict === "confirmed-error"
|
||||||
|
}
|
||||||
|
disabled={verdictPending}
|
||||||
|
onClick={() => void saveVerdict("confirmed-error")}
|
||||||
|
>
|
||||||
|
<Icon name="check" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
className="e40-case-review__verdict-button"
|
||||||
|
data-verdict="rejected"
|
||||||
|
data-active={
|
||||||
|
selectedDecision?.verdict === "rejected-error"
|
||||||
|
? "true"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
label={prompt?.reject ?? "Отклонить ошибку модели"}
|
||||||
|
aria-pressed={
|
||||||
|
selectedDecision?.verdict === "rejected-error"
|
||||||
|
}
|
||||||
|
disabled={verdictPending}
|
||||||
|
onClick={() => void saveVerdict("rejected-error")}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{prompt ? (
|
||||||
|
<div className="e40-case-review__verdict-guide">
|
||||||
|
<span data-verdict="confirmed">
|
||||||
|
<Icon name="check" size={12} />
|
||||||
|
{prompt.confirm}
|
||||||
|
</span>
|
||||||
|
<span data-verdict="rejected">
|
||||||
|
<Icon name="close" size={12} />
|
||||||
|
{prompt.reject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<small className="e40-case-review__commit-note">
|
||||||
|
Выбор сохраняется сразу; исходные E30/E40 не изменяются.
|
||||||
|
</small>
|
||||||
|
{reviewError ? (
|
||||||
|
<small
|
||||||
|
className="e40-case-review__verdict-error"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{reviewError}
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode === "camera" ? (
|
||||||
|
<E30EvidenceProjection
|
||||||
|
detail={detail}
|
||||||
|
projectionWidth={detail.materialization.projectionWidth}
|
||||||
|
projectionHeight={detail.materialization.projectionHeight}
|
||||||
|
pointLayerVisible={pointLayerVisible}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<E30EvidencePointCloud detail={detail} />
|
||||||
|
)}
|
||||||
|
</LaboratoryEvidenceViewer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
LaboratoryEvidence,
|
LaboratoryEvidence,
|
||||||
LaboratoryMetricGrid,
|
|
||||||
LaboratoryResultSummary,
|
LaboratoryResultSummary,
|
||||||
LaboratorySummary,
|
LaboratorySummary,
|
||||||
LaboratoryWorkTemplate,
|
LaboratoryWorkTemplate,
|
||||||
@@ -9,6 +8,7 @@ import type {
|
|||||||
E40PerceptionProductGateResult,
|
E40PerceptionProductGateResult,
|
||||||
} from "../../core/laboratory/e40ProductGate";
|
} from "../../core/laboratory/e40ProductGate";
|
||||||
import { formatNumber } from "../../presentation";
|
import { formatNumber } from "../../presentation";
|
||||||
|
import { E40CaseReview } from "./E40CaseReview";
|
||||||
|
|
||||||
function percent(value: number): string {
|
function percent(value: number): string {
|
||||||
return `${(value * 100).toLocaleString("ru-RU", {
|
return `${(value * 100).toLocaleString("ru-RU", {
|
||||||
@@ -32,14 +32,14 @@ export function E40Result({
|
|||||||
result.developmentCrossValidation.protocols.wholeTrackOrSceneWindow.dimensions
|
result.developmentCrossValidation.protocols.wholeTrackOrSceneWindow.dimensions
|
||||||
);
|
);
|
||||||
const gateLabel = result.qualityGatePassed
|
const gateLabel = result.qualityGatePassed
|
||||||
? "RAVNOVES00 product gate пройден"
|
? "Историческая оценка E40 пройдена"
|
||||||
: "RAVNOVES00 product gate не пройден";
|
: "Историческая оценка E40 не пройдена";
|
||||||
return (
|
return (
|
||||||
<LaboratoryWorkTemplate
|
<LaboratoryWorkTemplate
|
||||||
summary={(
|
summary={(
|
||||||
<LaboratorySummary
|
<LaboratorySummary
|
||||||
title="LAB E40 · leakage-resistant product gate"
|
title="LAB E40 · historical visible engineering evaluation"
|
||||||
description="После E39 проверена route-coordinate-free модель: смежные кадры, целые track и scene-window изолированы при development-проверке, а sealed validation исполнен один раз на Worker 006."
|
description="Историческая source-scoped оценка E40: camera-only softmax и фиксированная stratum-policy измерены на уже видимом инженерном контракте RAVNOVES00. Это не operational camera→LiDAR pipeline и не blind validation."
|
||||||
status={gateLabel}
|
status={gateLabel}
|
||||||
statusTone={result.qualityGatePassed ? "success" : "warning"}
|
statusTone={result.qualityGatePassed ? "success" : "warning"}
|
||||||
facts={[
|
facts={[
|
||||||
@@ -53,7 +53,7 @@ export function E40Result({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Validation",
|
label: "Validation",
|
||||||
value: `${formatNumber(metrics.validationItems, 0)} · sealed`,
|
value: `${formatNumber(metrics.validationItems, 0)} · historical visible`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Исполнение",
|
label: "Исполнение",
|
||||||
@@ -61,10 +61,10 @@ export function E40Result({
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
brief={{
|
brief={{
|
||||||
question: "Удерживает ли perception выбранного сенсорного рига не менее 90% по presence, geometry association и freshness после удаления route-coordinate leakage и группировки зависимых кадров?",
|
question: "Как camera-only softmax и фиксированная политика страт согласуются с уже видимым инженерным контрактом RAVNOVES00?",
|
||||||
approach: "Для 340 development-кейсов зафиксированы 125 признаков без frame index, session time, review ordinal, track ID и абсолютных map-координат. Консервативная stratum-policy и camera-only softmax прошли два групповых пятифолдовых протокола; validation labels при выборе и обучении не использовались.",
|
approach: "Для 340 development-кейсов зафиксированы 125 признаков без frame index, session time, review ordinal, track ID и абсолютных map-координат. Только camera-only использует softmax; agree, conflict, geometry-only и unknown получают детерминированные policy-state, а не вероятность operational detector.",
|
||||||
principalResult: `Development: contiguous presence ${percent(contiguous.presence.accuracy)}, grouped presence ${percent(grouped.presence.accuracy)}. Sealed validation: presence ${percent(dimensions.presence.accuracy)}, geometry ${percent(dimensions.geometryAssociation.accuracy)}, freshness ${percent(dimensions.freshness.accuracy)}.`,
|
principalResult: `Development: contiguous presence ${percent(contiguous.presence.accuracy)}, grouped presence ${percent(grouped.presence.accuracy)}. Historical visible evaluation: presence ${percent(dimensions.presence.accuracy)}, geometry ${percent(dimensions.geometryAssociation.accuracy)}, freshness ${percent(dimensions.freshness.accuracy)}.`,
|
||||||
limitation: "Это source-scoped доказательство только для RAVNOVES00 на инженерно проверенной разметке. Оно не доказывает перенос на другой маршрут, растительность, камеру, риг или живой ровер и не выдаёт навигационных либо safety-полномочий.",
|
limitation: "E40 — историческая source-scoped инженерная оценка поверх E29/E30, а не сама camera-first perception. Она не является blind validation и не доказывает независимую точность, перенос, навигацию или safety.",
|
||||||
}}
|
}}
|
||||||
method={{
|
method={{
|
||||||
completeness: "complete",
|
completeness: "complete",
|
||||||
@@ -74,7 +74,7 @@ export function E40Result({
|
|||||||
{
|
{
|
||||||
kind: "source",
|
kind: "source",
|
||||||
name: "E37 frozen acceptance contract",
|
name: "E37 frozen acceptance contract",
|
||||||
version: `${formatNumber(metrics.developmentItems, 0)} development + ${formatNumber(metrics.validationItems, 0)} sealed validation`,
|
version: `${formatNumber(metrics.developmentItems, 0)} development + ${formatNumber(metrics.validationItems, 0)} historical visible evaluation`,
|
||||||
role: "неизменяемый denominator и reference-метрики трёх измерений",
|
role: "неизменяемый denominator и reference-метрики трёх измерений",
|
||||||
identitySha256: null,
|
identitySha256: null,
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@ export function E40Result({
|
|||||||
kind: "runtime",
|
kind: "runtime",
|
||||||
name: "Worker 006",
|
name: "Worker 006",
|
||||||
version: result.workerNode,
|
version: result.workerNode,
|
||||||
role: "одно sealed evaluation в package-bound контейнере без командных полномочий",
|
role: "одно historical visible evaluation в package-bound контейнере без командных полномочий",
|
||||||
identitySha256: null,
|
identitySha256: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -105,34 +105,11 @@ export function E40Result({
|
|||||||
)}
|
)}
|
||||||
evidence={(
|
evidence={(
|
||||||
<LaboratoryEvidence
|
<LaboratoryEvidence
|
||||||
eyebrow="GROUPED DEVELOPMENT → SEALED VALIDATION"
|
eyebrow="GROUPED DEVELOPMENT → HISTORICAL VISIBLE EVALUATION"
|
||||||
title="Устойчивость без route-coordinate leakage"
|
title="Устойчивость без route-coordinate leakage"
|
||||||
kind="diagnostic-model"
|
kind="diagnostic-model"
|
||||||
>
|
>
|
||||||
<LaboratoryMetricGrid
|
<E40CaseReview resultId={result.resultId} />
|
||||||
metrics={[
|
|
||||||
{
|
|
||||||
label: "Contiguous time CV",
|
|
||||||
value: percent(contiguous.presence.accuracy),
|
|
||||||
hint: `presence · geometry ${percent(contiguous.geometryAssociation.accuracy)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Whole track / scene CV",
|
|
||||||
value: percent(grouped.presence.accuracy),
|
|
||||||
hint: `presence · geometry ${percent(grouped.geometryAssociation.accuracy)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Sealed presence",
|
|
||||||
value: percent(dimensions.presence.accuracy),
|
|
||||||
hint: `${formatNumber(dimensions.presence.correct, 0)} / ${formatNumber(dimensions.presence.total, 0)} · цель 90%`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Sealed freshness",
|
|
||||||
value: percent(dimensions.freshness.accuracy),
|
|
||||||
hint: `${formatNumber(dimensions.freshness.correct, 0)} / ${formatNumber(dimensions.freshness.total, 0)} · цель 90%`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</LaboratoryEvidence>
|
</LaboratoryEvidence>
|
||||||
)}
|
)}
|
||||||
result={(
|
result={(
|
||||||
@@ -167,7 +144,7 @@ export function E40Result({
|
|||||||
conclusion={{
|
conclusion={{
|
||||||
proved: result.qualityGatePassed
|
proved: result.qualityGatePassed
|
||||||
? "На неизменяемом RAVNOVES00 все три task-level dimension достигли 90%, полный denominator учтён, ложное свободное пространство не опубликовано и high-severity ошибок нет."
|
? "На неизменяемом RAVNOVES00 все три task-level dimension достигли 90%, полный denominator учтён, ложное свободное пространство не опубликовано и high-severity ошибок нет."
|
||||||
: `Development-профиль устойчив к двум зависимым разбиениям; sealed evaluation завершён с полным учётом ${formatNumber(metrics.validationItems, 0)} кейсов и без ложного свободного пространства.`,
|
: `Development-профиль устойчив к двум зависимым разбиениям; historical visible evaluation завершён с полным учётом ${formatNumber(metrics.validationItems, 0)} кейсов и без ложного свободного пространства.`,
|
||||||
notProved: "Не доказаны независимая физическая ground truth, второй маршрут, другой риг, растительная среда, живой rover runtime, навигация, команды или safety.",
|
notProved: "Не доказаны независимая физическая ground truth, второй маршрут, другой риг, растительная среда, живой rover runtime, навигация, команды или safety.",
|
||||||
decision: result.qualityGatePassed
|
decision: result.qualityGatePassed
|
||||||
? "Зафиксировать RAVNOVES00 perception gate как закрытый source-scoped этап. Следующий шаг — temporal product state и live-rover replay без расширения полномочий."
|
? "Зафиксировать RAVNOVES00 perception gate как закрытый source-scoped этап. Следующий шаг — temporal product state и live-rover replay без расширения полномочий."
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
type E30ReviewResult,
|
type E30ReviewResult,
|
||||||
} from "../../core/laboratory/e30Review";
|
} from "../../core/laboratory/e30Review";
|
||||||
import {
|
import {
|
||||||
fetchAdvancedLaboratoryResults,
|
|
||||||
type AdvancedLaboratoryResults,
|
type AdvancedLaboratoryResults,
|
||||||
} from "../../core/laboratory/advancedResults";
|
} from "../../core/laboratory/advancedResults";
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||||
} from "./laboratoryArchiveBriefs";
|
} from "./laboratoryArchiveBriefs";
|
||||||
|
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||||
};
|
};
|
||||||
@@ -63,18 +63,6 @@ type LaboratoryWorkId =
|
|||||||
| AdvancedLaboratoryWorkId
|
| AdvancedLaboratoryWorkId
|
||||||
| `session:${string}`;
|
| `session:${string}`;
|
||||||
|
|
||||||
const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = {
|
|
||||||
e31: null,
|
|
||||||
e32: null,
|
|
||||||
e33: null,
|
|
||||||
e34: null,
|
|
||||||
e35: null,
|
|
||||||
e37: null,
|
|
||||||
e38: null,
|
|
||||||
e39: null,
|
|
||||||
e40: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function laboratoryWorkOrdinal(value: string): number {
|
function laboratoryWorkOrdinal(value: string): number {
|
||||||
const match = value.match(/\bE(\d+)\b/i);
|
const match = value.match(/\bE(\d+)\b/i);
|
||||||
return match ? Number(match[1]) : -1;
|
return match ? Number(match[1]) : -1;
|
||||||
@@ -572,13 +560,11 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||||
const [advancedResults, setAdvancedResults] = useState(
|
|
||||||
EMPTY_ADVANCED_RESULTS,
|
|
||||||
);
|
|
||||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
pollingEnabled: false,
|
||||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||||
@@ -603,6 +589,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||||
[sessions.items],
|
[sessions.items],
|
||||||
);
|
);
|
||||||
|
const advanced = useAdvancedLaboratoryCatalog({
|
||||||
|
selectedWorkId: workId,
|
||||||
|
onResultLoaded: (nextWorkId, result) => {
|
||||||
|
const sourceSession = advancedLaboratorySourceSession(
|
||||||
|
nextWorkId,
|
||||||
|
result,
|
||||||
|
sourceSessions,
|
||||||
|
);
|
||||||
|
if (sourceSession) void sessions.replay(sourceSession.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -612,22 +610,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||||
fetchAdvancedLaboratoryResults({ signal: controller.signal }),
|
]).then(([e28, e29, e30]) => {
|
||||||
]).then(([e28, e29, e30, advanced]) => {
|
|
||||||
if (controller.signal.aborted) return;
|
if (controller.signal.aborted) return;
|
||||||
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
||||||
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
||||||
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
|
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
|
||||||
const nextAdvanced = advanced.status === "fulfilled" ? advanced.value : null;
|
|
||||||
setE28Model(nextE28);
|
setE28Model(nextE28);
|
||||||
setE29Result(nextE29);
|
setE29Result(nextE29);
|
||||||
setE30Result(nextE30);
|
setE30Result(nextE30);
|
||||||
setAdvancedResults(nextAdvanced ?? EMPTY_ADVANCED_RESULTS);
|
|
||||||
const failures = [
|
const failures = [
|
||||||
e28.status === "rejected" ? "E28" : null,
|
e28.status === "rejected" ? "E28" : null,
|
||||||
e29.status === "rejected" ? "E29" : null,
|
e29.status === "rejected" ? "E29" : null,
|
||||||
e30.status === "rejected" ? "E30" : null,
|
e30.status === "rejected" ? "E30" : null,
|
||||||
advanced.status === "rejected" ? "E31–E40" : null,
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
setEvidenceError(
|
setEvidenceError(
|
||||||
failures.length
|
failures.length
|
||||||
@@ -673,19 +667,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
label: "LAB E30 · evidence review A2",
|
label: "LAB E30 · evidence review A2",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
items.push(...advancedLaboratoryWorkOptions(
|
items.push(...advancedLaboratoryWorkOptions(advanced.index));
|
||||||
advancedResults,
|
|
||||||
sourceSessions,
|
|
||||||
));
|
|
||||||
return items.sort(
|
return items.sort(
|
||||||
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
|
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
advancedResults,
|
advanced.index,
|
||||||
e28Model,
|
e28Model,
|
||||||
e29Result,
|
e29Result,
|
||||||
e30Result,
|
e30Result,
|
||||||
sourceSessions,
|
|
||||||
]);
|
]);
|
||||||
const profiles = useMemo(() => {
|
const profiles = useMemo(() => {
|
||||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||||
@@ -726,6 +716,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
evidenceLoading
|
evidenceLoading
|
||||||
|
|| advanced.indexLoading
|
||||||
|| sessions.state === "idle"
|
|| sessions.state === "idle"
|
||||||
|| sessions.state === "loading"
|
|| sessions.state === "loading"
|
||||||
) return;
|
) return;
|
||||||
@@ -761,6 +752,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
evidenceLoading,
|
evidenceLoading,
|
||||||
|
advanced.indexLoading,
|
||||||
profileId,
|
profileId,
|
||||||
profiles,
|
profiles,
|
||||||
publishedWorks,
|
publishedWorks,
|
||||||
@@ -813,6 +805,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
evidenceLoading
|
evidenceLoading
|
||||||
|
|| advanced.indexLoading
|
||||||
|| sessions.state === "idle"
|
|| sessions.state === "idle"
|
||||||
|| sessions.state === "loading"
|
|| sessions.state === "loading"
|
||||||
) {
|
) {
|
||||||
@@ -831,7 +824,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
<Icon name="database" size={20} />
|
<Icon name="database" size={20} />
|
||||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||||
<p>
|
<p>
|
||||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
{evidenceError
|
||||||
|
?? advanced.indexError
|
||||||
|
?? sessions.error
|
||||||
|
?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -953,6 +949,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
result={e30Result}
|
result={e30Result}
|
||||||
sourceSession={e30SourceSession}
|
sourceSession={e30SourceSession}
|
||||||
/>
|
/>
|
||||||
|
) : advanced.loadingWorkId === workId ? (
|
||||||
|
<div className="laboratory-result-pending" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<strong>Подготавливаем выбранную лабораторную работу</strong>
|
||||||
|
<p>Сервер проверяет только её доказательства и связанные артефакты.</p>
|
||||||
|
</div>
|
||||||
|
) : advanced.failedWorkId === workId ? (
|
||||||
|
<div className="laboratory-result-pending">
|
||||||
|
<Icon name="database" size={20} />
|
||||||
|
<strong>Работа не прошла ревизию</strong>
|
||||||
|
<p>{advanced.resultError}</p>
|
||||||
|
</div>
|
||||||
) : isAdvancedLaboratoryWorkId(workId) ? (
|
) : isAdvancedLaboratoryWorkId(workId) ? (
|
||||||
<AdvancedLaboratoryResult
|
<AdvancedLaboratoryResult
|
||||||
props={props}
|
props={props}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
advancedLaboratoryResultAvailable,
|
||||||
|
emptyAdvancedLaboratoryResults,
|
||||||
|
fetchAdvancedLaboratoryIndex,
|
||||||
|
fetchAdvancedLaboratoryResult,
|
||||||
|
isAdvancedLaboratoryWorkId,
|
||||||
|
type AdvancedLaboratoryIndexItem,
|
||||||
|
type AdvancedLaboratoryWorkId,
|
||||||
|
} from "../../core/laboratory/advancedIndex";
|
||||||
|
import type {
|
||||||
|
AdvancedLaboratoryResults,
|
||||||
|
} from "../../core/laboratory/advancedResults";
|
||||||
|
|
||||||
|
function mergeResults(
|
||||||
|
current: AdvancedLaboratoryResults,
|
||||||
|
next: AdvancedLaboratoryResults,
|
||||||
|
): AdvancedLaboratoryResults {
|
||||||
|
return {
|
||||||
|
e31: next.e31 ?? current.e31,
|
||||||
|
e32: next.e32 ?? current.e32,
|
||||||
|
e33: next.e33 ?? current.e33,
|
||||||
|
e34: next.e34 ?? current.e34,
|
||||||
|
e35: next.e35 ?? current.e35,
|
||||||
|
e37: next.e37 ?? current.e37,
|
||||||
|
e38: next.e38 ?? current.e38,
|
||||||
|
e39: next.e39 ?? current.e39,
|
||||||
|
e40: next.e40 ?? current.e40,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: "Выбранная лабораторная работа не прошла серверную проверку.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdvancedLaboratoryCatalog({
|
||||||
|
selectedWorkId,
|
||||||
|
onResultLoaded,
|
||||||
|
}: {
|
||||||
|
selectedWorkId: string;
|
||||||
|
onResultLoaded?: (
|
||||||
|
workId: AdvancedLaboratoryWorkId,
|
||||||
|
result: AdvancedLaboratoryResults,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [index, setIndex] = useState<readonly AdvancedLaboratoryIndexItem[]>([]);
|
||||||
|
const [indexLoading, setIndexLoading] = useState(true);
|
||||||
|
const [indexError, setIndexError] = useState<string | null>(null);
|
||||||
|
const [results, setResults] = useState<AdvancedLaboratoryResults>(
|
||||||
|
emptyAdvancedLaboratoryResults,
|
||||||
|
);
|
||||||
|
const [loadingWorkId, setLoadingWorkId] =
|
||||||
|
useState<AdvancedLaboratoryWorkId | null>(null);
|
||||||
|
const [failedWorkId, setFailedWorkId] =
|
||||||
|
useState<AdvancedLaboratoryWorkId | null>(null);
|
||||||
|
const [resultError, setResultError] = useState<string | null>(null);
|
||||||
|
const onResultLoadedRef = useRef(onResultLoaded);
|
||||||
|
onResultLoadedRef.current = onResultLoaded;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setIndexLoading(true);
|
||||||
|
setIndexError(null);
|
||||||
|
void fetchAdvancedLaboratoryIndex({ signal: controller.signal })
|
||||||
|
.then((items) => {
|
||||||
|
if (!controller.signal.aborted) setIndex(items);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setIndex([]);
|
||||||
|
setIndexError(errorMessage(error));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setIndexLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isAdvancedLaboratoryWorkId(selectedWorkId)
|
||||||
|
|| advancedLaboratoryResultAvailable(selectedWorkId, results)
|
||||||
|
) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoadingWorkId(selectedWorkId);
|
||||||
|
setFailedWorkId(null);
|
||||||
|
setResultError(null);
|
||||||
|
void fetchAdvancedLaboratoryResult(selectedWorkId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}).then(async (next) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setResults((current) => mergeResults(current, next));
|
||||||
|
await onResultLoadedRef.current?.(selectedWorkId, next);
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setFailedWorkId(selectedWorkId);
|
||||||
|
setResultError(errorMessage(error));
|
||||||
|
}
|
||||||
|
}).finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoadingWorkId(null);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [results, selectedWorkId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
indexLoading,
|
||||||
|
indexError,
|
||||||
|
results,
|
||||||
|
loadingWorkId,
|
||||||
|
failedWorkId,
|
||||||
|
resultError,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { createServer } from "vite";
|
|||||||
|
|
||||||
let server;
|
let server;
|
||||||
let fetchAdvancedLaboratoryResults;
|
let fetchAdvancedLaboratoryResults;
|
||||||
|
let fetchAdvancedLaboratoryIndex;
|
||||||
|
let fetchAdvancedLaboratoryResult;
|
||||||
let AdvancedLaboratoryContractError;
|
let AdvancedLaboratoryContractError;
|
||||||
|
|
||||||
const authority = {
|
const authority = {
|
||||||
@@ -637,6 +639,10 @@ before(async () => {
|
|||||||
fetchAdvancedLaboratoryResults,
|
fetchAdvancedLaboratoryResults,
|
||||||
AdvancedLaboratoryContractError,
|
AdvancedLaboratoryContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/laboratory/advancedResults.ts"));
|
} = await server.ssrLoadModule("/src/core/laboratory/advancedResults.ts"));
|
||||||
|
({
|
||||||
|
fetchAdvancedLaboratoryIndex,
|
||||||
|
fetchAdvancedLaboratoryResult,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/advancedIndex.ts"));
|
||||||
});
|
});
|
||||||
|
|
||||||
after(async () => {
|
after(async () => {
|
||||||
@@ -703,6 +709,53 @@ test("decodes E31–E40 from separate read-only catalogs", async () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("LAB bootstrap reads a lightweight advanced index", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const decoded = await fetchAdvancedLaboratoryIndex({
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requests.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.laboratory-advanced-index/v1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
work_id: "e40-perception-product-gate",
|
||||||
|
result_id: `e40-perception-product-gate-${"a".repeat(64)}`,
|
||||||
|
created_at_utc: "2026-07-28T11:00:00Z",
|
||||||
|
access: "read-only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
access: "read-only",
|
||||||
|
}), { status: 200 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(decoded.map((item) => item.workId), [
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{ input: "/api/v1/laboratory/advanced-index", method: "GET" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selected advanced LAB fetches only its own strict catalog", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const decoded = await fetchAdvancedLaboratoryResult(
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
{
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requests.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify(catalog(e40())), { status: 200 });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(decoded.e40.qualityGatePassed, true);
|
||||||
|
assert.equal(decoded.e31, null);
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{ input: "/api/v1/laboratory/e40/results?limit=1", method: "GET" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps valid LAB catalogs available when one transport endpoint fails", async () => {
|
test("keeps valid LAB catalogs available when one transport endpoint fails", async () => {
|
||||||
const decoded = await fetchAdvancedLaboratoryResults({
|
const decoded = await fetchAdvancedLaboratoryResults({
|
||||||
fetcher: async (input) => {
|
fetcher: async (input) => {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ function item(overrides = {}) {
|
|||||||
materialization: {
|
materialization: {
|
||||||
frame_point_count: 1813,
|
frame_point_count: 1813,
|
||||||
projected_point_count: 3,
|
projected_point_count: 3,
|
||||||
|
projection_width: 800,
|
||||||
|
projection_height: 600,
|
||||||
candidate_point_count: 2,
|
candidate_point_count: 2,
|
||||||
selected_point_count: 1,
|
selected_point_count: 1,
|
||||||
rejected_candidate_point_count: 1,
|
rejected_candidate_point_count: 1,
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let parseE40CaseCatalog;
|
||||||
|
let parseE40OperatorReview;
|
||||||
|
let fetchE40CaseCatalog;
|
||||||
|
let saveE40OperatorVerdict;
|
||||||
|
let E40CaseReviewContractError;
|
||||||
|
|
||||||
|
const resultId = `e40-perception-product-gate-${"a".repeat(64)}`;
|
||||||
|
const materializationId = `e30-materialization-${"b".repeat(64)}`;
|
||||||
|
const itemId = `e30-review-item-${"c".repeat(64)}`;
|
||||||
|
|
||||||
|
function payload(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.laboratory-e40-case-catalog/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
materialization_id: materializationId,
|
||||||
|
items: [{
|
||||||
|
item_id: itemId,
|
||||||
|
sequence: 3,
|
||||||
|
source_frame_index: 1080,
|
||||||
|
source_stratum: "geometry-only",
|
||||||
|
severity: "high",
|
||||||
|
presence_confidence: 1,
|
||||||
|
prediction_basis: "fixed-stratum-policy",
|
||||||
|
reference: {
|
||||||
|
presence: "object-present",
|
||||||
|
geometry_association: "object-associated",
|
||||||
|
freshness: "current",
|
||||||
|
},
|
||||||
|
prediction: {
|
||||||
|
presence: "occupied-environment",
|
||||||
|
geometry_association: "independent-occupied",
|
||||||
|
freshness: "current",
|
||||||
|
},
|
||||||
|
mismatched_dimensions: ["presence", "geometry_association"],
|
||||||
|
access: "read-only",
|
||||||
|
}],
|
||||||
|
total: 23,
|
||||||
|
truncated: false,
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewPayload(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.e40-operator-review/v1",
|
||||||
|
review_id: `e40-operator-review-${"f".repeat(64)}`,
|
||||||
|
protocol: "sealed-error-adjudication/v1",
|
||||||
|
source: {
|
||||||
|
result_id: resultId,
|
||||||
|
materialization_id: materializationId,
|
||||||
|
case_catalog_sha256: "1".repeat(64),
|
||||||
|
item_count: 1,
|
||||||
|
item_set_sha256: "2".repeat(64),
|
||||||
|
},
|
||||||
|
reviewer_id: "DC",
|
||||||
|
revision: 1,
|
||||||
|
decisions: [{
|
||||||
|
item_id: itemId,
|
||||||
|
verdict: "confirmed-error",
|
||||||
|
revision: 1,
|
||||||
|
idempotency_key: "ui-test",
|
||||||
|
decided_at_utc: "2026-07-30T17:00:00.000Z",
|
||||||
|
event_id: `e40-operator-verdict-${"3".repeat(64)}`,
|
||||||
|
}],
|
||||||
|
reviewed_item_count: 1,
|
||||||
|
remaining_item_count: 0,
|
||||||
|
updated_at_utc: "2026-07-30T17:00:00.000Z",
|
||||||
|
access: "review-write",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
configFile: false,
|
||||||
|
appType: "custom",
|
||||||
|
optimizeDeps: { noDiscovery: true },
|
||||||
|
server: { middlewareMode: true, hmr: false },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
parseE40CaseCatalog,
|
||||||
|
parseE40OperatorReview,
|
||||||
|
fetchE40CaseCatalog,
|
||||||
|
saveE40OperatorVerdict,
|
||||||
|
E40CaseReviewContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/e40CaseReview.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E40 operator review preserves the sealed source and verdict", () => {
|
||||||
|
const review = parseE40OperatorReview(reviewPayload());
|
||||||
|
|
||||||
|
assert.equal(review.resultId, resultId);
|
||||||
|
assert.equal(review.materializationId, materializationId);
|
||||||
|
assert.equal(review.caseCatalogSha256, "1".repeat(64));
|
||||||
|
assert.equal(review.reviewedItemCount, 1);
|
||||||
|
assert.equal(review.decisions[0].verdict, "confirmed-error");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E40 case catalog preserves the sealed error binding", () => {
|
||||||
|
const catalog = parseE40CaseCatalog(payload());
|
||||||
|
|
||||||
|
assert.equal(catalog.resultId, resultId);
|
||||||
|
assert.equal(catalog.materializationId, materializationId);
|
||||||
|
assert.equal(catalog.items[0].sourceFrameIndex, 1080);
|
||||||
|
assert.equal(catalog.items[0].predictionBasis, "fixed-stratum-policy");
|
||||||
|
assert.deepEqual(
|
||||||
|
catalog.items[0].mismatchedDimensions,
|
||||||
|
["presence", "geometry_association"],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
catalog.items[0].prediction.geometryAssociation,
|
||||||
|
"independent-occupied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E40 case fetch is bounded and rejects a foreign result", async () => {
|
||||||
|
let requestUrl = "";
|
||||||
|
const fetcher = async (input) => {
|
||||||
|
requestUrl = String(input);
|
||||||
|
return new Response(JSON.stringify(payload({
|
||||||
|
result_id: `e40-perception-product-gate-${"d".repeat(64)}`,
|
||||||
|
})), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fetchE40CaseCatalog(resultId, { fetcher }),
|
||||||
|
E40CaseReviewContractError,
|
||||||
|
);
|
||||||
|
assert.match(requestUrl, /cases\?limit=48$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("E40 operator verdict is committed with revision and idempotency", async () => {
|
||||||
|
let requestUrl = "";
|
||||||
|
let requestInit;
|
||||||
|
const fetcher = async (input, init) => {
|
||||||
|
requestUrl = String(input);
|
||||||
|
requestInit = init;
|
||||||
|
return new Response(JSON.stringify(reviewPayload()), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const review = await saveE40OperatorVerdict(resultId, itemId, {
|
||||||
|
expectedRevision: 0,
|
||||||
|
idempotencyKey: "ui-test",
|
||||||
|
verdict: "confirmed-error",
|
||||||
|
fetcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(requestUrl, /operator-review\/decisions\/e30-review-item-/);
|
||||||
|
assert.equal(requestInit.method, "PUT");
|
||||||
|
assert.deepEqual(JSON.parse(requestInit.body), {
|
||||||
|
reviewer_id: "DC",
|
||||||
|
expected_revision: 0,
|
||||||
|
idempotency_key: "ui-test",
|
||||||
|
verdict: "confirmed-error",
|
||||||
|
});
|
||||||
|
assert.equal(review.revision, 1);
|
||||||
|
});
|
||||||
@@ -42,6 +42,10 @@ const e40ResultUrl = new URL(
|
|||||||
"../src/workspaces/laboratory/E40Result.tsx",
|
"../src/workspaces/laboratory/E40Result.tsx",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
|
const e40CaseReviewUrl = new URL(
|
||||||
|
"../src/workspaces/laboratory/E40CaseReview.tsx",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
const e35StylesUrl = new URL(
|
const e35StylesUrl = new URL(
|
||||||
"../src/styles/e35-degradation-recovery.css",
|
"../src/styles/e35-degradation-recovery.css",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
@@ -265,9 +269,10 @@ test("E39 reports refinement and the CV-to-validation gap through the canonical
|
|||||||
assert.match(advancedSource, /<E39Result/);
|
assert.match(advancedSource, /<E39Result/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("E40 reports dual grouped qualification and the sealed product gate through the canonical LAB anatomy", async () => {
|
test("E40 reports historical visible evaluation with bounded camera-LiDAR case review", async () => {
|
||||||
const [e40Source, advancedSource] = await Promise.all([
|
const [e40Source, caseReviewSource, advancedSource] = await Promise.all([
|
||||||
readFile(e40ResultUrl, "utf8"),
|
readFile(e40ResultUrl, "utf8"),
|
||||||
|
readFile(e40CaseReviewUrl, "utf8"),
|
||||||
readFile(advancedLaboratoryResultUrl, "utf8"),
|
readFile(advancedLaboratoryResultUrl, "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -275,10 +280,22 @@ test("E40 reports dual grouped qualification and the sealed product gate through
|
|||||||
assert.match(e40Source, /<LaboratoryEvidence/);
|
assert.match(e40Source, /<LaboratoryEvidence/);
|
||||||
assert.match(e40Source, /<LaboratoryResultSummary/);
|
assert.match(e40Source, /<LaboratoryResultSummary/);
|
||||||
assert.match(e40Source, /frame index, session time, review ordinal, track ID/);
|
assert.match(e40Source, /frame index, session time, review ordinal, track ID/);
|
||||||
assert.match(e40Source, /validation labels при выборе и обучении не использовались/);
|
assert.match(e40Source, /Только camera-only использует softmax/);
|
||||||
assert.match(e40Source, /Whole track \/ scene CV/);
|
assert.match(e40Source, /<E40CaseReview resultId=\{result\.resultId\}/);
|
||||||
assert.match(e40Source, /[Ss]ource-scoped perception gate/);
|
assert.match(e40Source, /historical visible engineering evaluation/);
|
||||||
assert.match(e40Source, /не подбирая профиль по validation/);
|
assert.match(e40Source, /не подбирая профиль по validation/);
|
||||||
|
assert.match(caseReviewSource, /<LaboratoryEvidenceViewer/);
|
||||||
|
assert.match(caseReviewSource, /<E30EvidenceProjection/);
|
||||||
|
assert.match(caseReviewSource, /<E30EvidencePointCloud/);
|
||||||
|
assert.match(caseReviewSource, /fetchE30ReviewItemDetail/);
|
||||||
|
assert.match(caseReviewSource, /fetchE40OperatorReview/);
|
||||||
|
assert.match(caseReviewSource, /saveE40OperatorVerdict/);
|
||||||
|
assert.match(caseReviewSource, /Расхождение исторической оценки E40/);
|
||||||
|
assert.match(caseReviewSource, /label="Предыдущее расхождение"/);
|
||||||
|
assert.match(caseReviewSource, /label="Следующее расхождение"/);
|
||||||
|
assert.match(caseReviewSource, /расхождение E40 подтверждается/);
|
||||||
|
assert.match(caseReviewSource, /предполагаемая ошибка отклоняется/);
|
||||||
|
assert.match(caseReviewSource, /фиксированная stratum-policy, не вероятность/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
e40Source,
|
e40Source,
|
||||||
/className="laboratory-(?:summary|result-summary|result-metrics|result-conclusion)"/,
|
/className="laboratory-(?:summary|result-summary|result-metrics|result-conclusion)"/,
|
||||||
|
|||||||
@@ -0,0 +1,436 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, Literal
|
||||||
|
|
||||||
|
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||||
|
|
||||||
|
E40_OPERATOR_REVIEW_SCHEMA: Final = "missioncore.e40-operator-review/v1"
|
||||||
|
E40_OPERATOR_REVIEW_PROTOCOL: Final = "sealed-error-adjudication/v1"
|
||||||
|
|
||||||
|
E40OperatorVerdict = Literal["confirmed-error", "rejected-error"]
|
||||||
|
|
||||||
|
_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||||
|
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||||
|
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||||
|
_REVIEW_ID = re.compile(r"^e40-operator-review-[a-f0-9]{64}$")
|
||||||
|
_EVENT_ID = re.compile(r"^e40-operator-verdict-[a-f0-9]{64}$")
|
||||||
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
_REVIEWER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
|
||||||
|
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||||
|
_VERDICTS: Final = {"confirmed-error", "rejected-error"}
|
||||||
|
_MAX_JSON_BYTES: Final = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorReviewError(RuntimeError):
|
||||||
|
"""Base error for the E40 operator-review lifecycle."""
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorReviewConflictError(E40OperatorReviewError):
|
||||||
|
"""The caller attempted to update a stale review revision."""
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorReviewValidationError(E40OperatorReviewError):
|
||||||
|
"""The requested review operation violates the frozen protocol."""
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorReviewIntegrityError(E40OperatorReviewError):
|
||||||
|
"""The stored review no longer matches its sealed E40 substrate."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class E40OperatorReviewSubject:
|
||||||
|
item_id: str
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class E40OperatorReviewSubstrate:
|
||||||
|
result_id: str
|
||||||
|
materialization_id: str
|
||||||
|
case_catalog_sha256: str
|
||||||
|
subjects: tuple[E40OperatorReviewSubject, ...]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
_RESULT_ID.fullmatch(self.result_id) is None
|
||||||
|
or _MATERIALIZATION_ID.fullmatch(self.materialization_id) is None
|
||||||
|
or _SHA256.fullmatch(self.case_catalog_sha256) is None
|
||||||
|
or not self.subjects
|
||||||
|
or len(self.subjects) > 64
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"E40 operator-review source binding is invalid"
|
||||||
|
)
|
||||||
|
if len({subject.item_id for subject in self.subjects}) != len(self.subjects):
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"E40 operator-review subjects are not unique"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
_ITEM_ID.fullmatch(subject.item_id) is None or subject.sequence < 0
|
||||||
|
for subject in self.subjects
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"E40 operator-review subject is invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_set_sha256(self) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
_canonical_json(
|
||||||
|
[
|
||||||
|
{"item_id": subject.item_id, "sequence": subject.sequence}
|
||||||
|
for subject in self.subjects
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
def binding(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"result_id": self.result_id,
|
||||||
|
"materialization_id": self.materialization_id,
|
||||||
|
"case_catalog_sha256": self.case_catalog_sha256,
|
||||||
|
"item_count": len(self.subjects),
|
||||||
|
"item_set_sha256": self.item_set_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_root(root: Path) -> Path:
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
if root.is_symlink() or not root.is_dir():
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator-review root is invalid"
|
||||||
|
)
|
||||||
|
return root.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewer_id(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if _REVIEWER_ID.fullmatch(value) is None:
|
||||||
|
raise E40OperatorReviewValidationError("reviewer_id is invalid")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _review_id(
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
) -> str:
|
||||||
|
identity = {
|
||||||
|
"protocol": E40_OPERATOR_REVIEW_PROTOCOL,
|
||||||
|
"source": substrate.binding(),
|
||||||
|
"reviewer_id": reviewer_id,
|
||||||
|
}
|
||||||
|
return f"e40-operator-review-{hashlib.sha256(_canonical_json(identity)).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _exclusive_lock(path: Path) -> Iterator[None]:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise E40OperatorReviewConflictError(
|
||||||
|
"E40 operator review is being updated"
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorReviewStore:
|
||||||
|
"""Mutable operator verdicts kept separate from immutable E40 evidence."""
|
||||||
|
|
||||||
|
def __init__(self, *, root: Path) -> None:
|
||||||
|
self.root = _safe_root(root)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
reviewer_id = _reviewer_id(reviewer_id)
|
||||||
|
review_id = _review_id(substrate, reviewer_id)
|
||||||
|
path = self.root / f"{review_id}.json"
|
||||||
|
if path.is_symlink():
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator review must not be a symlink"
|
||||||
|
)
|
||||||
|
if not path.exists():
|
||||||
|
return self._empty(
|
||||||
|
review_id=review_id,
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
)
|
||||||
|
return self._read(
|
||||||
|
path=path,
|
||||||
|
review_id=review_id,
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_verdict(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
item_id: str,
|
||||||
|
expected_revision: int,
|
||||||
|
idempotency_key: str,
|
||||||
|
verdict: E40OperatorVerdict,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
reviewer_id = _reviewer_id(reviewer_id)
|
||||||
|
if (
|
||||||
|
_ITEM_ID.fullmatch(item_id) is None
|
||||||
|
or item_id not in {subject.item_id for subject in substrate.subjects}
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"item_id is outside the E40 error catalog"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
_IDEMPOTENCY_KEY.fullmatch(idempotency_key) is None
|
||||||
|
or verdict not in _VERDICTS
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"E40 operator verdict request is invalid"
|
||||||
|
)
|
||||||
|
if expected_revision < 0:
|
||||||
|
raise E40OperatorReviewValidationError(
|
||||||
|
"expected_revision is invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
review_id = _review_id(substrate, reviewer_id)
|
||||||
|
path = self.root / f"{review_id}.json"
|
||||||
|
lock_path = self.root / ".locks" / f"{review_id}.lock"
|
||||||
|
with _exclusive_lock(lock_path):
|
||||||
|
current = self.get(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
)
|
||||||
|
decisions = list(current["decisions"]) # type: ignore[arg-type]
|
||||||
|
replay = next(
|
||||||
|
(
|
||||||
|
decision
|
||||||
|
for decision in decisions
|
||||||
|
if decision["idempotency_key"] == idempotency_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if replay is not None:
|
||||||
|
if (
|
||||||
|
replay["item_id"] != item_id
|
||||||
|
or replay["verdict"] != verdict
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewConflictError(
|
||||||
|
"idempotency key was already used for another verdict"
|
||||||
|
)
|
||||||
|
return current
|
||||||
|
if current["revision"] != expected_revision:
|
||||||
|
raise E40OperatorReviewConflictError(
|
||||||
|
"E40 operator-review revision changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
next_revision = expected_revision + 1
|
||||||
|
decided_at = utc_now_iso()
|
||||||
|
event_identity = {
|
||||||
|
"review_id": review_id,
|
||||||
|
"item_id": item_id,
|
||||||
|
"verdict": verdict,
|
||||||
|
"revision": next_revision,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"decided_at_utc": decided_at,
|
||||||
|
}
|
||||||
|
decision = {
|
||||||
|
"item_id": item_id,
|
||||||
|
"verdict": verdict,
|
||||||
|
"revision": next_revision,
|
||||||
|
"idempotency_key": idempotency_key,
|
||||||
|
"decided_at_utc": decided_at,
|
||||||
|
"event_id": (
|
||||||
|
"e40-operator-verdict-"
|
||||||
|
f"{hashlib.sha256(_canonical_json(event_identity)).hexdigest()}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
decisions = [
|
||||||
|
previous
|
||||||
|
for previous in decisions
|
||||||
|
if previous["item_id"] != item_id
|
||||||
|
]
|
||||||
|
decisions.append(decision)
|
||||||
|
subject_order = {
|
||||||
|
subject.item_id: index
|
||||||
|
for index, subject in enumerate(substrate.subjects)
|
||||||
|
}
|
||||||
|
decisions.sort(key=lambda value: subject_order[str(value["item_id"])])
|
||||||
|
payload = self._payload(
|
||||||
|
review_id=review_id,
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
revision=next_revision,
|
||||||
|
decisions=decisions,
|
||||||
|
updated_at_utc=decided_at,
|
||||||
|
)
|
||||||
|
write_json_atomic(path, payload)
|
||||||
|
path.chmod(0o600)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _empty(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
review_id: str,
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return self._payload(
|
||||||
|
review_id=review_id,
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
revision=0,
|
||||||
|
decisions=[],
|
||||||
|
updated_at_utc=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
review_id: str,
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
revision: int,
|
||||||
|
decisions: list[dict[str, object]],
|
||||||
|
updated_at_utc: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": E40_OPERATOR_REVIEW_SCHEMA,
|
||||||
|
"review_id": review_id,
|
||||||
|
"protocol": E40_OPERATOR_REVIEW_PROTOCOL,
|
||||||
|
"source": substrate.binding(),
|
||||||
|
"reviewer_id": reviewer_id,
|
||||||
|
"revision": revision,
|
||||||
|
"decisions": decisions,
|
||||||
|
"reviewed_item_count": len(decisions),
|
||||||
|
"remaining_item_count": len(substrate.subjects) - len(decisions),
|
||||||
|
"updated_at_utc": updated_at_utc,
|
||||||
|
"access": "review-write",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _read(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
path: Path,
|
||||||
|
review_id: str,
|
||||||
|
substrate: E40OperatorReviewSubstrate,
|
||||||
|
reviewer_id: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or not 0 < path.stat().st_size <= _MAX_JSON_BYTES
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator review is unavailable"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator review is invalid"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator review must be an object"
|
||||||
|
)
|
||||||
|
decisions = value.get("decisions")
|
||||||
|
revision = value.get("revision")
|
||||||
|
if (
|
||||||
|
value.get("schema_version") != E40_OPERATOR_REVIEW_SCHEMA
|
||||||
|
or value.get("review_id") != review_id
|
||||||
|
or value.get("protocol") != E40_OPERATOR_REVIEW_PROTOCOL
|
||||||
|
or value.get("source") != substrate.binding()
|
||||||
|
or value.get("reviewer_id") != reviewer_id
|
||||||
|
or value.get("access") != "review-write"
|
||||||
|
or isinstance(revision, bool)
|
||||||
|
or not isinstance(revision, int)
|
||||||
|
or revision < 0
|
||||||
|
or not isinstance(decisions, list)
|
||||||
|
or value.get("reviewed_item_count") != len(decisions)
|
||||||
|
or value.get("remaining_item_count")
|
||||||
|
!= len(substrate.subjects) - len(decisions)
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator review identity is invalid"
|
||||||
|
)
|
||||||
|
known_items = {subject.item_id for subject in substrate.subjects}
|
||||||
|
item_ids: set[str] = set()
|
||||||
|
idempotency_keys: set[str] = set()
|
||||||
|
for decision in decisions:
|
||||||
|
decided_at = decision.get("decided_at_utc") if isinstance(
|
||||||
|
decision, dict
|
||||||
|
) else None
|
||||||
|
event_identity = {
|
||||||
|
"review_id": review_id,
|
||||||
|
"item_id": decision.get("item_id") if isinstance(
|
||||||
|
decision, dict
|
||||||
|
) else None,
|
||||||
|
"verdict": decision.get("verdict") if isinstance(
|
||||||
|
decision, dict
|
||||||
|
) else None,
|
||||||
|
"revision": decision.get("revision") if isinstance(
|
||||||
|
decision, dict
|
||||||
|
) else None,
|
||||||
|
"idempotency_key": decision.get("idempotency_key") if isinstance(
|
||||||
|
decision, dict
|
||||||
|
) else None,
|
||||||
|
"decided_at_utc": decided_at,
|
||||||
|
}
|
||||||
|
expected_event_id = (
|
||||||
|
"e40-operator-verdict-"
|
||||||
|
f"{hashlib.sha256(_canonical_json(event_identity)).hexdigest()}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not isinstance(decision, dict)
|
||||||
|
or decision.get("item_id") not in known_items
|
||||||
|
or decision.get("item_id") in item_ids
|
||||||
|
or decision.get("verdict") not in _VERDICTS
|
||||||
|
or not isinstance(decision.get("revision"), int)
|
||||||
|
or not 1 <= int(decision["revision"]) <= revision
|
||||||
|
or not isinstance(decision.get("decided_at_utc"), str)
|
||||||
|
or _EVENT_ID.fullmatch(str(decision.get("event_id"))) is None
|
||||||
|
or decision.get("event_id") != expected_event_id
|
||||||
|
or _IDEMPOTENCY_KEY.fullmatch(
|
||||||
|
str(decision.get("idempotency_key"))
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
or decision.get("idempotency_key") in idempotency_keys
|
||||||
|
):
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator verdict is invalid"
|
||||||
|
)
|
||||||
|
item_ids.add(str(decision["item_id"]))
|
||||||
|
idempotency_keys.add(str(decision["idempotency_key"]))
|
||||||
|
if decisions and max(
|
||||||
|
int(decision["revision"]) for decision in decisions
|
||||||
|
) != revision:
|
||||||
|
raise E40OperatorReviewIntegrityError(
|
||||||
|
"E40 operator-review revision is invalid"
|
||||||
|
)
|
||||||
|
return value
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -58,6 +59,10 @@ from k1link.compute.e40_perception_product_gate import (
|
|||||||
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
||||||
"missioncore.laboratory-advanced-catalog/v1"
|
"missioncore.laboratory-advanced-catalog/v1"
|
||||||
)
|
)
|
||||||
|
LABORATORY_ADVANCED_INDEX_SCHEMA: Final = (
|
||||||
|
"missioncore.laboratory-advanced-index/v1"
|
||||||
|
)
|
||||||
|
_INDEX_DOCUMENT_MAX_BYTES: Final = 64 * 1024
|
||||||
|
|
||||||
_E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$")
|
_E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$")
|
||||||
_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$")
|
_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$")
|
||||||
@@ -71,6 +76,14 @@ _E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
|||||||
|
|
||||||
RootProvider = Callable[[], Path | None]
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
_AdvancedIndexSpec = tuple[
|
||||||
|
str,
|
||||||
|
RootProvider,
|
||||||
|
re.Pattern[str],
|
||||||
|
str,
|
||||||
|
str,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||||
signature: list[int] = []
|
signature: list[int] = []
|
||||||
@@ -193,6 +206,82 @@ def _candidates(root: Path, pattern: re.Pattern[str]) -> list[Path]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _advanced_index_item(
|
||||||
|
candidate: Path,
|
||||||
|
*,
|
||||||
|
work_id: str,
|
||||||
|
document_name: str,
|
||||||
|
schema_version: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
document_path = candidate / document_name
|
||||||
|
if document_path.is_symlink() or not document_path.is_file():
|
||||||
|
raise ValueError("advanced LAB index document is missing")
|
||||||
|
if document_path.stat().st_size > _INDEX_DOCUMENT_MAX_BYTES:
|
||||||
|
raise ValueError("advanced LAB index document is too large")
|
||||||
|
payload = json.loads(document_path.read_text(encoding="utf-8"))
|
||||||
|
document = _object(payload, "advanced LAB index document")
|
||||||
|
if document.get("schema_version") != schema_version:
|
||||||
|
raise ValueError("advanced LAB index schema is invalid")
|
||||||
|
if document.get("result_id") != candidate.name:
|
||||||
|
raise ValueError("advanced LAB index result identity is invalid")
|
||||||
|
identity_sha256 = document.get("identity_sha256")
|
||||||
|
if (
|
||||||
|
not isinstance(identity_sha256, str)
|
||||||
|
or re.fullmatch(r"[a-f0-9]{64}", identity_sha256) is None
|
||||||
|
or not candidate.name.endswith(identity_sha256)
|
||||||
|
):
|
||||||
|
raise ValueError("advanced LAB index digest is invalid")
|
||||||
|
identity = _object(document.get("identity"), "advanced LAB identity")
|
||||||
|
authority = _object(
|
||||||
|
identity.get("authority"),
|
||||||
|
"advanced LAB authority",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
authority.get("commands_enabled") is not False
|
||||||
|
or authority.get("navigation_or_safety_accepted") is not False
|
||||||
|
):
|
||||||
|
raise ValueError("advanced LAB authority is invalid")
|
||||||
|
if document.get("ground_truth") not in (None, False):
|
||||||
|
raise ValueError("advanced LAB ground-truth claim is invalid")
|
||||||
|
created_at_utc = document.get("created_at_utc")
|
||||||
|
if not isinstance(created_at_utc, str) or not created_at_utc.strip():
|
||||||
|
raise ValueError("advanced LAB creation time is invalid")
|
||||||
|
return {
|
||||||
|
"work_id": work_id,
|
||||||
|
"result_id": candidate.name,
|
||||||
|
"created_at_utc": created_at_utc,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _advanced_index(
|
||||||
|
specs: tuple[_AdvancedIndexSpec, ...],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
for work_id, provider, pattern, document_name, schema_version in specs:
|
||||||
|
root = _configured_root(provider)
|
||||||
|
if root is None:
|
||||||
|
continue
|
||||||
|
for candidate in _candidates(root, pattern):
|
||||||
|
try:
|
||||||
|
items.append(
|
||||||
|
_advanced_index_item(
|
||||||
|
candidate,
|
||||||
|
work_id=work_id,
|
||||||
|
document_name=document_name,
|
||||||
|
schema_version=schema_version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_ADVANCED_INDEX_SCHEMA,
|
||||||
|
"items": items,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, label: str) -> dict[str, Any]:
|
def _object(value: object, label: str) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise ValueError(f"{label} is invalid")
|
raise ValueError(f"{label} is invalid")
|
||||||
@@ -739,6 +828,76 @@ def build_advanced_laboratory_router(
|
|||||||
) -> APIRouter:
|
) -> APIRouter:
|
||||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||||
|
|
||||||
|
@router.get("/advanced-index")
|
||||||
|
def list_advanced_results() -> dict[str, object]:
|
||||||
|
return _advanced_index(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
"e31-source-binding",
|
||||||
|
e31_root_provider,
|
||||||
|
_E31_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e31-source-qualification/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e32-track-geometry",
|
||||||
|
e32_root_provider,
|
||||||
|
_E32_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e32-track-geometry-replay/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e33-worker-shadow",
|
||||||
|
e33_root_provider,
|
||||||
|
_E33_RESULT_ID,
|
||||||
|
"result.json",
|
||||||
|
"missioncore.e33-worker-shadow-result/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e34-temporal-layer",
|
||||||
|
e34_root_provider,
|
||||||
|
_E34_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e34-temporal-occupied-result/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e35-degradation-recovery",
|
||||||
|
e35_root_provider,
|
||||||
|
_E35_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e35-degradation-result/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e37-ravnoves-acceptance",
|
||||||
|
e37_root_provider,
|
||||||
|
_E37_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e37-acceptance-contract/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e38-perception-baseline",
|
||||||
|
e38_root_provider,
|
||||||
|
_E38_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e38-perception-baseline/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e39-perception-refinement",
|
||||||
|
e39_root_provider,
|
||||||
|
_E39_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e39-perception-refinement/v1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"e40-perception-product-gate",
|
||||||
|
e40_root_provider,
|
||||||
|
_E40_RESULT_ID,
|
||||||
|
"manifest.json",
|
||||||
|
"missioncore.e40-perception-product-gate/v1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/e31/results")
|
@router.get("/e31/results")
|
||||||
def list_e31_results(
|
def list_e31_results(
|
||||||
limit: int = Query(default=1, ge=1, le=10),
|
limit: int = Query(default=1, ge=1, le=10),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from k1link.web.device_plugin_composition import load_installed_device_plugins
|
|||||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||||
from k1link.web.e30_review_api import build_e30_review_router
|
from k1link.web.e30_review_api import build_e30_review_router
|
||||||
|
from k1link.web.e40_case_review_api import build_e40_case_review_router
|
||||||
from k1link.web.environment_api import build_environment_router
|
from k1link.web.environment_api import build_environment_router
|
||||||
from k1link.web.laboratory_api import build_laboratory_router
|
from k1link.web.laboratory_api import build_laboratory_router
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
@@ -614,6 +615,24 @@ app.include_router(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
build_e40_case_review_router(
|
||||||
|
e40_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "e40"
|
||||||
|
/ "results"
|
||||||
|
),
|
||||||
|
operator_review_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "e40"
|
||||||
|
/ "operator-reviews"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
build_e30_engineering_router(
|
build_e30_engineering_router(
|
||||||
generation_root_provider=lambda: (
|
generation_root_provider=lambda: (
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Annotated, Any, Final, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from fastapi import Path as ApiPath
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from k1link.compute.e40_operator_review import (
|
||||||
|
E40OperatorReviewConflictError,
|
||||||
|
E40OperatorReviewIntegrityError,
|
||||||
|
E40OperatorReviewStore,
|
||||||
|
E40OperatorReviewSubject,
|
||||||
|
E40OperatorReviewSubstrate,
|
||||||
|
E40OperatorReviewValidationError,
|
||||||
|
)
|
||||||
|
from k1link.compute.e40_perception_product_gate import (
|
||||||
|
E40_PREDICTION_SCHEMA,
|
||||||
|
E40_PREDICTIONS_NAME,
|
||||||
|
E40PerceptionProductGate,
|
||||||
|
E40PerceptionProductGateError,
|
||||||
|
read_e40_perception_product_gate,
|
||||||
|
)
|
||||||
|
|
||||||
|
LABORATORY_E40_CASE_CATALOG_SCHEMA: Final = (
|
||||||
|
"missioncore.laboratory-e40-case-catalog/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||||
|
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||||
|
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||||
|
_MAX_PREDICTIONS_BYTES: Final = 8 * 1024 * 1024
|
||||||
|
_AUTHORITY: Final = {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
_DIMENSIONS: Final = (
|
||||||
|
"presence",
|
||||||
|
"geometry_association",
|
||||||
|
"freshness",
|
||||||
|
)
|
||||||
|
_STRATA: Final = {
|
||||||
|
"agree",
|
||||||
|
"camera-only",
|
||||||
|
"conflict",
|
||||||
|
"geometry-only",
|
||||||
|
"unknown",
|
||||||
|
}
|
||||||
|
_SEVERITIES: Final = {"high", "medium", "standard"}
|
||||||
|
_SPLITS: Final = {"development", "validation"}
|
||||||
|
_VALUES: Final = {
|
||||||
|
"presence": {
|
||||||
|
"background-or-noise",
|
||||||
|
"object-present",
|
||||||
|
"occupied-environment",
|
||||||
|
},
|
||||||
|
"geometry_association": {
|
||||||
|
"independent-occupied",
|
||||||
|
"insufficient-support",
|
||||||
|
"object-associated",
|
||||||
|
"rejected-nonobject",
|
||||||
|
"unknown",
|
||||||
|
},
|
||||||
|
"freshness": {"current", "stale", "unavailable"},
|
||||||
|
}
|
||||||
|
_SEVERITY_ORDER: Final = {"high": 0, "medium": 1, "standard": 2}
|
||||||
|
|
||||||
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
|
||||||
|
class E40OperatorVerdictRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
reviewer_id: str = Field(min_length=1, max_length=128)
|
||||||
|
expected_revision: int = Field(ge=0)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=128)
|
||||||
|
verdict: Literal["confirmed-error", "rejected-error"]
|
||||||
|
|
||||||
|
|
||||||
|
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||||
|
signature: list[int] = []
|
||||||
|
for path in sorted(root.iterdir(), key=lambda item: item.name):
|
||||||
|
if not path.is_file() or path.is_symlink():
|
||||||
|
continue
|
||||||
|
stat = path.stat()
|
||||||
|
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||||
|
return tuple(signature)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8)
|
||||||
|
def _read_result_cached(
|
||||||
|
root_text: str,
|
||||||
|
signature: tuple[int, ...],
|
||||||
|
) -> E40PerceptionProductGate:
|
||||||
|
del signature
|
||||||
|
return read_e40_perception_product_gate(Path(root_text))
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_root(provider: RootProvider) -> Path | None:
|
||||||
|
root = provider()
|
||||||
|
if root is None:
|
||||||
|
return None
|
||||||
|
resolved = root.resolve()
|
||||||
|
return resolved if resolved.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(root: Path, result_id: str) -> Path:
|
||||||
|
if _RESULT_ID.fullmatch(result_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||||
|
candidate = root / result_id
|
||||||
|
if candidate.is_symlink() or not candidate.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: object, label: str) -> dict[str, Any]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise E40PerceptionProductGateError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _nonnegative_integer(value: object, label: str) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
f"{label} must be a nonnegative integer"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_number(value: object, label: str) -> float:
|
||||||
|
if (
|
||||||
|
isinstance(value, bool)
|
||||||
|
or not isinstance(value, (int, float))
|
||||||
|
or not 0 <= float(value) <= 1
|
||||||
|
):
|
||||||
|
raise E40PerceptionProductGateError(f"{label} must be within [0, 1]")
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _dimension_state(value: object, label: str) -> dict[str, str]:
|
||||||
|
source = _object(value, label)
|
||||||
|
if set(source) != set(_DIMENSIONS):
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
f"{label} has incompatible dimensions"
|
||||||
|
)
|
||||||
|
state: dict[str, str] = {}
|
||||||
|
for dimension in _DIMENSIONS:
|
||||||
|
item = source.get(dimension)
|
||||||
|
if not isinstance(item, str) or item not in _VALUES[dimension]:
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
f"{label}.{dimension} is invalid"
|
||||||
|
)
|
||||||
|
state[dimension] = item
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _read_predictions(result: E40PerceptionProductGate) -> tuple[dict[str, Any], ...]:
|
||||||
|
path = result.result_root / E40_PREDICTIONS_NAME
|
||||||
|
if (
|
||||||
|
not path.is_file()
|
||||||
|
or path.is_symlink()
|
||||||
|
or path.stat().st_size > _MAX_PREDICTIONS_BYTES
|
||||||
|
):
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 sealed predictions are unavailable"
|
||||||
|
)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
sequences: set[int] = set()
|
||||||
|
item_ids: set[str] = set()
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8-sig") as stream:
|
||||||
|
for line in stream:
|
||||||
|
row = _object(json.loads(line), E40_PREDICTIONS_NAME)
|
||||||
|
sequence = _nonnegative_integer(
|
||||||
|
row.get("sequence"),
|
||||||
|
"E40 prediction.sequence",
|
||||||
|
)
|
||||||
|
item_id = row.get("item_id")
|
||||||
|
review_key = row.get("review_key")
|
||||||
|
source_frame_index = _nonnegative_integer(
|
||||||
|
row.get("source_frame_index"),
|
||||||
|
"E40 prediction.source_frame_index",
|
||||||
|
)
|
||||||
|
stratum = row.get("source_stratum")
|
||||||
|
severity = row.get("severity")
|
||||||
|
split = row.get("split")
|
||||||
|
if (
|
||||||
|
row.get("schema_version") != E40_PREDICTION_SCHEMA
|
||||||
|
or not isinstance(item_id, str)
|
||||||
|
or _ITEM_ID.fullmatch(item_id) is None
|
||||||
|
or not isinstance(review_key, str)
|
||||||
|
or not review_key
|
||||||
|
or stratum not in _STRATA
|
||||||
|
or severity not in _SEVERITIES
|
||||||
|
or split not in _SPLITS
|
||||||
|
or row.get("scored") is not (split == "validation")
|
||||||
|
or row.get("authority") != _AUTHORITY
|
||||||
|
or sequence in sequences
|
||||||
|
or item_id in item_ids
|
||||||
|
):
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 prediction identity is invalid"
|
||||||
|
)
|
||||||
|
prediction = _dimension_state(
|
||||||
|
row.get("prediction"),
|
||||||
|
"E40 prediction.prediction",
|
||||||
|
)
|
||||||
|
reference = _dimension_state(
|
||||||
|
row.get("reference"),
|
||||||
|
"E40 prediction.reference",
|
||||||
|
)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"sequence": sequence,
|
||||||
|
"item_id": item_id,
|
||||||
|
"review_key": review_key,
|
||||||
|
"source_frame_index": source_frame_index,
|
||||||
|
"source_stratum": stratum,
|
||||||
|
"severity": severity,
|
||||||
|
"split": split,
|
||||||
|
"prediction": prediction,
|
||||||
|
"reference": reference,
|
||||||
|
"presence_confidence": _bounded_number(
|
||||||
|
row.get("presence_confidence"),
|
||||||
|
"E40 prediction.presence_confidence",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sequences.add(sequence)
|
||||||
|
item_ids.add(item_id)
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 sealed predictions are invalid"
|
||||||
|
) from exc
|
||||||
|
validation_total = _object(
|
||||||
|
result.report.get("metrics"),
|
||||||
|
"E40 metrics",
|
||||||
|
).get("validation_items")
|
||||||
|
if (
|
||||||
|
not rows
|
||||||
|
or _nonnegative_integer(validation_total, "E40 validation_items")
|
||||||
|
!= sum(row["split"] == "validation" for row in rows)
|
||||||
|
):
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 sealed validation denominator changed"
|
||||||
|
)
|
||||||
|
return tuple(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialization_id(result: E40PerceptionProductGate) -> str:
|
||||||
|
identity = _object(result.manifest.get("identity"), "E40 identity")
|
||||||
|
source = _object(identity.get("source"), "E40 source")
|
||||||
|
value = source.get("materialization_id")
|
||||||
|
if not isinstance(value, str) or _MATERIALIZATION_ID.fullmatch(value) is None:
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 materialization binding is invalid"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _case(row: dict[str, Any]) -> dict[str, object]:
|
||||||
|
reference = _object(row["reference"], "E40 reference")
|
||||||
|
prediction = _object(row["prediction"], "E40 prediction")
|
||||||
|
source_stratum = str(row["source_stratum"])
|
||||||
|
mismatched = [
|
||||||
|
dimension
|
||||||
|
for dimension in _DIMENSIONS
|
||||||
|
if reference[dimension] != prediction[dimension]
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"item_id": row["item_id"],
|
||||||
|
"sequence": row["sequence"],
|
||||||
|
"source_frame_index": row["source_frame_index"],
|
||||||
|
"source_stratum": source_stratum,
|
||||||
|
"severity": row["severity"],
|
||||||
|
"presence_confidence": row["presence_confidence"],
|
||||||
|
"prediction_basis": (
|
||||||
|
"camera-only-softmax"
|
||||||
|
if source_stratum == "camera-only"
|
||||||
|
else "fixed-stratum-policy"
|
||||||
|
),
|
||||||
|
"reference": dict(reference),
|
||||||
|
"prediction": dict(prediction),
|
||||||
|
"mismatched_dimensions": mismatched,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_catalog(
|
||||||
|
root_provider: RootProvider,
|
||||||
|
result_id: str,
|
||||||
|
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||||
|
root = _configured_root(root_provider)
|
||||||
|
if root is None:
|
||||||
|
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||||
|
candidate = _candidate(root, result_id)
|
||||||
|
result = _read_result_cached(
|
||||||
|
str(candidate.resolve()),
|
||||||
|
_result_signature(candidate),
|
||||||
|
)
|
||||||
|
rows = _read_predictions(result)
|
||||||
|
errors = [
|
||||||
|
row
|
||||||
|
for row in rows
|
||||||
|
if row["split"] == "validation"
|
||||||
|
and any(
|
||||||
|
row["reference"][dimension] != row["prediction"][dimension]
|
||||||
|
for dimension in _DIMENSIONS
|
||||||
|
)
|
||||||
|
]
|
||||||
|
errors.sort(
|
||||||
|
key=lambda row: (
|
||||||
|
_SEVERITY_ORDER[str(row["severity"])],
|
||||||
|
int(row["sequence"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not errors or len(errors) > 64:
|
||||||
|
raise E40PerceptionProductGateError(
|
||||||
|
"E40 error catalog is outside the review boundary"
|
||||||
|
)
|
||||||
|
materialization_id = _materialization_id(result)
|
||||||
|
cases = [_case(row) for row in errors]
|
||||||
|
case_catalog_sha256 = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"materialization_id": materialization_id,
|
||||||
|
"items": cases,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
catalog: dict[str, object] = {
|
||||||
|
"schema_version": LABORATORY_E40_CASE_CATALOG_SCHEMA,
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"materialization_id": materialization_id,
|
||||||
|
"items": cases,
|
||||||
|
"total": len(errors),
|
||||||
|
"truncated": False,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
substrate = E40OperatorReviewSubstrate(
|
||||||
|
result_id=result.result_id,
|
||||||
|
materialization_id=materialization_id,
|
||||||
|
case_catalog_sha256=case_catalog_sha256,
|
||||||
|
subjects=tuple(
|
||||||
|
E40OperatorReviewSubject(
|
||||||
|
item_id=str(row["item_id"]),
|
||||||
|
sequence=int(row["sequence"]),
|
||||||
|
)
|
||||||
|
for row in errors
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return catalog, substrate
|
||||||
|
|
||||||
|
|
||||||
|
def build_e40_case_review_router(
|
||||||
|
*,
|
||||||
|
e40_root_provider: RootProvider = lambda: None,
|
||||||
|
operator_review_root_provider: RootProvider = lambda: None,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/v1/laboratory/e40", tags=["laboratory"])
|
||||||
|
|
||||||
|
def catalog_and_source(
|
||||||
|
result_id: str,
|
||||||
|
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||||
|
try:
|
||||||
|
return _load_catalog(e40_root_provider, result_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (
|
||||||
|
E40PerceptionProductGateError,
|
||||||
|
E40OperatorReviewValidationError,
|
||||||
|
KeyError,
|
||||||
|
OSError,
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="E40 case-review не прошёл проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def store() -> E40OperatorReviewStore:
|
||||||
|
root = operator_review_root_provider()
|
||||||
|
if root is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="E40 operator review не настроен",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return E40OperatorReviewStore(root=root)
|
||||||
|
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="E40 operator review storage недоступен",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def invoke(
|
||||||
|
operation: Callable[[], dict[str, object]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
return operation()
|
||||||
|
except E40OperatorReviewValidationError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except E40OperatorReviewConflictError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="E40 operator review не прошёл проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/cases")
|
||||||
|
def list_e40_error_cases(
|
||||||
|
result_id: str,
|
||||||
|
limit: int = Query(default=48, ge=1, le=64),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
catalog, _ = catalog_and_source(result_id)
|
||||||
|
items = list(catalog["items"]) # type: ignore[arg-type]
|
||||||
|
return {
|
||||||
|
**catalog,
|
||||||
|
"items": items[:limit],
|
||||||
|
"truncated": len(items) > limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/results/{result_id}/operator-review")
|
||||||
|
def get_operator_review(
|
||||||
|
result_id: str,
|
||||||
|
reviewer_id: str = Query(
|
||||||
|
default="DC",
|
||||||
|
min_length=1,
|
||||||
|
max_length=128,
|
||||||
|
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$",
|
||||||
|
),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_, substrate = catalog_and_source(result_id)
|
||||||
|
review_store = store()
|
||||||
|
return invoke(
|
||||||
|
lambda: review_store.get(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=reviewer_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/results/{result_id}/operator-review/decisions/{item_id}"
|
||||||
|
)
|
||||||
|
def record_operator_verdict(
|
||||||
|
result_id: str,
|
||||||
|
item_id: Annotated[
|
||||||
|
str,
|
||||||
|
ApiPath(pattern=r"^e30-review-item-[a-f0-9]{64}$"),
|
||||||
|
],
|
||||||
|
request: E40OperatorVerdictRequest,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_, substrate = catalog_and_source(result_id)
|
||||||
|
review_store = store()
|
||||||
|
return invoke(
|
||||||
|
lambda: review_store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id=request.reviewer_id,
|
||||||
|
item_id=item_id,
|
||||||
|
expected_revision=request.expected_revision,
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
verdict=request.verdict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@@ -41,6 +42,93 @@ def test_advanced_catalogs_are_empty_when_not_configured() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_index_is_empty_when_not_configured() -> None:
|
||||||
|
router = build_advanced_laboratory_router()
|
||||||
|
route = _endpoint(router, "/api/v1/laboratory/advanced-index")
|
||||||
|
|
||||||
|
assert route() == { # type: ignore[operator]
|
||||||
|
"schema_version": "missioncore.laboratory-advanced-index/v1",
|
||||||
|
"items": [],
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_index_reads_only_bounded_identity_documents(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
e31 = tmp_path / "e31"
|
||||||
|
invalid = e31 / f"e31-source-qualification-{'1' * 64}"
|
||||||
|
valid = e31 / f"e31-source-qualification-{'2' * 64}"
|
||||||
|
invalid.mkdir(parents=True)
|
||||||
|
valid.mkdir()
|
||||||
|
(invalid / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
|
(valid / "manifest.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.e31-source-qualification/v1",
|
||||||
|
"result_id": valid.name,
|
||||||
|
"created_at_utc": "2026-07-27T10:00:00Z",
|
||||||
|
"identity_sha256": "2" * 64,
|
||||||
|
"identity": {
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
router = build_advanced_laboratory_router(
|
||||||
|
e31_root_provider=lambda: e31,
|
||||||
|
)
|
||||||
|
route = _endpoint(router, "/api/v1/laboratory/advanced-index")
|
||||||
|
|
||||||
|
assert route() == { # type: ignore[operator]
|
||||||
|
"schema_version": "missioncore.laboratory-advanced-index/v1",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"work_id": "e31-source-binding",
|
||||||
|
"result_id": valid.name,
|
||||||
|
"created_at_utc": "2026-07-27T10:00:00Z",
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_index_rejects_authority_escalation(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "e40"
|
||||||
|
candidate = root / f"e40-perception-product-gate-{'a' * 64}"
|
||||||
|
candidate.mkdir(parents=True)
|
||||||
|
(candidate / "manifest.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.e40-perception-product-gate/v1",
|
||||||
|
"result_id": candidate.name,
|
||||||
|
"created_at_utc": "2026-07-28T11:00:00Z",
|
||||||
|
"identity_sha256": "a" * 64,
|
||||||
|
"identity": {
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": True,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
router = build_advanced_laboratory_router(
|
||||||
|
e40_root_provider=lambda: root,
|
||||||
|
)
|
||||||
|
route = _endpoint(router, "/api/v1/laboratory/advanced-index")
|
||||||
|
|
||||||
|
assert route()["items"] == [] # type: ignore[index,operator]
|
||||||
|
|
||||||
|
|
||||||
def test_advanced_catalogs_fail_closed_on_incomplete_results(
|
def test_advanced_catalogs_fail_closed_on_incomplete_results(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from pytest import MonkeyPatch
|
||||||
|
|
||||||
|
import k1link.web.e40_case_review_api as case_api
|
||||||
|
from k1link.web.e40_case_review_api import build_e40_case_review_router
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(router: APIRouter, path: str) -> object:
|
||||||
|
for route in router.routes:
|
||||||
|
if (
|
||||||
|
isinstance(route, APIRoute)
|
||||||
|
and route.path == path
|
||||||
|
and route.methods is not None
|
||||||
|
and "GET" in route.methods
|
||||||
|
):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"GET {path} route is missing")
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction(
|
||||||
|
*,
|
||||||
|
sequence: int,
|
||||||
|
severity: str,
|
||||||
|
split: str = "validation",
|
||||||
|
presence: str = "object-present",
|
||||||
|
predicted_presence: str = "occupied-environment",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.e40-perception-prediction/v1",
|
||||||
|
"sequence": sequence,
|
||||||
|
"item_id": f"e30-review-item-{sequence:064x}",
|
||||||
|
"review_key": f"geometry:{sequence}:0",
|
||||||
|
"source_frame_index": 100 + sequence,
|
||||||
|
"source_stratum": "geometry-only",
|
||||||
|
"severity": severity,
|
||||||
|
"split": split,
|
||||||
|
"prediction": {
|
||||||
|
"presence": predicted_presence,
|
||||||
|
"geometry_association": "independent-occupied",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"presence_confidence": 1.0,
|
||||||
|
"reference": {
|
||||||
|
"presence": presence,
|
||||||
|
"geometry_association": "object-associated",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"scored": split == "validation",
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_e40_case_review_is_bounded_sorted_and_source_bound(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
result_id = f"e40-perception-product-gate-{'a' * 64}"
|
||||||
|
materialization_id = f"e30-materialization-{'b' * 64}"
|
||||||
|
candidate = tmp_path / result_id
|
||||||
|
candidate.mkdir()
|
||||||
|
rows = [
|
||||||
|
_prediction(sequence=7, severity="medium"),
|
||||||
|
_prediction(
|
||||||
|
sequence=1,
|
||||||
|
severity="standard",
|
||||||
|
presence="object-present",
|
||||||
|
predicted_presence="object-present",
|
||||||
|
),
|
||||||
|
_prediction(sequence=4, severity="high"),
|
||||||
|
_prediction(sequence=2, severity="high"),
|
||||||
|
_prediction(sequence=9, severity="standard", split="development"),
|
||||||
|
]
|
||||||
|
rows[1]["prediction"]["geometry_association"] = "object-associated"
|
||||||
|
predictions = candidate / "predictions.jsonl"
|
||||||
|
predictions.write_text(
|
||||||
|
"".join(json.dumps(row) + "\n" for row in rows),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(candidate / "manifest.json").write_text("{}", encoding="utf-8")
|
||||||
|
result = SimpleNamespace(
|
||||||
|
result_id=result_id,
|
||||||
|
result_root=candidate,
|
||||||
|
manifest={
|
||||||
|
"identity": {
|
||||||
|
"source": {"materialization_id": materialization_id},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
report={"metrics": {"validation_items": 4}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fake_read(
|
||||||
|
root_text: str,
|
||||||
|
signature: tuple[int, ...],
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
assert root_text == str(candidate.resolve())
|
||||||
|
assert signature
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(case_api, "_read_result_cached", fake_read)
|
||||||
|
router = build_e40_case_review_router(
|
||||||
|
e40_root_provider=lambda: tmp_path,
|
||||||
|
)
|
||||||
|
route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/laboratory/e40/results/{result_id}/cases",
|
||||||
|
)
|
||||||
|
catalog = route(result_id=result_id, limit=2) # type: ignore[operator]
|
||||||
|
|
||||||
|
assert catalog == {
|
||||||
|
"schema_version": "missioncore.laboratory-e40-case-catalog/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"materialization_id": materialization_id,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"item_id": f"e30-review-item-{2:064x}",
|
||||||
|
"sequence": 2,
|
||||||
|
"source_frame_index": 102,
|
||||||
|
"source_stratum": "geometry-only",
|
||||||
|
"severity": "high",
|
||||||
|
"presence_confidence": 1.0,
|
||||||
|
"prediction_basis": "fixed-stratum-policy",
|
||||||
|
"reference": {
|
||||||
|
"presence": "object-present",
|
||||||
|
"geometry_association": "object-associated",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"prediction": {
|
||||||
|
"presence": "occupied-environment",
|
||||||
|
"geometry_association": "independent-occupied",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"mismatched_dimensions": [
|
||||||
|
"presence",
|
||||||
|
"geometry_association",
|
||||||
|
],
|
||||||
|
"access": "read-only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"item_id": f"e30-review-item-{4:064x}",
|
||||||
|
"sequence": 4,
|
||||||
|
"source_frame_index": 104,
|
||||||
|
"source_stratum": "geometry-only",
|
||||||
|
"severity": "high",
|
||||||
|
"presence_confidence": 1.0,
|
||||||
|
"prediction_basis": "fixed-stratum-policy",
|
||||||
|
"reference": {
|
||||||
|
"presence": "object-present",
|
||||||
|
"geometry_association": "object-associated",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"prediction": {
|
||||||
|
"presence": "occupied-environment",
|
||||||
|
"geometry_association": "independent-occupied",
|
||||||
|
"freshness": "current",
|
||||||
|
},
|
||||||
|
"mismatched_dimensions": [
|
||||||
|
"presence",
|
||||||
|
"geometry_association",
|
||||||
|
],
|
||||||
|
"access": "read-only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"total": 3,
|
||||||
|
"truncated": True,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.compute.e40_operator_review import (
|
||||||
|
E40OperatorReviewConflictError,
|
||||||
|
E40OperatorReviewStore,
|
||||||
|
E40OperatorReviewSubject,
|
||||||
|
E40OperatorReviewSubstrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _substrate() -> E40OperatorReviewSubstrate:
|
||||||
|
return E40OperatorReviewSubstrate(
|
||||||
|
result_id=f"e40-perception-product-gate-{'a' * 64}",
|
||||||
|
materialization_id=f"e30-materialization-{'b' * 64}",
|
||||||
|
case_catalog_sha256="c" * 64,
|
||||||
|
subjects=(
|
||||||
|
E40OperatorReviewSubject(
|
||||||
|
item_id=f"e30-review-item-{'d' * 64}",
|
||||||
|
sequence=2,
|
||||||
|
),
|
||||||
|
E40OperatorReviewSubject(
|
||||||
|
item_id=f"e30-review-item-{'e' * 64}",
|
||||||
|
sequence=9,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_e40_operator_verdict_is_atomic_resumable_and_revisioned(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = E40OperatorReviewStore(root=tmp_path)
|
||||||
|
substrate = _substrate()
|
||||||
|
first_item = substrate.subjects[0].item_id
|
||||||
|
|
||||||
|
empty = store.get(substrate=substrate, reviewer_id="DC")
|
||||||
|
assert empty["revision"] == 0
|
||||||
|
assert empty["reviewed_item_count"] == 0
|
||||||
|
assert empty["remaining_item_count"] == 2
|
||||||
|
|
||||||
|
saved = store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
item_id=first_item,
|
||||||
|
expected_revision=0,
|
||||||
|
idempotency_key="ui-first",
|
||||||
|
verdict="confirmed-error",
|
||||||
|
)
|
||||||
|
assert saved["revision"] == 1
|
||||||
|
assert saved["reviewed_item_count"] == 1
|
||||||
|
assert saved["decisions"][0]["verdict"] == "confirmed-error" # type: ignore[index]
|
||||||
|
|
||||||
|
resumed = E40OperatorReviewStore(root=tmp_path).get(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
)
|
||||||
|
assert resumed == saved
|
||||||
|
|
||||||
|
replay = store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
item_id=first_item,
|
||||||
|
expected_revision=0,
|
||||||
|
idempotency_key="ui-first",
|
||||||
|
verdict="confirmed-error",
|
||||||
|
)
|
||||||
|
assert replay == saved
|
||||||
|
|
||||||
|
revised = store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
item_id=first_item,
|
||||||
|
expected_revision=1,
|
||||||
|
idempotency_key="ui-revise",
|
||||||
|
verdict="rejected-error",
|
||||||
|
)
|
||||||
|
assert revised["revision"] == 2
|
||||||
|
assert revised["reviewed_item_count"] == 1
|
||||||
|
assert revised["decisions"][0]["verdict"] == "rejected-error" # type: ignore[index]
|
||||||
|
|
||||||
|
with pytest.raises(E40OperatorReviewConflictError):
|
||||||
|
store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
item_id=substrate.subjects[1].item_id,
|
||||||
|
expected_revision=1,
|
||||||
|
idempotency_key="ui-stale",
|
||||||
|
verdict="confirmed-error",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_e40_operator_review_is_bound_to_exact_case_catalog(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
store = E40OperatorReviewStore(root=tmp_path)
|
||||||
|
substrate = _substrate()
|
||||||
|
store.record_verdict(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
item_id=substrate.subjects[0].item_id,
|
||||||
|
expected_revision=0,
|
||||||
|
idempotency_key="ui-bound",
|
||||||
|
verdict="confirmed-error",
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = E40OperatorReviewSubstrate(
|
||||||
|
result_id=substrate.result_id,
|
||||||
|
materialization_id=substrate.materialization_id,
|
||||||
|
case_catalog_sha256="f" * 64,
|
||||||
|
subjects=substrate.subjects,
|
||||||
|
)
|
||||||
|
changed_review = store.get(substrate=changed, reviewer_id="DC")
|
||||||
|
|
||||||
|
assert changed_review["revision"] == 0
|
||||||
|
assert changed_review["review_id"] != store.get(
|
||||||
|
substrate=substrate,
|
||||||
|
reviewer_id="DC",
|
||||||
|
)["review_id"]
|
||||||
Reference in New Issue
Block a user