feat(lab): verify and expose real evidence
This commit is contained in:
@@ -0,0 +1,540 @@
|
|||||||
|
export interface E29ReviewFrame {
|
||||||
|
frameIndex: number;
|
||||||
|
sourceFrameIndex: number;
|
||||||
|
sessionSeconds: number;
|
||||||
|
conflictCount: number;
|
||||||
|
semanticObservationCount: number;
|
||||||
|
geometryOnlyClusterCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E29EvidenceResult {
|
||||||
|
resultId: string;
|
||||||
|
createdAtUtc: string | null;
|
||||||
|
status: "diagnostic-replay-complete";
|
||||||
|
identity: {
|
||||||
|
frameCount: number;
|
||||||
|
timelineStartSeconds: number;
|
||||||
|
timelineEndSeconds: number;
|
||||||
|
};
|
||||||
|
metrics: {
|
||||||
|
frames: {
|
||||||
|
total: number;
|
||||||
|
sourceAvailable: number;
|
||||||
|
localSurfaceValid: number;
|
||||||
|
};
|
||||||
|
semanticObservations: {
|
||||||
|
total: number;
|
||||||
|
current: number;
|
||||||
|
agreementFractionOfCurrent: number;
|
||||||
|
geometryStatus: {
|
||||||
|
agree: number;
|
||||||
|
cameraOnly: number;
|
||||||
|
conflict: number;
|
||||||
|
unknown: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
geometryOnlyOccupied: {
|
||||||
|
clusterCount: number;
|
||||||
|
pointCount: number;
|
||||||
|
};
|
||||||
|
runtime: {
|
||||||
|
buildElapsedMs: number;
|
||||||
|
frameProcessingP95Ms: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
decision: {
|
||||||
|
cameraFirstContractImplemented: boolean;
|
||||||
|
parallelGeometryOnlyLayerImplemented: boolean;
|
||||||
|
productionPromotion: boolean;
|
||||||
|
nextGate: string;
|
||||||
|
};
|
||||||
|
limitations: readonly string[];
|
||||||
|
authority: {
|
||||||
|
commandsEnabled: false;
|
||||||
|
navigationOrSafetyAccepted: false;
|
||||||
|
};
|
||||||
|
reviewFrames: readonly E29ReviewFrame[];
|
||||||
|
linkedEvidence: {
|
||||||
|
sourceSessionId: string;
|
||||||
|
localSurfaceModelId: string;
|
||||||
|
sourcePackId: string;
|
||||||
|
sourceResultId: string;
|
||||||
|
publishedOnControlPlane: true;
|
||||||
|
workerCopyRequired: false;
|
||||||
|
};
|
||||||
|
groundTruth: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E29EvidenceCatalog {
|
||||||
|
configured: boolean;
|
||||||
|
candidateTotal: number;
|
||||||
|
invalidTotal: number;
|
||||||
|
items: readonly E29EvidenceResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E29SemanticObservation {
|
||||||
|
trackId: number;
|
||||||
|
label: string;
|
||||||
|
geometryStatus: string;
|
||||||
|
geometryReason: string;
|
||||||
|
rangeM: number | null;
|
||||||
|
score: number;
|
||||||
|
support: {
|
||||||
|
classifiedPoints: number;
|
||||||
|
occupiedPoints: number;
|
||||||
|
surfacePoints: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E29GeometryOnlyCluster {
|
||||||
|
nearestRangeM: number;
|
||||||
|
pointCount: number;
|
||||||
|
voxelCount: number;
|
||||||
|
heightRangeM: readonly [number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E29EvidenceFrame {
|
||||||
|
resultId: string;
|
||||||
|
frameIndex: number;
|
||||||
|
sourceFrameIndex: number;
|
||||||
|
sessionSeconds: number;
|
||||||
|
semanticObservations: readonly E29SemanticObservation[];
|
||||||
|
geometryOnlyOccupied: readonly E29GeometryOnlyCluster[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class E29EvidenceContractError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "E29EvidenceContractError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type E29Fetch = (
|
||||||
|
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 E29EvidenceContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалась строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалось число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integerValue(value: unknown, label: string): number {
|
||||||
|
const parsed = numberValue(value, label);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалось целое число.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanValue(value: unknown, label: string): boolean {
|
||||||
|
if (typeof value !== "boolean") {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидался boolean.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function falseValue(value: unknown, label: string): false {
|
||||||
|
if (booleanValue(value, label) !== false) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалось false.`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trueValue(value: unknown, label: string): true {
|
||||||
|
if (booleanValue(value, label) !== true) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалось true.`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableNumber(value: unknown, label: string): number | null {
|
||||||
|
return value === null ? null : numberValue(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tuple2(value: unknown, label: string): readonly [number, number] {
|
||||||
|
if (!Array.isArray(value) || value.length !== 2) {
|
||||||
|
throw new E29EvidenceContractError(`${label}: ожидалась пара чисел.`);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
numberValue(value[0], `${label}[0]`),
|
||||||
|
numberValue(value[1], `${label}[1]`),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReviewFrame(value: unknown): E29ReviewFrame {
|
||||||
|
const source = record(value, "review_frame");
|
||||||
|
return {
|
||||||
|
frameIndex: integerValue(source.frame_index, "review_frame.frame_index"),
|
||||||
|
sourceFrameIndex: integerValue(
|
||||||
|
source.source_frame_index,
|
||||||
|
"review_frame.source_frame_index",
|
||||||
|
),
|
||||||
|
sessionSeconds: numberValue(
|
||||||
|
source.session_seconds,
|
||||||
|
"review_frame.session_seconds",
|
||||||
|
),
|
||||||
|
conflictCount: integerValue(
|
||||||
|
source.conflict_count,
|
||||||
|
"review_frame.conflict_count",
|
||||||
|
),
|
||||||
|
semanticObservationCount: integerValue(
|
||||||
|
source.semantic_observation_count,
|
||||||
|
"review_frame.semantic_observation_count",
|
||||||
|
),
|
||||||
|
geometryOnlyClusterCount: integerValue(
|
||||||
|
source.geometry_only_cluster_count,
|
||||||
|
"review_frame.geometry_only_cluster_count",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvidenceResult(value: unknown): E29EvidenceResult {
|
||||||
|
const source = record(value, "E29 result");
|
||||||
|
const identity = record(source.identity, "E29 identity");
|
||||||
|
const metrics = record(source.metrics, "E29 metrics");
|
||||||
|
const frames = record(metrics.frames, "E29 metrics.frames");
|
||||||
|
const semantic = record(
|
||||||
|
metrics.semantic_observations,
|
||||||
|
"E29 metrics.semantic_observations",
|
||||||
|
);
|
||||||
|
const geometryStatus = record(
|
||||||
|
semantic.geometry_status,
|
||||||
|
"E29 metrics.semantic_observations.geometry_status",
|
||||||
|
);
|
||||||
|
const geometryOnly = record(
|
||||||
|
metrics.geometry_only_occupied,
|
||||||
|
"E29 metrics.geometry_only_occupied",
|
||||||
|
);
|
||||||
|
const runtime = record(metrics.runtime, "E29 metrics.runtime");
|
||||||
|
const frameProcessing = record(
|
||||||
|
runtime.frame_processing_ms,
|
||||||
|
"E29 metrics.runtime.frame_processing_ms",
|
||||||
|
);
|
||||||
|
const decision = record(source.decision, "E29 decision");
|
||||||
|
const authority = record(source.authority, "E29 authority");
|
||||||
|
const linked = record(source.linked_evidence, "E29 linked evidence");
|
||||||
|
const status = stringValue(source.status, "E29 status");
|
||||||
|
if (status !== "diagnostic-replay-complete") {
|
||||||
|
throw new E29EvidenceContractError("E29 status не завершён.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(source.limitations) || !Array.isArray(source.review_frames)) {
|
||||||
|
throw new E29EvidenceContractError("E29 collections имеют неверный формат.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: stringValue(source.result_id, "E29 result_id"),
|
||||||
|
createdAtUtc: source.created_at_utc === null
|
||||||
|
? null
|
||||||
|
: stringValue(source.created_at_utc, "E29 created_at_utc"),
|
||||||
|
status,
|
||||||
|
identity: {
|
||||||
|
frameCount: integerValue(identity.frame_count, "E29 identity.frame_count"),
|
||||||
|
timelineStartSeconds: numberValue(
|
||||||
|
identity.timeline_start_seconds,
|
||||||
|
"E29 identity.timeline_start_seconds",
|
||||||
|
),
|
||||||
|
timelineEndSeconds: numberValue(
|
||||||
|
identity.timeline_end_seconds,
|
||||||
|
"E29 identity.timeline_end_seconds",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
frames: {
|
||||||
|
total: integerValue(frames.total, "E29 metrics.frames.total"),
|
||||||
|
sourceAvailable: integerValue(
|
||||||
|
frames.source_available,
|
||||||
|
"E29 metrics.frames.source_available",
|
||||||
|
),
|
||||||
|
localSurfaceValid: integerValue(
|
||||||
|
frames.local_surface_valid,
|
||||||
|
"E29 metrics.frames.local_surface_valid",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
semanticObservations: {
|
||||||
|
total: integerValue(
|
||||||
|
semantic.total,
|
||||||
|
"E29 metrics.semantic_observations.total",
|
||||||
|
),
|
||||||
|
current: integerValue(
|
||||||
|
semantic.current,
|
||||||
|
"E29 metrics.semantic_observations.current",
|
||||||
|
),
|
||||||
|
agreementFractionOfCurrent: numberValue(
|
||||||
|
semantic.agreement_fraction_of_current,
|
||||||
|
"E29 metrics.semantic_observations.agreement_fraction_of_current",
|
||||||
|
),
|
||||||
|
geometryStatus: {
|
||||||
|
agree: integerValue(geometryStatus.agree, "E29 geometry_status.agree"),
|
||||||
|
cameraOnly: integerValue(
|
||||||
|
geometryStatus["single-source-camera"],
|
||||||
|
"E29 geometry_status.single-source-camera",
|
||||||
|
),
|
||||||
|
conflict: integerValue(
|
||||||
|
geometryStatus.conflict,
|
||||||
|
"E29 geometry_status.conflict",
|
||||||
|
),
|
||||||
|
unknown: integerValue(
|
||||||
|
geometryStatus.unknown,
|
||||||
|
"E29 geometry_status.unknown",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
geometryOnlyOccupied: {
|
||||||
|
clusterCount: integerValue(
|
||||||
|
geometryOnly.cluster_count,
|
||||||
|
"E29 geometry_only_occupied.cluster_count",
|
||||||
|
),
|
||||||
|
pointCount: integerValue(
|
||||||
|
geometryOnly.point_count,
|
||||||
|
"E29 geometry_only_occupied.point_count",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
buildElapsedMs: numberValue(
|
||||||
|
runtime.build_elapsed_ms,
|
||||||
|
"E29 runtime.build_elapsed_ms",
|
||||||
|
),
|
||||||
|
frameProcessingP95Ms: numberValue(
|
||||||
|
frameProcessing.p95,
|
||||||
|
"E29 runtime.frame_processing_ms.p95",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
cameraFirstContractImplemented: booleanValue(
|
||||||
|
decision.camera_first_contract_implemented,
|
||||||
|
"E29 decision.camera_first_contract_implemented",
|
||||||
|
),
|
||||||
|
parallelGeometryOnlyLayerImplemented: booleanValue(
|
||||||
|
decision.parallel_geometry_only_layer_implemented,
|
||||||
|
"E29 decision.parallel_geometry_only_layer_implemented",
|
||||||
|
),
|
||||||
|
productionPromotion: booleanValue(
|
||||||
|
decision.production_promotion,
|
||||||
|
"E29 decision.production_promotion",
|
||||||
|
),
|
||||||
|
nextGate: stringValue(decision.next_gate, "E29 decision.next_gate"),
|
||||||
|
},
|
||||||
|
limitations: source.limitations.map((item, index) => (
|
||||||
|
stringValue(item, `E29 limitations[${index}]`)
|
||||||
|
)),
|
||||||
|
authority: {
|
||||||
|
commandsEnabled: falseValue(
|
||||||
|
authority.commands_enabled,
|
||||||
|
"E29 authority.commands_enabled",
|
||||||
|
),
|
||||||
|
navigationOrSafetyAccepted: falseValue(
|
||||||
|
authority.navigation_or_safety_accepted,
|
||||||
|
"E29 authority.navigation_or_safety_accepted",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
reviewFrames: source.review_frames.map(parseReviewFrame),
|
||||||
|
linkedEvidence: {
|
||||||
|
sourceSessionId: stringValue(
|
||||||
|
linked.source_session_id,
|
||||||
|
"E29 linked_evidence.source_session_id",
|
||||||
|
),
|
||||||
|
localSurfaceModelId: stringValue(
|
||||||
|
linked.local_surface_model_id,
|
||||||
|
"E29 linked_evidence.local_surface_model_id",
|
||||||
|
),
|
||||||
|
sourcePackId: stringValue(
|
||||||
|
linked.source_pack_id,
|
||||||
|
"E29 linked_evidence.source_pack_id",
|
||||||
|
),
|
||||||
|
sourceResultId: stringValue(
|
||||||
|
linked.source_result_id,
|
||||||
|
"E29 linked_evidence.source_result_id",
|
||||||
|
),
|
||||||
|
publishedOnControlPlane: trueValue(
|
||||||
|
linked.published_on_control_plane,
|
||||||
|
"E29 linked_evidence.published_on_control_plane",
|
||||||
|
),
|
||||||
|
workerCopyRequired: falseValue(
|
||||||
|
linked.worker_copy_required,
|
||||||
|
"E29 linked_evidence.worker_copy_required",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
groundTruth: falseValue(source.ground_truth, "E29 ground_truth"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseE29EvidenceCatalog(value: unknown): E29EvidenceCatalog {
|
||||||
|
const source = record(value, "E29 catalog");
|
||||||
|
if (source.schema_version !== "missioncore.laboratory-e29-catalog/v1") {
|
||||||
|
throw new E29EvidenceContractError("E29 catalog schema не поддерживается.");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(source.items)) {
|
||||||
|
throw new E29EvidenceContractError("E29 catalog.items имеет неверный формат.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
configured: booleanValue(source.configured, "E29 catalog.configured"),
|
||||||
|
candidateTotal: integerValue(
|
||||||
|
source.candidate_total,
|
||||||
|
"E29 catalog.candidate_total",
|
||||||
|
),
|
||||||
|
invalidTotal: integerValue(
|
||||||
|
source.invalid_total,
|
||||||
|
"E29 catalog.invalid_total",
|
||||||
|
),
|
||||||
|
items: source.items.map(parseEvidenceResult),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSemanticObservation(value: unknown): E29SemanticObservation {
|
||||||
|
const source = record(value, "E29 semantic observation");
|
||||||
|
const support = record(source.support, "E29 semantic observation.support");
|
||||||
|
return {
|
||||||
|
trackId: integerValue(source.track_id, "E29 semantic observation.track_id"),
|
||||||
|
label: stringValue(source.label, "E29 semantic observation.label"),
|
||||||
|
geometryStatus: stringValue(
|
||||||
|
source.geometry_status,
|
||||||
|
"E29 semantic observation.geometry_status",
|
||||||
|
),
|
||||||
|
geometryReason: stringValue(
|
||||||
|
source.geometry_reason,
|
||||||
|
"E29 semantic observation.geometry_reason",
|
||||||
|
),
|
||||||
|
rangeM: nullableNumber(source.range_m, "E29 semantic observation.range_m"),
|
||||||
|
score: numberValue(source.score, "E29 semantic observation.score"),
|
||||||
|
support: {
|
||||||
|
classifiedPoints: integerValue(
|
||||||
|
support.classified_points_in_bbox,
|
||||||
|
"E29 support.classified_points_in_bbox",
|
||||||
|
),
|
||||||
|
occupiedPoints: integerValue(
|
||||||
|
support.occupied_points_in_bbox,
|
||||||
|
"E29 support.occupied_points_in_bbox",
|
||||||
|
),
|
||||||
|
surfacePoints: integerValue(
|
||||||
|
support.surface_points_in_bbox,
|
||||||
|
"E29 support.surface_points_in_bbox",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGeometryOnly(value: unknown): E29GeometryOnlyCluster {
|
||||||
|
const source = record(value, "E29 geometry-only cluster");
|
||||||
|
return {
|
||||||
|
nearestRangeM: numberValue(
|
||||||
|
source.nearest_range_m,
|
||||||
|
"E29 geometry-only.nearest_range_m",
|
||||||
|
),
|
||||||
|
pointCount: integerValue(
|
||||||
|
source.point_count,
|
||||||
|
"E29 geometry-only.point_count",
|
||||||
|
),
|
||||||
|
voxelCount: integerValue(
|
||||||
|
source.voxel_count,
|
||||||
|
"E29 geometry-only.voxel_count",
|
||||||
|
),
|
||||||
|
heightRangeM: tuple2(
|
||||||
|
source.height_range_m,
|
||||||
|
"E29 geometry-only.height_range_m",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseE29EvidenceFrame(value: unknown): E29EvidenceFrame {
|
||||||
|
const source = record(value, "E29 frame response");
|
||||||
|
if (source.schema_version !== "missioncore.laboratory-e29-frame/v1") {
|
||||||
|
throw new E29EvidenceContractError("E29 frame schema не поддерживается.");
|
||||||
|
}
|
||||||
|
const frame = record(source.frame, "E29 frame");
|
||||||
|
if (
|
||||||
|
frame.schema_version !== "missioncore.e29-camera-geometry-frame/v1"
|
||||||
|
|| !Array.isArray(frame.semantic_observations)
|
||||||
|
|| !Array.isArray(frame.geometry_only_occupied)
|
||||||
|
) {
|
||||||
|
throw new E29EvidenceContractError("E29 frame имеет неверный формат.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultId: stringValue(source.result_id, "E29 frame result_id"),
|
||||||
|
frameIndex: integerValue(frame.frame_index, "E29 frame.frame_index"),
|
||||||
|
sourceFrameIndex: integerValue(
|
||||||
|
frame.source_frame_index,
|
||||||
|
"E29 frame.source_frame_index",
|
||||||
|
),
|
||||||
|
sessionSeconds: numberValue(
|
||||||
|
frame.session_seconds,
|
||||||
|
"E29 frame.session_seconds",
|
||||||
|
),
|
||||||
|
semanticObservations: frame.semantic_observations.map(
|
||||||
|
parseSemanticObservation,
|
||||||
|
),
|
||||||
|
geometryOnlyOccupied: frame.geometry_only_occupied.map(parseGeometryOnly),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
// Preserve the status-aware fallback below.
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||||
|
? String((payload as { detail?: unknown }).detail)
|
||||||
|
: fallback;
|
||||||
|
throw new E29EvidenceContractError(detail);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE29EvidenceCatalog(
|
||||||
|
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||||
|
): Promise<E29EvidenceCatalog> {
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const response = await fetcher("/api/v1/laboratory/e29/results?limit=1", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
return parseE29EvidenceCatalog(
|
||||||
|
await responseJson(response, "Не удалось получить LAB E29."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchE29EvidenceFrame(
|
||||||
|
resultId: string,
|
||||||
|
frameIndex: number,
|
||||||
|
options: { signal?: AbortSignal; fetcher?: E29Fetch } = {},
|
||||||
|
): Promise<E29EvidenceFrame> {
|
||||||
|
if (
|
||||||
|
!/^e29-camera-geometry-[a-f0-9]{64}$/.test(resultId)
|
||||||
|
|| !Number.isSafeInteger(frameIndex)
|
||||||
|
|| frameIndex < 0
|
||||||
|
) {
|
||||||
|
throw new E29EvidenceContractError("Некорректный LAB E29 frame id.");
|
||||||
|
}
|
||||||
|
const fetcher = options.fetcher ?? fetch;
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/e29/results/${resultId}/frames/${frameIndex}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: options.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return parseE29EvidenceFrame(
|
||||||
|
await responseJson(response, "Не удалось получить кадр LAB E29."),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1488,7 +1488,7 @@ export async function fetchLidarLocalSurfaces(
|
|||||||
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
options: { signal?: AbortSignal; fetcher?: LidarFetch } = {},
|
||||||
): Promise<LidarLocalSurfaceCatalog> {
|
): Promise<LidarLocalSurfaceCatalog> {
|
||||||
const fetcher = options.fetcher ?? fetch;
|
const fetcher = options.fetcher ?? fetch;
|
||||||
const response = await fetcher("/api/v1/lidar/local-surfaces?limit=10", {
|
const response = await fetcher("/api/v1/lidar/local-surfaces?limit=1", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
|
|||||||
@@ -2592,6 +2592,111 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 0.025);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review h2 {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__picker button {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__picker button:hover,
|
||||||
|
.laboratory-frame-review__picker button.is-active {
|
||||||
|
background: rgb(255 255 255 / 0.09);
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__picker span {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 660;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__picker small,
|
||||||
|
.laboratory-frame-review__detail small {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__state {
|
||||||
|
display: flex;
|
||||||
|
min-height: 6rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail > div {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail > div > span {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.54rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail > div > strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__conflicts p {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.laboratory-selector {
|
.laboratory-selector {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
@@ -2601,6 +2706,14 @@
|
|||||||
.laboratory-result-metrics {
|
.laboratory-result-metrics {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataset-entry {
|
.dataset-entry {
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ import type {
|
|||||||
MissionRuntimeState,
|
MissionRuntimeState,
|
||||||
ObservationSourceDescriptor,
|
ObservationSourceDescriptor,
|
||||||
} from "../core/runtime/contracts";
|
} from "../core/runtime/contracts";
|
||||||
|
import {
|
||||||
|
fetchE29EvidenceCatalog,
|
||||||
|
fetchE29EvidenceFrame,
|
||||||
|
type E29EvidenceFrame,
|
||||||
|
type E29EvidenceResult,
|
||||||
|
} from "../core/laboratory/e29Evidence";
|
||||||
|
import {
|
||||||
|
fetchLidarLocalSurfaces,
|
||||||
|
type LidarLocalSurfaceModel,
|
||||||
|
} from "../core/lidar/localSurface";
|
||||||
import {
|
import {
|
||||||
RerunViewport,
|
RerunViewport,
|
||||||
isRecordedPlaybackPresentationReady,
|
isRecordedPlaybackPresentationReady,
|
||||||
@@ -1271,39 +1281,200 @@ function LaboratoryTask({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function E29LaboratoryResult({ rigLabel }: { rigLabel: string }) {
|
function formatSeconds(value: number): string {
|
||||||
|
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function E29LaboratoryResult({
|
||||||
|
props,
|
||||||
|
rigLabel,
|
||||||
|
result,
|
||||||
|
sourceSession,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
props: WorkspaceRendererProps;
|
||||||
|
rigLabel: string;
|
||||||
|
result: E29EvidenceResult;
|
||||||
|
sourceSession: ObservationSessionSummary;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
|
||||||
|
result.reviewFrames[0]?.frameIndex ?? 0,
|
||||||
|
);
|
||||||
|
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
|
||||||
|
const [frameLoading, setFrameLoading] = useState(false);
|
||||||
|
const [frameError, setFrameError] = useState<string | null>(null);
|
||||||
|
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
|
||||||
|
const semantic = result.metrics.semanticObservations;
|
||||||
|
const geometryStatus = semantic.geometryStatus;
|
||||||
|
const conflicts = frame?.semanticObservations.filter(
|
||||||
|
(observation) => observation.geometryStatus === "conflict",
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setFrameLoading(true);
|
||||||
|
setFrameError(null);
|
||||||
|
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}).then((next) => {
|
||||||
|
setFrame(next);
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setFrame(null);
|
||||||
|
setFrameError(
|
||||||
|
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
|
||||||
|
);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!controller.signal.aborted) setFrameLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [result.resultId, selectedFrameIndex]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LaboratoryTask
|
<LaboratoryTask
|
||||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||||
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||||
status="Диагностический replay"
|
status="Проверенные артефакты"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||||
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
|
{
|
||||||
{ label: "Объектов", value: "20 513 наблюдений" },
|
label: "Источник",
|
||||||
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
|
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Наблюдений",
|
||||||
|
value: formatNumber(semantic.total, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Артефакты",
|
||||||
|
value: "Control plane · read-only · hash verified",
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<section className="lab-result-surface">
|
||||||
|
<header>
|
||||||
|
<span className="section-eyebrow">ИСХОДНЫЕ ДАННЫЕ</span>
|
||||||
|
<strong>LiDAR, траектория и камера RAVNOVES00</strong>
|
||||||
|
</header>
|
||||||
|
{replayReady ? (
|
||||||
|
<SpatialWorkspace {...props} />
|
||||||
|
) : (
|
||||||
|
<div className="laboratory-result-pending" role="status">
|
||||||
|
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||||
|
<strong>{loading ? "Проверяем и открываем запись" : "Исходная запись не открыта"}</strong>
|
||||||
|
<p>
|
||||||
|
{error ?? (loading
|
||||||
|
? "Viewer появится после серверной проверки неизменяемого RRD."
|
||||||
|
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="laboratory-result-summary">
|
<section className="laboratory-result-summary">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||||
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
|
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone="success">Replay завершён</StatusBadge>
|
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||||
|
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||||
|
</StatusBadge>
|
||||||
</header>
|
</header>
|
||||||
<div className="laboratory-result-metrics">
|
<div className="laboratory-result-metrics">
|
||||||
<div><span>Поддержка геометрией</span><strong>6 341</strong><small>32,31% current observations</small></div>
|
<div>
|
||||||
<div><span>Camera-only</span><strong>13 246</strong><small>Семантика сохранена</small></div>
|
<span>Поддержка геометрией</span>
|
||||||
<div><span>Postprocess p95</span><strong>2,517 мс</strong><small>8,467 с полный build</small></div>
|
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||||
<div><span>Geometry-only</span><strong>21 321</strong><small>Занято / класс неизвестен</small></div>
|
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Только камера</span>
|
||||||
|
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
|
||||||
|
<small>Семантика без LiDAR-подтверждения</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Postprocess p95</span>
|
||||||
|
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
|
||||||
|
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Только геометрия</span>
|
||||||
|
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
|
||||||
|
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>{result.decision.nextGate}</p>
|
||||||
Следующий gate — покадровый разбор конфликтов, camera-only пробелов и
|
</section>
|
||||||
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
|
|
||||||
</p>
|
<section className="laboratory-frame-review">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
|
||||||
|
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone="warning">
|
||||||
|
{formatNumber(geometryStatus.conflict, 0)} конфликтов
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<div className="laboratory-frame-review__picker" role="list">
|
||||||
|
{result.reviewFrames.slice(0, 16).map((review) => (
|
||||||
|
<button
|
||||||
|
key={review.frameIndex}
|
||||||
|
type="button"
|
||||||
|
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
|
||||||
|
onClick={() => setSelectedFrameIndex(review.frameIndex)}
|
||||||
|
>
|
||||||
|
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
|
||||||
|
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{frameLoading ? (
|
||||||
|
<div className="laboratory-frame-review__state" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<span>Читаем подтверждённый кадр</span>
|
||||||
|
</div>
|
||||||
|
) : frameError || !frame ? (
|
||||||
|
<div className="laboratory-frame-review__state" role="status">
|
||||||
|
<Icon name="database" size={18} />
|
||||||
|
<span>{frameError ?? "Кадр недоступен."}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="laboratory-frame-review__detail">
|
||||||
|
<div>
|
||||||
|
<span>Кадр источника</span>
|
||||||
|
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
|
||||||
|
<small>{formatSeconds(frame.sessionSeconds)}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Семантические наблюдения</span>
|
||||||
|
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
|
||||||
|
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Geometry-only компоненты</span>
|
||||||
|
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
|
||||||
|
<small>Класс не назначается</small>
|
||||||
|
</div>
|
||||||
|
<div className="laboratory-frame-review__conflicts">
|
||||||
|
<span>Фактические конфликты</span>
|
||||||
|
{conflicts.length ? conflicts.map((observation) => (
|
||||||
|
<p key={observation.trackId}>
|
||||||
|
<strong>{observation.label} · track {observation.trackId}</strong>
|
||||||
|
<small>
|
||||||
|
{observation.geometryReason} · classified {observation.support.classifiedPoints}
|
||||||
|
{" · "}surface {observation.support.surfacePoints}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
)) : <small>В этом кадре конфликт не найден.</small>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1421,6 +1592,10 @@ function PublishedLaboratoryResult({
|
|||||||
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||||
|
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||||
|
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||||
|
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||||
|
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit: 100,
|
limit: 100,
|
||||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||||
@@ -1429,9 +1604,47 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||||
});
|
});
|
||||||
const publishedWorks = useMemo(
|
const publishedWorks = useMemo(
|
||||||
() => sessions.items.filter((session) => session.lab !== null),
|
() => sessions.items.filter((session) => (
|
||||||
|
session.lab !== null
|
||||||
|
&& session.status === "ready"
|
||||||
|
&& session.replayable
|
||||||
|
&& session.modalities.includes("point-cloud")
|
||||||
|
)),
|
||||||
[sessions.items],
|
[sessions.items],
|
||||||
);
|
);
|
||||||
|
const sourceSessions = useMemo(
|
||||||
|
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||||
|
[sessions.items],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setEvidenceLoading(true);
|
||||||
|
setEvidenceError(null);
|
||||||
|
void Promise.allSettled([
|
||||||
|
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||||
|
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||||
|
]).then(([e28, e29]) => {
|
||||||
|
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;
|
||||||
|
setE28Model(nextE28);
|
||||||
|
setE29Result(nextE29);
|
||||||
|
const failures = [
|
||||||
|
e28.status === "rejected" ? "E28" : null,
|
||||||
|
e29.status === "rejected" ? "E29" : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
setEvidenceError(
|
||||||
|
failures.length
|
||||||
|
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}).finally(() => {
|
||||||
|
if (!controller.signal.aborted) setEvidenceLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const rigLabel = useMemo(() => {
|
const rigLabel = useMemo(() => {
|
||||||
if (props.deviceLabel) return props.deviceLabel;
|
if (props.deviceLabel) return props.deviceLabel;
|
||||||
const coordinateFrame = publishedWorks
|
const coordinateFrame = publishedWorks
|
||||||
@@ -1442,22 +1655,44 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
: "";
|
: "";
|
||||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||||
}, [props.deviceLabel, publishedWorks]);
|
}, [props.deviceLabel, publishedWorks]);
|
||||||
const profiles: readonly LaboratoryOption<LaboratoryProfileId>[] = [
|
const sensorWorks = useMemo(() => {
|
||||||
{
|
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||||
id: "sensor-fusion",
|
if (e28Model) {
|
||||||
label: `${rigLabel} · камера + LiDAR · worker D`,
|
items.push({
|
||||||
},
|
id: "e28-local-surface",
|
||||||
{
|
label: "LAB E28 · локальная поверхность L2.6",
|
||||||
id: "published-perception",
|
});
|
||||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
}
|
||||||
},
|
if (
|
||||||
];
|
e29Result
|
||||||
|
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
|
||||||
|
) {
|
||||||
|
items.push({
|
||||||
|
id: "e29-camera-geometry",
|
||||||
|
label: "LAB E29 · camera-first + geometry",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [e28Model, e29Result, sourceSessions]);
|
||||||
|
const profiles = useMemo(() => {
|
||||||
|
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||||
|
if (sensorWorks.length) {
|
||||||
|
items.push({
|
||||||
|
id: "sensor-fusion",
|
||||||
|
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (publishedWorks.length) {
|
||||||
|
items.push({
|
||||||
|
id: "published-perception",
|
||||||
|
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
|
||||||
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
||||||
profileId === "sensor-fusion"
|
profileId === "sensor-fusion"
|
||||||
? [
|
? sensorWorks
|
||||||
{ id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" },
|
|
||||||
{ id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" },
|
|
||||||
]
|
|
||||||
: publishedWorks.map((session) => ({
|
: publishedWorks.map((session) => ({
|
||||||
id: `session:${session.id}` as const,
|
id: `session:${session.id}` as const,
|
||||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||||
@@ -1468,18 +1703,49 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
const selectedSession = selectedSessionId
|
const selectedSession = selectedSessionId
|
||||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||||
: null;
|
: null;
|
||||||
|
const e29SourceSession = e29Result
|
||||||
|
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profileId !== "published-perception" || workId.startsWith("session:")) return;
|
if (
|
||||||
const first = publishedWorks[0];
|
evidenceLoading
|
||||||
if (!first) return;
|
|| sessions.state === "idle"
|
||||||
setWorkId(`session:${first.id}`);
|
|| sessions.state === "loading"
|
||||||
}, [profileId, publishedWorks, workId]);
|
) return;
|
||||||
|
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||||
|
const firstProfile = profiles[0];
|
||||||
|
if (!firstProfile) return;
|
||||||
|
setProfileId(firstProfile.id);
|
||||||
|
if (firstProfile.id === "sensor-fusion") {
|
||||||
|
const firstWork = sensorWorks[0];
|
||||||
|
if (firstWork) setWorkId(firstWork.id);
|
||||||
|
} else {
|
||||||
|
const first = publishedWorks[0];
|
||||||
|
if (first) setWorkId(`session:${first.id}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workOptions.some((work) => work.id === workId)) {
|
||||||
|
const firstWork = workOptions[0];
|
||||||
|
if (firstWork) setWorkId(firstWork.id);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
evidenceLoading,
|
||||||
|
profileId,
|
||||||
|
profiles,
|
||||||
|
publishedWorks,
|
||||||
|
sensorWorks,
|
||||||
|
sessions.state,
|
||||||
|
workId,
|
||||||
|
workOptions,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectProfile = (next: LaboratoryProfileId) => {
|
const selectProfile = (next: LaboratoryProfileId) => {
|
||||||
setProfileId(next);
|
setProfileId(next);
|
||||||
if (next === "sensor-fusion") {
|
if (next === "sensor-fusion") {
|
||||||
setWorkId("e28-local-surface");
|
const first = sensorWorks[0];
|
||||||
|
if (first) setWorkId(first.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const first = publishedWorks[0];
|
const first = publishedWorks[0];
|
||||||
@@ -1491,10 +1757,41 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
|
|
||||||
const selectWork = (next: LaboratoryWorkId) => {
|
const selectWork = (next: LaboratoryWorkId) => {
|
||||||
setWorkId(next);
|
setWorkId(next);
|
||||||
if (!next.startsWith("session:")) return;
|
if (next.startsWith("session:")) {
|
||||||
void sessions.replay(next.slice("session:".length));
|
void sessions.replay(next.slice("session:".length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next === "e29-camera-geometry" && e29SourceSession) {
|
||||||
|
void sessions.replay(e29SourceSession.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
evidenceLoading
|
||||||
|
|| sessions.state === "idle"
|
||||||
|
|| sessions.state === "loading"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="laboratory-result-pending" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<strong>Ревизия лабораторных данных</strong>
|
||||||
|
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profiles.length) {
|
||||||
|
return (
|
||||||
|
<div className="laboratory-result-pending" role="status">
|
||||||
|
<Icon name="database" size={20} />
|
||||||
|
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||||
|
<p>
|
||||||
|
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="lab-archive-workspace">
|
<div className="lab-archive-workspace">
|
||||||
<LaboratorySelector
|
<LaboratorySelector
|
||||||
@@ -1524,12 +1821,15 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
<LaboratoryTask
|
<LaboratoryTask
|
||||||
title="LAB E28 · локальная модель поверхности L2.6"
|
title="LAB E28 · локальная модель поверхности L2.6"
|
||||||
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
|
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
|
||||||
status="Диагностический replay"
|
status="Проверенные артефакты"
|
||||||
facts={[
|
facts={[
|
||||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||||
{ label: "Покрытие", value: "3 928 / 3 928 кадров" },
|
{
|
||||||
|
label: "Покрытие",
|
||||||
|
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
|
||||||
|
},
|
||||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||||
{ label: "Полномочия", value: "Навигация и команды запрещены" },
|
{ label: "Артефакты", value: "Control plane · read-only · hash verified" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<LidarQualityWorkspace
|
<LidarQualityWorkspace
|
||||||
@@ -1538,8 +1838,19 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : workId === "e29-camera-geometry" ? (
|
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||||
<E29LaboratoryResult rigLabel={rigLabel} />
|
<E29LaboratoryResult
|
||||||
|
props={props}
|
||||||
|
rigLabel={rigLabel}
|
||||||
|
result={e29Result}
|
||||||
|
sourceSession={e29SourceSession}
|
||||||
|
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||||
|
error={
|
||||||
|
sessions.failedSessionId === e29SourceSession.id
|
||||||
|
? sessions.error
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : selectedSession ? (
|
) : selectedSession ? (
|
||||||
<PublishedLaboratoryResult
|
<PublishedLaboratoryResult
|
||||||
props={props}
|
props={props}
|
||||||
@@ -1550,8 +1861,8 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="laboratory-result-pending">
|
<div className="laboratory-result-pending">
|
||||||
<Icon name="database" size={20} />
|
<Icon name="database" size={20} />
|
||||||
<strong>В этом профиле пока нет опубликованных работ</strong>
|
<strong>Работа не прошла ревизию</strong>
|
||||||
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
|
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let parseE29EvidenceCatalog;
|
||||||
|
let parseE29EvidenceFrame;
|
||||||
|
let fetchE29EvidenceCatalog;
|
||||||
|
let fetchE29EvidenceFrame;
|
||||||
|
let E29EvidenceContractError;
|
||||||
|
|
||||||
|
const resultId = `e29-camera-geometry-${"a".repeat(64)}`;
|
||||||
|
|
||||||
|
function result(overrides = {}) {
|
||||||
|
return {
|
||||||
|
result_id: resultId,
|
||||||
|
created_at_utc: "2026-07-26T00:00:00Z",
|
||||||
|
status: "diagnostic-replay-complete",
|
||||||
|
identity: {
|
||||||
|
frame_count: 4489,
|
||||||
|
timeline_start_seconds: 35.4,
|
||||||
|
timeline_end_seconds: 484.0,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
frames: {
|
||||||
|
total: 4489,
|
||||||
|
source_available: 3928,
|
||||||
|
local_surface_valid: 3928,
|
||||||
|
},
|
||||||
|
semantic_observations: {
|
||||||
|
total: 20513,
|
||||||
|
current: 19625,
|
||||||
|
agreement_fraction_of_current: 0.3231,
|
||||||
|
geometry_status: {
|
||||||
|
agree: 6341,
|
||||||
|
"single-source-camera": 13246,
|
||||||
|
conflict: 38,
|
||||||
|
unknown: 888,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
geometry_only_occupied: {
|
||||||
|
cluster_count: 21321,
|
||||||
|
point_count: 2021343,
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
build_elapsed_ms: 8466.6,
|
||||||
|
frame_processing_ms: { p95: 2.517 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
camera_first_contract_implemented: true,
|
||||||
|
parallel_geometry_only_layer_implemented: true,
|
||||||
|
production_promotion: false,
|
||||||
|
next_gate: "operator review",
|
||||||
|
},
|
||||||
|
limitations: ["not ground truth"],
|
||||||
|
authority: {
|
||||||
|
commands_enabled: false,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
review_frames: [{
|
||||||
|
frame_index: 343,
|
||||||
|
source_frame_index: 343,
|
||||||
|
session_seconds: 69.799,
|
||||||
|
conflict_count: 1,
|
||||||
|
semantic_observation_count: 6,
|
||||||
|
geometry_only_cluster_count: 5,
|
||||||
|
}],
|
||||||
|
linked_evidence: {
|
||||||
|
source_session_id: "20260720T065719Z_viewer_live",
|
||||||
|
local_surface_model_id: `k1-local-surface-${"b".repeat(64)}`,
|
||||||
|
source_pack_id: `e10-lidar-pack-${"c".repeat(64)}`,
|
||||||
|
source_result_id: `e10-integrated-perception-${"d".repeat(64)}`,
|
||||||
|
published_on_control_plane: true,
|
||||||
|
worker_copy_required: false,
|
||||||
|
},
|
||||||
|
ground_truth: false,
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.laboratory-e29-catalog/v1",
|
||||||
|
configured: true,
|
||||||
|
items: [result()],
|
||||||
|
candidate_total: 3,
|
||||||
|
invalid_total: 0,
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.laboratory-e29-frame/v1",
|
||||||
|
result_id: resultId,
|
||||||
|
frame: {
|
||||||
|
schema_version: "missioncore.e29-camera-geometry-frame/v1",
|
||||||
|
frame_index: 343,
|
||||||
|
source_frame_index: 343,
|
||||||
|
session_seconds: 69.799,
|
||||||
|
semantic_observations: [{
|
||||||
|
track_id: 240042,
|
||||||
|
label: "car",
|
||||||
|
geometry_status: "conflict",
|
||||||
|
geometry_reason: "camera-object-region-observed-as-local-surface",
|
||||||
|
range_m: null,
|
||||||
|
score: 0.76,
|
||||||
|
support: {
|
||||||
|
classified_points_in_bbox: 8,
|
||||||
|
occupied_points_in_bbox: 0,
|
||||||
|
surface_points_in_bbox: 7,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
geometry_only_occupied: [{
|
||||||
|
nearest_range_m: 3.71,
|
||||||
|
point_count: 7,
|
||||||
|
voxel_count: 5,
|
||||||
|
height_range_m: [1.61, 1.86],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
({
|
||||||
|
parseE29EvidenceCatalog,
|
||||||
|
parseE29EvidenceFrame,
|
||||||
|
fetchE29EvidenceCatalog,
|
||||||
|
fetchE29EvidenceFrame,
|
||||||
|
E29EvidenceContractError,
|
||||||
|
} = await server.ssrLoadModule("/src/core/laboratory/e29Evidence.ts"));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes only verified non-authoritative E29 evidence", () => {
|
||||||
|
const decoded = parseE29EvidenceCatalog(catalog());
|
||||||
|
assert.equal(decoded.items.length, 1);
|
||||||
|
assert.equal(decoded.candidateTotal, 3);
|
||||||
|
assert.equal(decoded.items[0].metrics.semanticObservations.geometryStatus.conflict, 38);
|
||||||
|
assert.equal(decoded.items[0].linkedEvidence.publishedOnControlPlane, true);
|
||||||
|
|
||||||
|
const decodedFrame = parseE29EvidenceFrame(frame());
|
||||||
|
assert.equal(decodedFrame.semanticObservations[0].geometryStatus, "conflict");
|
||||||
|
assert.equal(decodedFrame.geometryOnlyOccupied[0].pointCount, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects command authority and unverified publication", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseE29EvidenceCatalog(catalog({
|
||||||
|
items: [result({
|
||||||
|
authority: {
|
||||||
|
commands_enabled: true,
|
||||||
|
navigation_or_safety_accepted: false,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
})),
|
||||||
|
E29EvidenceContractError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseE29EvidenceCatalog(catalog({
|
||||||
|
items: [result({
|
||||||
|
linked_evidence: {
|
||||||
|
...result().linked_evidence,
|
||||||
|
published_on_control_plane: false,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
})),
|
||||||
|
E29EvidenceContractError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetches canonical catalog and exact review frame read-only", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const fetchedCatalog = await fetchE29EvidenceCatalog({
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requests.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify(catalog()), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchedFrame = await fetchE29EvidenceFrame(resultId, 343, {
|
||||||
|
fetcher: async (input, init) => {
|
||||||
|
requests.push({ input: String(input), method: init?.method });
|
||||||
|
return new Response(JSON.stringify(frame()), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(fetchedCatalog.items[0].identity.frameCount, 4489);
|
||||||
|
assert.equal(fetchedFrame.sourceFrameIndex, 343);
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
input: "/api/v1/laboratory/e29/results?limit=1",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: `/api/v1/laboratory/e29/results/${resultId}/frames/343`,
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ from k1link.sessions import (
|
|||||||
)
|
)
|
||||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||||
from k1link.web.environment_api import build_environment_router
|
from k1link.web.environment_api import build_environment_router
|
||||||
|
from k1link.web.laboratory_api import build_laboratory_router
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||||
from k1link.web.plugin_runtime import (
|
from k1link.web.plugin_runtime import (
|
||||||
@@ -464,6 +465,34 @@ app.include_router(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
build_laboratory_router(
|
||||||
|
e29_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT / ".runtime" / "compute-experiments" / "e29" / "results"
|
||||||
|
),
|
||||||
|
local_surface_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "k1-local-surface-v1"
|
||||||
|
/ "models"
|
||||||
|
),
|
||||||
|
source_pack_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "e10"
|
||||||
|
/ "lidar-packs"
|
||||||
|
),
|
||||||
|
source_result_root_provider=lambda: (
|
||||||
|
REPOSITORY_ROOT
|
||||||
|
/ ".runtime"
|
||||||
|
/ "compute-experiments"
|
||||||
|
/ "e10"
|
||||||
|
/ "worker-results"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||||
|
|||||||
@@ -0,0 +1,497 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from k1link.compute.semantic_geometry_fusion import (
|
||||||
|
CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||||
|
CAMERA_GEOMETRY_FRAMES_NAME,
|
||||||
|
CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||||
|
CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||||
|
CAMERA_GEOMETRY_REPORT_NAME,
|
||||||
|
CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||||
|
)
|
||||||
|
|
||||||
|
LABORATORY_E29_CATALOG_SCHEMA: Final = (
|
||||||
|
"missioncore.laboratory-e29-catalog/v1"
|
||||||
|
)
|
||||||
|
LABORATORY_E29_DETAIL_SCHEMA: Final = "missioncore.laboratory-e29-detail/v1"
|
||||||
|
LABORATORY_E29_FRAME_SCHEMA: Final = "missioncore.laboratory-e29-frame/v1"
|
||||||
|
|
||||||
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
_E29_RESULT_ID = re.compile(r"^e29-camera-geometry-[a-f0-9]{64}$")
|
||||||
|
_LOCAL_SURFACE_ID = re.compile(r"^k1-local-surface-[a-f0-9]{64}$")
|
||||||
|
_SOURCE_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$")
|
||||||
|
_SOURCE_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$")
|
||||||
|
_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$")
|
||||||
|
|
||||||
|
_MAX_MANIFEST_BYTES: Final = 256 * 1024
|
||||||
|
_MAX_REPORT_BYTES: Final = 2 * 1024 * 1024
|
||||||
|
_MAX_FRAME_BYTES: Final = 2 * 1024 * 1024
|
||||||
|
_MAX_REVIEW_FRAMES: Final = 64
|
||||||
|
|
||||||
|
RootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
|
||||||
|
class LaboratoryEvidenceError(ValueError):
|
||||||
|
"""Raised when published laboratory evidence is incomplete or changed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path, *, maximum_bytes: int) -> dict[str, Any]:
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise LaboratoryEvidenceError("laboratory JSON artifact is unavailable")
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size <= 0 or size > maximum_bytes:
|
||||||
|
raise LaboratoryEvidenceError("laboratory JSON artifact is out of bounds")
|
||||||
|
try:
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise LaboratoryEvidenceError("laboratory JSON artifact is invalid") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise LaboratoryEvidenceError("laboratory JSON artifact must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_artifact_path(root: Path, value: object) -> Path:
|
||||||
|
if not isinstance(value, str) or not value or Path(value).name != value:
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact path is invalid")
|
||||||
|
path = root / value
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact is unavailable")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _artifact_map(
|
||||||
|
root: Path,
|
||||||
|
manifest: Mapping[str, Any],
|
||||||
|
) -> dict[str, tuple[Path, Mapping[str, Any]]]:
|
||||||
|
raw = manifest.get("artifacts")
|
||||||
|
if not isinstance(raw, list) or len(raw) != 2:
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact list is invalid")
|
||||||
|
artifacts: dict[str, tuple[Path, Mapping[str, Any]]] = {}
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact entry is invalid")
|
||||||
|
role = item.get("role")
|
||||||
|
byte_length = item.get("byte_length")
|
||||||
|
digest = item.get("sha256")
|
||||||
|
if (
|
||||||
|
not isinstance(role, str)
|
||||||
|
or role in artifacts
|
||||||
|
or not isinstance(byte_length, int)
|
||||||
|
or byte_length <= 0
|
||||||
|
or not isinstance(digest, str)
|
||||||
|
or _SHA256.fullmatch(digest) is None
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact metadata is invalid")
|
||||||
|
path = _safe_artifact_path(root, item.get("path"))
|
||||||
|
if path.stat().st_size != byte_length:
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact size changed")
|
||||||
|
artifacts[role] = (path, item)
|
||||||
|
if set(artifacts) != {"camera-geometry-frames", "camera-geometry-report"}:
|
||||||
|
raise LaboratoryEvidenceError("laboratory artifact roles are invalid")
|
||||||
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||||
|
paths = (
|
||||||
|
root / CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||||
|
root / CAMERA_GEOMETRY_REPORT_NAME,
|
||||||
|
root / CAMERA_GEOMETRY_FRAMES_NAME,
|
||||||
|
)
|
||||||
|
signature: list[int] = []
|
||||||
|
for path in paths:
|
||||||
|
stat = path.stat()
|
||||||
|
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||||
|
return tuple(signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_frame(row: Mapping[str, Any]) -> dict[str, object] | None:
|
||||||
|
observations = row.get("semantic_observations")
|
||||||
|
geometry = row.get("geometry_only_occupied")
|
||||||
|
if not isinstance(observations, list) or not isinstance(geometry, list):
|
||||||
|
raise LaboratoryEvidenceError("E29 frame collections are invalid")
|
||||||
|
conflicts = sum(
|
||||||
|
1
|
||||||
|
for observation in observations
|
||||||
|
if isinstance(observation, dict)
|
||||||
|
and observation.get("geometry_status") == "conflict"
|
||||||
|
)
|
||||||
|
if conflicts == 0:
|
||||||
|
return None
|
||||||
|
frame_index = row.get("frame_index")
|
||||||
|
session_seconds = row.get("session_seconds")
|
||||||
|
if (
|
||||||
|
not isinstance(frame_index, int)
|
||||||
|
or frame_index < 0
|
||||||
|
or not isinstance(session_seconds, (int, float))
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 review frame identity is invalid")
|
||||||
|
return {
|
||||||
|
"frame_index": frame_index,
|
||||||
|
"source_frame_index": row.get("source_frame_index"),
|
||||||
|
"session_seconds": float(session_seconds),
|
||||||
|
"conflict_count": conflicts,
|
||||||
|
"semantic_observation_count": len(observations),
|
||||||
|
"geometry_only_cluster_count": len(geometry),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=16)
|
||||||
|
def _read_result_cached(
|
||||||
|
root_text: str,
|
||||||
|
signature: tuple[int, ...],
|
||||||
|
) -> tuple[dict[str, Any], tuple[int, ...]]:
|
||||||
|
del signature
|
||||||
|
root = Path(root_text)
|
||||||
|
if root.is_symlink() or not root.is_dir() or _E29_RESULT_ID.fullmatch(root.name) is None:
|
||||||
|
raise LaboratoryEvidenceError("E29 result id is invalid")
|
||||||
|
manifest = _read_json(
|
||||||
|
root / CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||||
|
maximum_bytes=_MAX_MANIFEST_BYTES,
|
||||||
|
)
|
||||||
|
artifacts = _artifact_map(root, manifest)
|
||||||
|
report_path, report_artifact = artifacts["camera-geometry-report"]
|
||||||
|
frames_path, frames_artifact = artifacts["camera-geometry-frames"]
|
||||||
|
report = _read_json(report_path, maximum_bytes=_MAX_REPORT_BYTES)
|
||||||
|
identity = manifest.get("identity")
|
||||||
|
identity_sha256 = manifest.get("identity_sha256")
|
||||||
|
if (
|
||||||
|
manifest.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA
|
||||||
|
or manifest.get("result_id") != root.name
|
||||||
|
or not isinstance(identity, dict)
|
||||||
|
or identity.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA
|
||||||
|
or not isinstance(identity_sha256, str)
|
||||||
|
or _SHA256.fullmatch(identity_sha256) is None
|
||||||
|
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||||
|
or root.name != f"e29-camera-geometry-{identity_sha256}"
|
||||||
|
or report.get("schema_version") != CAMERA_GEOMETRY_REPORT_SCHEMA
|
||||||
|
or report.get("result_id") != root.name
|
||||||
|
or report.get("identity") != identity
|
||||||
|
or report.get("status") != "diagnostic-replay-complete"
|
||||||
|
or report.get("ground_truth") is not False
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 result identity is invalid")
|
||||||
|
if _sha256(report_path) != report_artifact.get("sha256"):
|
||||||
|
raise LaboratoryEvidenceError("E29 report digest changed")
|
||||||
|
|
||||||
|
expected_frames = identity.get("frame_count")
|
||||||
|
if not isinstance(expected_frames, int) or expected_frames <= 0:
|
||||||
|
raise LaboratoryEvidenceError("E29 frame count is invalid")
|
||||||
|
offsets: list[int] = []
|
||||||
|
review_frames: list[dict[str, object]] = []
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with frames_path.open("rb") as stream:
|
||||||
|
frame_index = 0
|
||||||
|
while True:
|
||||||
|
offset = stream.tell()
|
||||||
|
raw = stream.readline(_MAX_FRAME_BYTES + 1)
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
if len(raw) > _MAX_FRAME_BYTES:
|
||||||
|
raise LaboratoryEvidenceError("E29 frame is out of bounds")
|
||||||
|
digest.update(raw)
|
||||||
|
try:
|
||||||
|
row = json.loads(raw)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise LaboratoryEvidenceError("E29 frame is invalid") from exc
|
||||||
|
if (
|
||||||
|
not isinstance(row, dict)
|
||||||
|
or row.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA
|
||||||
|
or row.get("frame_index") != frame_index
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 frame sequence changed")
|
||||||
|
offsets.append(offset)
|
||||||
|
if len(review_frames) < _MAX_REVIEW_FRAMES:
|
||||||
|
review = _review_frame(row)
|
||||||
|
if review is not None:
|
||||||
|
review_frames.append(review)
|
||||||
|
frame_index += 1
|
||||||
|
if (
|
||||||
|
len(offsets) != expected_frames
|
||||||
|
or digest.hexdigest() != frames_artifact.get("sha256")
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 frame evidence changed")
|
||||||
|
|
||||||
|
item = {
|
||||||
|
"result_id": root.name,
|
||||||
|
"created_at_utc": manifest.get("created_at_utc"),
|
||||||
|
"status": report["status"],
|
||||||
|
"identity": identity,
|
||||||
|
"metrics": report.get("metrics"),
|
||||||
|
"decision": report.get("decision"),
|
||||||
|
"limitations": report.get("limitations"),
|
||||||
|
"authority": report.get("authority"),
|
||||||
|
"review_frames": review_frames,
|
||||||
|
"ground_truth": False,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
return item, tuple(offsets)
|
||||||
|
|
||||||
|
|
||||||
|
def _linked_evidence(
|
||||||
|
item: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
local_surface_root: Path,
|
||||||
|
source_pack_root: Path,
|
||||||
|
source_result_root: Path,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
identity = item.get("identity")
|
||||||
|
if not isinstance(identity, dict):
|
||||||
|
raise LaboratoryEvidenceError("E29 identity is unavailable")
|
||||||
|
model_id = identity.get("local_surface_model_id")
|
||||||
|
pack_id = identity.get("source_pack_id")
|
||||||
|
source_result_id = identity.get("source_result_id")
|
||||||
|
if (
|
||||||
|
not isinstance(model_id, str)
|
||||||
|
or _LOCAL_SURFACE_ID.fullmatch(model_id) is None
|
||||||
|
or not isinstance(pack_id, str)
|
||||||
|
or _SOURCE_PACK_ID.fullmatch(pack_id) is None
|
||||||
|
or not isinstance(source_result_id, str)
|
||||||
|
or _SOURCE_RESULT_ID.fullmatch(source_result_id) is None
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 linked evidence ids are invalid")
|
||||||
|
model_root = local_surface_root / model_id
|
||||||
|
pack_root = source_pack_root / pack_id
|
||||||
|
result_root = source_result_root / source_result_id
|
||||||
|
required = (
|
||||||
|
model_root / "manifest.json",
|
||||||
|
model_root / "local-surface.json",
|
||||||
|
model_root / "local-surface.npz",
|
||||||
|
pack_root / "manifest.json",
|
||||||
|
pack_root / "lidar-pack.npz",
|
||||||
|
result_root / "result.json",
|
||||||
|
result_root / "fusion-frames.jsonl",
|
||||||
|
)
|
||||||
|
if any(path.is_symlink() or not path.is_file() for path in required):
|
||||||
|
raise LaboratoryEvidenceError("E29 linked evidence is incomplete")
|
||||||
|
local_surface_report = _read_json(
|
||||||
|
model_root / "local-surface.json",
|
||||||
|
maximum_bytes=_MAX_REPORT_BYTES,
|
||||||
|
)
|
||||||
|
session_id = local_surface_report.get("session_id")
|
||||||
|
if (
|
||||||
|
local_surface_report.get("model_id") != model_id
|
||||||
|
or not isinstance(session_id, str)
|
||||||
|
or _SESSION_ID.fullmatch(session_id) is None
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 source session is invalid")
|
||||||
|
return {
|
||||||
|
"source_session_id": session_id,
|
||||||
|
"local_surface_model_id": model_id,
|
||||||
|
"source_pack_id": pack_id,
|
||||||
|
"source_result_id": source_result_id,
|
||||||
|
"published_on_control_plane": True,
|
||||||
|
"worker_copy_required": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _roots(
|
||||||
|
*,
|
||||||
|
e29_root_provider: RootProvider,
|
||||||
|
local_surface_root_provider: RootProvider,
|
||||||
|
source_pack_root_provider: RootProvider,
|
||||||
|
source_result_root_provider: RootProvider,
|
||||||
|
) -> tuple[Path, Path, Path, Path] | None:
|
||||||
|
values = (
|
||||||
|
e29_root_provider(),
|
||||||
|
local_surface_root_provider(),
|
||||||
|
source_pack_root_provider(),
|
||||||
|
source_result_root_provider(),
|
||||||
|
)
|
||||||
|
if any(value is None for value in values):
|
||||||
|
return None
|
||||||
|
roots = tuple(value.resolve() for value in values if value is not None)
|
||||||
|
if len(roots) != 4 or any(not root.is_dir() for root in roots):
|
||||||
|
return None
|
||||||
|
return roots[0], roots[1], roots[2], roots[3]
|
||||||
|
|
||||||
|
|
||||||
|
def build_laboratory_router(
|
||||||
|
*,
|
||||||
|
e29_root_provider: RootProvider = lambda: None,
|
||||||
|
local_surface_root_provider: RootProvider = lambda: None,
|
||||||
|
source_pack_root_provider: RootProvider = lambda: None,
|
||||||
|
source_result_root_provider: RootProvider = lambda: None,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||||
|
|
||||||
|
@router.get("/e29/results")
|
||||||
|
def list_e29_results(
|
||||||
|
limit: int = Query(default=1, ge=1, le=10),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
roots = _roots(
|
||||||
|
e29_root_provider=e29_root_provider,
|
||||||
|
local_surface_root_provider=local_surface_root_provider,
|
||||||
|
source_pack_root_provider=source_pack_root_provider,
|
||||||
|
source_result_root_provider=source_result_root_provider,
|
||||||
|
)
|
||||||
|
if roots is None:
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_E29_CATALOG_SCHEMA,
|
||||||
|
"configured": False,
|
||||||
|
"items": [],
|
||||||
|
"candidate_total": 0,
|
||||||
|
"invalid_total": 0,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
e29_root, local_surface_root, source_pack_root, source_result_root = roots
|
||||||
|
candidates = sorted(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in e29_root.iterdir()
|
||||||
|
if candidate.is_dir()
|
||||||
|
and _E29_RESULT_ID.fullmatch(candidate.name) is not None
|
||||||
|
),
|
||||||
|
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
items: list[dict[str, object]] = []
|
||||||
|
invalid_total = 0
|
||||||
|
for candidate in candidates:
|
||||||
|
try:
|
||||||
|
item, _ = _read_result_cached(
|
||||||
|
str(candidate.resolve()),
|
||||||
|
_result_signature(candidate),
|
||||||
|
)
|
||||||
|
document = copy.deepcopy(item)
|
||||||
|
document["linked_evidence"] = _linked_evidence(
|
||||||
|
document,
|
||||||
|
local_surface_root=local_surface_root,
|
||||||
|
source_pack_root=source_pack_root,
|
||||||
|
source_result_root=source_result_root,
|
||||||
|
)
|
||||||
|
if len(items) < limit:
|
||||||
|
items.append(document)
|
||||||
|
except (LaboratoryEvidenceError, OSError):
|
||||||
|
invalid_total += 1
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_E29_CATALOG_SCHEMA,
|
||||||
|
"configured": True,
|
||||||
|
"items": items,
|
||||||
|
"candidate_total": len(candidates),
|
||||||
|
"invalid_total": invalid_total,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/e29/results/{result_id}")
|
||||||
|
def get_e29_result(result_id: str) -> dict[str, object]:
|
||||||
|
roots = _roots(
|
||||||
|
e29_root_provider=e29_root_provider,
|
||||||
|
local_surface_root_provider=local_surface_root_provider,
|
||||||
|
source_pack_root_provider=source_pack_root_provider,
|
||||||
|
source_result_root_provider=source_result_root_provider,
|
||||||
|
)
|
||||||
|
if roots is None or _E29_RESULT_ID.fullmatch(result_id) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="LAB E29 result не найден")
|
||||||
|
e29_root, local_surface_root, source_pack_root, source_result_root = roots
|
||||||
|
candidate = e29_root / result_id
|
||||||
|
if not candidate.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="LAB E29 result не найден")
|
||||||
|
try:
|
||||||
|
item, _ = _read_result_cached(
|
||||||
|
str(candidate.resolve()),
|
||||||
|
_result_signature(candidate),
|
||||||
|
)
|
||||||
|
document = copy.deepcopy(item)
|
||||||
|
document["linked_evidence"] = _linked_evidence(
|
||||||
|
document,
|
||||||
|
local_surface_root=local_surface_root,
|
||||||
|
source_pack_root=source_pack_root,
|
||||||
|
source_result_root=source_result_root,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_E29_DETAIL_SCHEMA,
|
||||||
|
"result": document,
|
||||||
|
}
|
||||||
|
except (LaboratoryEvidenceError, OSError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="LAB E29 evidence не прошло проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
@router.get("/e29/results/{result_id}/frames/{frame_index}")
|
||||||
|
def get_e29_frame(result_id: str, frame_index: int) -> dict[str, object]:
|
||||||
|
roots = _roots(
|
||||||
|
e29_root_provider=e29_root_provider,
|
||||||
|
local_surface_root_provider=local_surface_root_provider,
|
||||||
|
source_pack_root_provider=source_pack_root_provider,
|
||||||
|
source_result_root_provider=source_result_root_provider,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
roots is None
|
||||||
|
or _E29_RESULT_ID.fullmatch(result_id) is None
|
||||||
|
or frame_index < 0
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="LAB E29 frame не найден")
|
||||||
|
e29_root, local_surface_root, source_pack_root, source_result_root = roots
|
||||||
|
candidate = e29_root / result_id
|
||||||
|
if not candidate.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail="LAB E29 frame не найден")
|
||||||
|
try:
|
||||||
|
item, offsets = _read_result_cached(
|
||||||
|
str(candidate.resolve()),
|
||||||
|
_result_signature(candidate),
|
||||||
|
)
|
||||||
|
_linked_evidence(
|
||||||
|
item,
|
||||||
|
local_surface_root=local_surface_root,
|
||||||
|
source_pack_root=source_pack_root,
|
||||||
|
source_result_root=source_result_root,
|
||||||
|
)
|
||||||
|
if frame_index >= len(offsets):
|
||||||
|
raise HTTPException(status_code=404, detail="LAB E29 frame не найден")
|
||||||
|
frames_path = candidate / CAMERA_GEOMETRY_FRAMES_NAME
|
||||||
|
with frames_path.open("rb") as stream:
|
||||||
|
stream.seek(offsets[frame_index])
|
||||||
|
raw = stream.readline(_MAX_FRAME_BYTES + 1)
|
||||||
|
if not raw or len(raw) > _MAX_FRAME_BYTES:
|
||||||
|
raise LaboratoryEvidenceError("E29 frame is out of bounds")
|
||||||
|
row = json.loads(raw)
|
||||||
|
if (
|
||||||
|
not isinstance(row, dict)
|
||||||
|
or row.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA
|
||||||
|
or row.get("frame_index") != frame_index
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceError("E29 frame identity changed")
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_E29_FRAME_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"frame": row,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (LaboratoryEvidenceError, OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="LAB E29 frame не прошёл проверку целостности",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -528,6 +528,8 @@ def build_lidar_router(
|
|||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
try:
|
try:
|
||||||
model = K1LocalSurfaceV1(candidate)
|
model = K1LocalSurfaceV1(candidate)
|
||||||
try:
|
try:
|
||||||
@@ -539,9 +541,11 @@ def build_lidar_router(
|
|||||||
return {
|
return {
|
||||||
"schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA,
|
"schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA,
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"items": items[:limit],
|
"items": items,
|
||||||
"valid_total": len(items),
|
"valid_total": len(items),
|
||||||
"invalid_total": invalid_total,
|
"invalid_total": invalid_total,
|
||||||
|
"candidate_total": len(candidates),
|
||||||
|
"scan_complete": len(items) + invalid_total == len(candidates),
|
||||||
"access": "read-only",
|
"access": "read-only",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.compute.semantic_geometry_fusion import (
|
||||||
|
CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||||
|
CAMERA_GEOMETRY_FRAMES_NAME,
|
||||||
|
CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||||
|
CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||||
|
CAMERA_GEOMETRY_REPORT_NAME,
|
||||||
|
CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||||
|
)
|
||||||
|
from k1link.web.laboratory_api import build_laboratory_router
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
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 _write_json(path: Path, value: object) -> None:
|
||||||
|
path.write_bytes(_canonical_json(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _laboratory_evidence(tmp_path: Path) -> tuple[Path, Path, Path, Path, str]:
|
||||||
|
hash_a = "a" * 64
|
||||||
|
hash_b = "b" * 64
|
||||||
|
hash_c = "c" * 64
|
||||||
|
model_id = f"k1-local-surface-{hash_a}"
|
||||||
|
source_pack_id = f"e10-lidar-pack-{hash_b}"
|
||||||
|
source_result_id = f"e10-integrated-perception-{hash_c}"
|
||||||
|
identity = {
|
||||||
|
"schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||||
|
"local_surface_model_id": model_id,
|
||||||
|
"source_pack_id": source_pack_id,
|
||||||
|
"source_result_id": source_result_id,
|
||||||
|
"frame_count": 2,
|
||||||
|
}
|
||||||
|
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||||
|
result_id = f"e29-camera-geometry-{identity_sha256}"
|
||||||
|
|
||||||
|
e29_root = tmp_path / "e29"
|
||||||
|
result_root = e29_root / result_id
|
||||||
|
result_root.mkdir(parents=True)
|
||||||
|
frames = [
|
||||||
|
{
|
||||||
|
"schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||||
|
"frame_index": 0,
|
||||||
|
"source_frame_index": 100,
|
||||||
|
"session_seconds": 10.0,
|
||||||
|
"semantic_observations": [
|
||||||
|
{
|
||||||
|
"track_id": 7,
|
||||||
|
"semantic_class": "car",
|
||||||
|
"geometry_status": "conflict",
|
||||||
|
"range_m": 4.5,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"geometry_only_occupied": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||||
|
"frame_index": 1,
|
||||||
|
"source_frame_index": 101,
|
||||||
|
"session_seconds": 10.1,
|
||||||
|
"semantic_observations": [],
|
||||||
|
"geometry_only_occupied": [{"cluster_id": 8, "range_m": 2.0}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
frames_path = result_root / CAMERA_GEOMETRY_FRAMES_NAME
|
||||||
|
frames_path.write_bytes(b"".join(_canonical_json(row) + b"\n" for row in frames))
|
||||||
|
report = {
|
||||||
|
"schema_version": CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity": identity,
|
||||||
|
"status": "diagnostic-replay-complete",
|
||||||
|
"ground_truth": False,
|
||||||
|
"metrics": {
|
||||||
|
"frames": {"total": 2},
|
||||||
|
"semantic_observations": {
|
||||||
|
"total": 1,
|
||||||
|
"geometry_agree": 0,
|
||||||
|
"camera_only": 0,
|
||||||
|
"conflict": 1,
|
||||||
|
},
|
||||||
|
"geometry_only": {"cluster_count": 1},
|
||||||
|
"runtime": {"postprocess_p95_ms": 1.2, "build_elapsed_ms": 3.4},
|
||||||
|
},
|
||||||
|
"decision": {"status": "replay-experiment-only"},
|
||||||
|
"limitations": ["synthetic evidence"],
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
report_path = result_root / CAMERA_GEOMETRY_REPORT_NAME
|
||||||
|
_write_json(report_path, report)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||||
|
"result_id": result_id,
|
||||||
|
"created_at_utc": "2026-07-26T00:00:00Z",
|
||||||
|
"identity": identity,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"role": "camera-geometry-frames",
|
||||||
|
"path": frames_path.name,
|
||||||
|
"byte_length": frames_path.stat().st_size,
|
||||||
|
"sha256": _sha256(frames_path),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "camera-geometry-report",
|
||||||
|
"path": report_path.name,
|
||||||
|
"byte_length": report_path.stat().st_size,
|
||||||
|
"sha256": _sha256(report_path),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_json(result_root / CAMERA_GEOMETRY_MANIFEST_NAME, manifest)
|
||||||
|
|
||||||
|
local_surface_root = tmp_path / "local-surface"
|
||||||
|
model_root = local_surface_root / model_id
|
||||||
|
model_root.mkdir(parents=True)
|
||||||
|
_write_json(model_root / "manifest.json", {"model_id": model_id})
|
||||||
|
_write_json(
|
||||||
|
model_root / "local-surface.json",
|
||||||
|
{
|
||||||
|
"model_id": model_id,
|
||||||
|
"session_id": "20260720T065719Z_viewer_live",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
(model_root / "local-surface.npz").write_bytes(b"verified-model")
|
||||||
|
|
||||||
|
source_pack_root = tmp_path / "source-pack"
|
||||||
|
pack_root = source_pack_root / source_pack_id
|
||||||
|
pack_root.mkdir(parents=True)
|
||||||
|
_write_json(pack_root / "manifest.json", {"pack_id": source_pack_id})
|
||||||
|
(pack_root / "lidar-pack.npz").write_bytes(b"verified-source-pack")
|
||||||
|
|
||||||
|
source_result_root = tmp_path / "source-result"
|
||||||
|
source_root = source_result_root / source_result_id
|
||||||
|
source_root.mkdir(parents=True)
|
||||||
|
_write_json(source_root / "result.json", {"result_id": source_result_id})
|
||||||
|
(source_root / "fusion-frames.jsonl").write_bytes(b"{}\n")
|
||||||
|
return (
|
||||||
|
e29_root,
|
||||||
|
local_surface_root,
|
||||||
|
source_pack_root,
|
||||||
|
source_result_root,
|
||||||
|
result_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laboratory_catalog_and_frame_require_verified_linked_evidence(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
e29_root, local_root, pack_root, source_root, result_id = (
|
||||||
|
_laboratory_evidence(tmp_path)
|
||||||
|
)
|
||||||
|
router = build_laboratory_router(
|
||||||
|
e29_root_provider=lambda: e29_root,
|
||||||
|
local_surface_root_provider=lambda: local_root,
|
||||||
|
source_pack_root_provider=lambda: pack_root,
|
||||||
|
source_result_root_provider=lambda: source_root,
|
||||||
|
)
|
||||||
|
catalog_route = _endpoint(router, "/api/v1/laboratory/e29/results")
|
||||||
|
frame_route = _endpoint(
|
||||||
|
router,
|
||||||
|
"/api/v1/laboratory/e29/results/{result_id}/frames/{frame_index}",
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = catalog_route(limit=1) # type: ignore[operator]
|
||||||
|
frame = frame_route(result_id=result_id, frame_index=0) # type: ignore[operator]
|
||||||
|
|
||||||
|
assert catalog["configured"] is True
|
||||||
|
assert catalog["candidate_total"] == 1
|
||||||
|
assert catalog["invalid_total"] == 0
|
||||||
|
assert len(catalog["items"]) == 1
|
||||||
|
item = catalog["items"][0]
|
||||||
|
assert item["result_id"] == result_id
|
||||||
|
assert item["linked_evidence"]["source_session_id"] == (
|
||||||
|
"20260720T065719Z_viewer_live"
|
||||||
|
)
|
||||||
|
assert item["linked_evidence"]["published_on_control_plane"] is True
|
||||||
|
assert frame["frame"]["semantic_observations"][0]["geometry_status"] == (
|
||||||
|
"conflict"
|
||||||
|
)
|
||||||
|
assert str(tmp_path) not in repr(catalog)
|
||||||
|
assert str(tmp_path) not in repr(frame)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laboratory_catalog_hides_tampered_or_incomplete_results(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
e29_root, local_root, pack_root, source_root, result_id = (
|
||||||
|
_laboratory_evidence(tmp_path)
|
||||||
|
)
|
||||||
|
frames_path = e29_root / result_id / CAMERA_GEOMETRY_FRAMES_NAME
|
||||||
|
frames_path.write_bytes(frames_path.read_bytes() + b"{}\n")
|
||||||
|
router = build_laboratory_router(
|
||||||
|
e29_root_provider=lambda: e29_root,
|
||||||
|
local_surface_root_provider=lambda: local_root,
|
||||||
|
source_pack_root_provider=lambda: pack_root,
|
||||||
|
source_result_root_provider=lambda: source_root,
|
||||||
|
)
|
||||||
|
catalog_route = _endpoint(router, "/api/v1/laboratory/e29/results")
|
||||||
|
|
||||||
|
tampered = catalog_route(limit=1) # type: ignore[operator]
|
||||||
|
assert tampered["candidate_total"] == 1
|
||||||
|
assert tampered["invalid_total"] == 1
|
||||||
|
assert tampered["items"] == []
|
||||||
|
|
||||||
|
frames_path.write_bytes(frames_path.read_bytes()[:-3])
|
||||||
|
(
|
||||||
|
local_root
|
||||||
|
/ f"k1-local-surface-{'a' * 64}"
|
||||||
|
/ "local-surface.npz"
|
||||||
|
).unlink()
|
||||||
|
incomplete = catalog_route(limit=1) # type: ignore[operator]
|
||||||
|
assert incomplete["candidate_total"] == 1
|
||||||
|
assert incomplete["invalid_total"] == 1
|
||||||
|
assert incomplete["items"] == []
|
||||||
Reference in New Issue
Block a user