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,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
@@ -402,7 +402,7 @@ function parseCatalog<T>(
|
||||
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 metrics = record(item.metrics, "E31.metrics");
|
||||
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 metrics = record(item.metrics, "E32.metrics");
|
||||
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 worker = record(item.worker, "E33.worker");
|
||||
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 metrics = record(item.metrics, "E37.metrics");
|
||||
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 metrics = record(item.metrics, "E38.metrics");
|
||||
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 metrics = record(item.metrics, "E39.metrics");
|
||||
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,
|
||||
parser: (value: unknown) => T,
|
||||
fetcher: LaboratoryFetch,
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface E30ReviewItem {
|
||||
materialization: {
|
||||
framePointCount: number;
|
||||
projectedPointCount: number;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
candidatePointCount: number;
|
||||
selectedPointCount: number;
|
||||
rejectedCandidatePointCount: number;
|
||||
@@ -428,6 +430,14 @@ function parseItem(value: unknown): E30ReviewItem {
|
||||
materialized.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(
|
||||
materialized.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/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/e40-case-review.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/e34-temporal-layer.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 data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +206,7 @@ export function E30EvidenceProjection({
|
||||
</span>
|
||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
Проверяемый кластер E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
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 { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
@@ -15,97 +20,29 @@ import { E39Result } from "./E39Result";
|
||||
import { E40Result } from "./E40Result";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
|
||||
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 { isAdvancedLaboratoryWorkId };
|
||||
export type { AdvancedLaboratoryWorkId };
|
||||
|
||||
type LaboratoryWorkspaceProps = 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(
|
||||
results: AdvancedLaboratoryResults,
|
||||
sourceSessions: ReadonlyMap<string, ObservationSessionSummary>,
|
||||
index: readonly AdvancedLaboratoryIndexItem[],
|
||||
): readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] {
|
||||
const options: LaboratoryOption<AdvancedLaboratoryWorkId>[] = [];
|
||||
if (results.e31 && sourceSessions.has(results.e31.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e31-source-binding",
|
||||
label: "LAB E31 · source binding",
|
||||
});
|
||||
}
|
||||
if (results.e32 && sourceSessions.has(results.e32.sourceSessionId)) {
|
||||
options.push({
|
||||
id: "e32-track-geometry",
|
||||
label: "LAB E32 · TrackGeometry v1",
|
||||
});
|
||||
}
|
||||
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;
|
||||
const available = new Set(index.map(({ workId }) => workId));
|
||||
const options: readonly LaboratoryOption<AdvancedLaboratoryWorkId>[] = [
|
||||
{ id: "e31-source-binding", label: "LAB E31 · source binding" },
|
||||
{ id: "e32-track-geometry", label: "LAB E32 · TrackGeometry v1" },
|
||||
{ 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" },
|
||||
{ id: "e37-ravnoves-acceptance", label: "LAB E37 · RAVNOVES00 acceptance R0" },
|
||||
{ id: "e38-perception-baseline", label: "LAB E38 · perception quality R1" },
|
||||
{ id: "e39-perception-refinement", label: "LAB E39 · perception refinement R1" },
|
||||
{ id: "e40-perception-product-gate", label: "LAB E40 · leakage-resistant product gate" },
|
||||
];
|
||||
return options.filter(({ id }) => available.has(id));
|
||||
}
|
||||
|
||||
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 {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryMetricGrid,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
@@ -9,6 +8,7 @@ import type {
|
||||
E40PerceptionProductGateResult,
|
||||
} from "../../core/laboratory/e40ProductGate";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E40CaseReview } from "./E40CaseReview";
|
||||
|
||||
function percent(value: number): string {
|
||||
return `${(value * 100).toLocaleString("ru-RU", {
|
||||
@@ -32,14 +32,14 @@ export function E40Result({
|
||||
result.developmentCrossValidation.protocols.wholeTrackOrSceneWindow.dimensions
|
||||
);
|
||||
const gateLabel = result.qualityGatePassed
|
||||
? "RAVNOVES00 product gate пройден"
|
||||
: "RAVNOVES00 product gate не пройден";
|
||||
? "Историческая оценка E40 пройдена"
|
||||
: "Историческая оценка E40 не пройдена";
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E40 · leakage-resistant product gate"
|
||||
description="После E39 проверена route-coordinate-free модель: смежные кадры, целые track и scene-window изолированы при development-проверке, а sealed validation исполнен один раз на Worker 006."
|
||||
title="LAB E40 · historical visible engineering evaluation"
|
||||
description="Историческая source-scoped оценка E40: camera-only softmax и фиксированная stratum-policy измерены на уже видимом инженерном контракте RAVNOVES00. Это не operational camera→LiDAR pipeline и не blind validation."
|
||||
status={gateLabel}
|
||||
statusTone={result.qualityGatePassed ? "success" : "warning"}
|
||||
facts={[
|
||||
@@ -53,7 +53,7 @@ export function E40Result({
|
||||
},
|
||||
{
|
||||
label: "Validation",
|
||||
value: `${formatNumber(metrics.validationItems, 0)} · sealed`,
|
||||
value: `${formatNumber(metrics.validationItems, 0)} · historical visible`,
|
||||
},
|
||||
{
|
||||
label: "Исполнение",
|
||||
@@ -61,10 +61,10 @@ export function E40Result({
|
||||
},
|
||||
]}
|
||||
brief={{
|
||||
question: "Удерживает ли perception выбранного сенсорного рига не менее 90% по presence, geometry association и freshness после удаления route-coordinate leakage и группировки зависимых кадров?",
|
||||
approach: "Для 340 development-кейсов зафиксированы 125 признаков без frame index, session time, review ordinal, track ID и абсолютных map-координат. Консервативная stratum-policy и camera-only softmax прошли два групповых пятифолдовых протокола; validation labels при выборе и обучении не использовались.",
|
||||
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)}.`,
|
||||
limitation: "Это source-scoped доказательство только для RAVNOVES00 на инженерно проверенной разметке. Оно не доказывает перенос на другой маршрут, растительность, камеру, риг или живой ровер и не выдаёт навигационных либо safety-полномочий.",
|
||||
question: "Как camera-only softmax и фиксированная политика страт согласуются с уже видимым инженерным контрактом RAVNOVES00?",
|
||||
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)}. Historical visible evaluation: presence ${percent(dimensions.presence.accuracy)}, geometry ${percent(dimensions.geometryAssociation.accuracy)}, freshness ${percent(dimensions.freshness.accuracy)}.`,
|
||||
limitation: "E40 — историческая source-scoped инженерная оценка поверх E29/E30, а не сама camera-first perception. Она не является blind validation и не доказывает независимую точность, перенос, навигацию или safety.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
@@ -74,7 +74,7 @@ export function E40Result({
|
||||
{
|
||||
kind: "source",
|
||||
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-метрики трёх измерений",
|
||||
identitySha256: null,
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export function E40Result({
|
||||
kind: "runtime",
|
||||
name: "Worker 006",
|
||||
version: result.workerNode,
|
||||
role: "одно sealed evaluation в package-bound контейнере без командных полномочий",
|
||||
role: "одно historical visible evaluation в package-bound контейнере без командных полномочий",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
@@ -105,34 +105,11 @@ export function E40Result({
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="GROUPED DEVELOPMENT → SEALED VALIDATION"
|
||||
eyebrow="GROUPED DEVELOPMENT → HISTORICAL VISIBLE EVALUATION"
|
||||
title="Устойчивость без route-coordinate leakage"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<LaboratoryMetricGrid
|
||||
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%`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<E40CaseReview resultId={result.resultId} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
@@ -167,7 +144,7 @@ export function E40Result({
|
||||
conclusion={{
|
||||
proved: result.qualityGatePassed
|
||||
? "На неизменяемом 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.",
|
||||
decision: result.qualityGatePassed
|
||||
? "Зафиксировать RAVNOVES00 perception gate как закрытый source-scoped этап. Следующий шаг — temporal product state и live-rover replay без расширения полномочий."
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchAdvancedLaboratoryResults,
|
||||
type AdvancedLaboratoryResults,
|
||||
} from "../../core/laboratory/advancedResults";
|
||||
import {
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||
} from "./laboratoryArchiveBriefs";
|
||||
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
@@ -63,18 +63,6 @@ type LaboratoryWorkId =
|
||||
| AdvancedLaboratoryWorkId
|
||||
| `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 {
|
||||
const match = value.match(/\bE(\d+)\b/i);
|
||||
return match ? Number(match[1]) : -1;
|
||||
@@ -572,13 +560,11 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [advancedResults, setAdvancedResults] = useState(
|
||||
EMPTY_ADVANCED_RESULTS,
|
||||
);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
pollingEnabled: false,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
@@ -603,6 +589,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[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(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -612,22 +610,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||
fetchAdvancedLaboratoryResults({ signal: controller.signal }),
|
||||
]).then(([e28, e29, e30, advanced]) => {
|
||||
]).then(([e28, e29, e30]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const nextE28 = e28.status === "fulfilled" ? e28.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 nextAdvanced = advanced.status === "fulfilled" ? advanced.value : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
setAdvancedResults(nextAdvanced ?? EMPTY_ADVANCED_RESULTS);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
advanced.status === "rejected" ? "E31–E40" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
@@ -673,19 +667,15 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
items.push(...advancedLaboratoryWorkOptions(
|
||||
advancedResults,
|
||||
sourceSessions,
|
||||
));
|
||||
items.push(...advancedLaboratoryWorkOptions(advanced.index));
|
||||
return items.sort(
|
||||
(left, right) => laboratoryWorkOrdinal(right.label) - laboratoryWorkOrdinal(left.label),
|
||||
);
|
||||
}, [
|
||||
advancedResults,
|
||||
advanced.index,
|
||||
e28Model,
|
||||
e29Result,
|
||||
e30Result,
|
||||
sourceSessions,
|
||||
]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
@@ -726,6 +716,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
useEffect(() => {
|
||||
if (
|
||||
evidenceLoading
|
||||
|| advanced.indexLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
@@ -761,6 +752,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
advanced.indexLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
@@ -813,6 +805,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| advanced.indexLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
@@ -831,7 +824,10 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
{evidenceError
|
||||
?? advanced.indexError
|
||||
?? sessions.error
|
||||
?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -953,6 +949,18 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
result={e30Result}
|
||||
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) ? (
|
||||
<AdvancedLaboratoryResult
|
||||
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 fetchAdvancedLaboratoryResults;
|
||||
let fetchAdvancedLaboratoryIndex;
|
||||
let fetchAdvancedLaboratoryResult;
|
||||
let AdvancedLaboratoryContractError;
|
||||
|
||||
const authority = {
|
||||
@@ -637,6 +639,10 @@ before(async () => {
|
||||
fetchAdvancedLaboratoryResults,
|
||||
AdvancedLaboratoryContractError,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/advancedResults.ts"));
|
||||
({
|
||||
fetchAdvancedLaboratoryIndex,
|
||||
fetchAdvancedLaboratoryResult,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/advancedIndex.ts"));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const decoded = await fetchAdvancedLaboratoryResults({
|
||||
fetcher: async (input) => {
|
||||
|
||||
@@ -73,6 +73,8 @@ function item(overrides = {}) {
|
||||
materialization: {
|
||||
frame_point_count: 1813,
|
||||
projected_point_count: 3,
|
||||
projection_width: 800,
|
||||
projection_height: 600,
|
||||
candidate_point_count: 2,
|
||||
selected_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",
|
||||
import.meta.url,
|
||||
);
|
||||
const e40CaseReviewUrl = new URL(
|
||||
"../src/workspaces/laboratory/E40CaseReview.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const e35StylesUrl = new URL(
|
||||
"../src/styles/e35-degradation-recovery.css",
|
||||
import.meta.url,
|
||||
@@ -265,9 +269,10 @@ test("E39 reports refinement and the CV-to-validation gap through the canonical
|
||||
assert.match(advancedSource, /<E39Result/);
|
||||
});
|
||||
|
||||
test("E40 reports dual grouped qualification and the sealed product gate through the canonical LAB anatomy", async () => {
|
||||
const [e40Source, advancedSource] = await Promise.all([
|
||||
test("E40 reports historical visible evaluation with bounded camera-LiDAR case review", async () => {
|
||||
const [e40Source, caseReviewSource, advancedSource] = await Promise.all([
|
||||
readFile(e40ResultUrl, "utf8"),
|
||||
readFile(e40CaseReviewUrl, "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, /<LaboratoryResultSummary/);
|
||||
assert.match(e40Source, /frame index, session time, review ordinal, track ID/);
|
||||
assert.match(e40Source, /validation labels при выборе и обучении не использовались/);
|
||||
assert.match(e40Source, /Whole track \/ scene CV/);
|
||||
assert.match(e40Source, /[Ss]ource-scoped perception gate/);
|
||||
assert.match(e40Source, /Только camera-only использует softmax/);
|
||||
assert.match(e40Source, /<E40CaseReview resultId=\{result\.resultId\}/);
|
||||
assert.match(e40Source, /historical visible engineering evaluation/);
|
||||
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(
|
||||
e40Source,
|
||||
/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
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
@@ -58,6 +59,10 @@ from k1link.compute.e40_perception_product_gate import (
|
||||
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
||||
"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}$")
|
||||
_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]
|
||||
|
||||
_AdvancedIndexSpec = tuple[
|
||||
str,
|
||||
RootProvider,
|
||||
re.Pattern[str],
|
||||
str,
|
||||
str,
|
||||
]
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[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]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} is invalid")
|
||||
@@ -739,6 +828,76 @@ def build_advanced_laboratory_router(
|
||||
) -> APIRouter:
|
||||
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")
|
||||
def list_e31_results(
|
||||
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_human_review_api import build_e30_human_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.laboratory_api import build_laboratory_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(
|
||||
build_e30_engineering_router(
|
||||
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
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
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(
|
||||
tmp_path: Path,
|
||||
) -> 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