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 } = {},
|
||||
): Promise<LidarLocalSurfaceCatalog> {
|
||||
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",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
|
||||
@@ -2592,6 +2592,111 @@
|
||||
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) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -2601,6 +2706,14 @@
|
||||
.laboratory-result-metrics {
|
||||
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 {
|
||||
|
||||
@@ -44,6 +44,16 @@ import type {
|
||||
MissionRuntimeState,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../core/runtime/contracts";
|
||||
import {
|
||||
fetchE29EvidenceCatalog,
|
||||
fetchE29EvidenceFrame,
|
||||
type E29EvidenceFrame,
|
||||
type E29EvidenceResult,
|
||||
} from "../core/laboratory/e29Evidence";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../core/lidar/localSurface";
|
||||
import {
|
||||
RerunViewport,
|
||||
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 (
|
||||
<>
|
||||
<LaboratoryTask
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Проверяется рабочая продуктовая граница: камера сохраняет семантический класс и идентичность объекта, LiDAR подтверждает метрическую дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Диагностический replay"
|
||||
status="Проверенные артефакты"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{ label: "Источник", value: "RAVNOVES00 · 4 489 кадров" },
|
||||
{ label: "Объектов", value: "20 513 наблюдений" },
|
||||
{ label: "Полномочия", value: "Только shadow · команды запрещены" },
|
||||
{
|
||||
label: "Источник",
|
||||
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">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Архитектурная схема подтверждена, safety-gate не пройден</h2>
|
||||
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone="success">Replay завершён</StatusBadge>
|
||||
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
<div><span>Поддержка геометрией</span><strong>6 341</strong><small>32,31% current observations</small></div>
|
||||
<div><span>Camera-only</span><strong>13 246</strong><small>Семантика сохранена</small></div>
|
||||
<div><span>Postprocess p95</span><strong>2,517 мс</strong><small>8,467 с полный build</small></div>
|
||||
<div><span>Geometry-only</span><strong>21 321</strong><small>Занято / класс неизвестен</small></div>
|
||||
<div>
|
||||
<span>Поддержка геометрией</span>
|
||||
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||
<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>
|
||||
<p>
|
||||
Следующий gate — покадровый разбор конфликтов, camera-only пробелов и
|
||||
geometry-only компонентов, затем source-paced shadow с deadline и staleness.
|
||||
</p>
|
||||
<p>{result.decision.nextGate}</p>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
@@ -1421,6 +1592,10 @@ function PublishedLaboratoryResult({
|
||||
function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
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({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
@@ -1429,9 +1604,47 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
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],
|
||||
);
|
||||
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(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
@@ -1442,22 +1655,44 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const profiles: readonly LaboratoryOption<LaboratoryProfileId>[] = [
|
||||
{
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · worker D`,
|
||||
},
|
||||
{
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
},
|
||||
];
|
||||
const sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
});
|
||||
}
|
||||
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>[] =
|
||||
profileId === "sensor-fusion"
|
||||
? [
|
||||
{ id: "e28-local-surface", label: "LAB E28 · локальная поверхность L2.6" },
|
||||
{ id: "e29-camera-geometry", label: "LAB E29 · camera-first + geometry" },
|
||||
]
|
||||
? sensorWorks
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
@@ -1468,18 +1703,49 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
const e29SourceSession = e29Result
|
||||
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (profileId !== "published-perception" || workId.startsWith("session:")) return;
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
setWorkId(`session:${first.id}`);
|
||||
}, [profileId, publishedWorks, workId]);
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) 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) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
setWorkId("e28-local-surface");
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
@@ -1491,10 +1757,41 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (!next.startsWith("session:")) return;
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
if (next.startsWith("session:")) {
|
||||
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 (
|
||||
<div className="lab-archive-workspace">
|
||||
<LaboratorySelector
|
||||
@@ -1524,12 +1821,15 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
<LaboratoryTask
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Полная запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает локальную поверхность, наблюдаемые препятствия, временные скачки и ошибки предсказания без изменения исходного сенсора и без командного канала."
|
||||
status="Диагностический replay"
|
||||
status="Проверенные артефакты"
|
||||
facts={[
|
||||
{ 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: "Навигация и команды запрещены" },
|
||||
{ label: "Артефакты", value: "Control plane · read-only · hash verified" },
|
||||
]}
|
||||
/>
|
||||
<LidarQualityWorkspace
|
||||
@@ -1538,8 +1838,19 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</>
|
||||
) : workId === "e29-camera-geometry" ? (
|
||||
<E29LaboratoryResult rigLabel={rigLabel} />
|
||||
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||
<E29LaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e29Result}
|
||||
sourceSession={e29SourceSession}
|
||||
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||
error={
|
||||
sessions.failedSessionId === e29SourceSession.id
|
||||
? sessions.error
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
@@ -1550,8 +1861,8 @@ function LabArchiveWorkspace(props: WorkspaceRendererProps) {
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>В этом профиле пока нет опубликованных работ</strong>
|
||||
<p>Работы появятся после публикации проверенного LAB-provenance.</p>
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</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.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.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
for candidate in candidates:
|
||||
if len(items) >= limit:
|
||||
break
|
||||
try:
|
||||
model = K1LocalSurfaceV1(candidate)
|
||||
try:
|
||||
@@ -539,9 +541,11 @@ def build_lidar_router(
|
||||
return {
|
||||
"schema_version": K1_LOCAL_SURFACE_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items[:limit],
|
||||
"items": items,
|
||||
"valid_total": len(items),
|
||||
"invalid_total": invalid_total,
|
||||
"candidate_total": len(candidates),
|
||||
"scan_complete": len(items) + invalid_total == len(candidates),
|
||||
"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