feat(perception): qualify E35 degradation recovery
This commit is contained in:
@@ -94,6 +94,7 @@ export interface AdvancedLaboratoryResults {
|
||||
e32: E32LaboratoryResult | null;
|
||||
e33: E33LaboratoryResult | null;
|
||||
e34: E34TemporalLayerResult | null;
|
||||
e35: E35DegradationRecoveryResult | null;
|
||||
}
|
||||
|
||||
export class AdvancedLaboratoryContractError extends Error {
|
||||
@@ -372,15 +373,20 @@ export async function fetchAdvancedLaboratoryResults({
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<AdvancedLaboratoryResults> {
|
||||
const [e31, e32, e33, e34] = await Promise.all([
|
||||
const [e31, e32, e33, e34, e35] = await Promise.all([
|
||||
fetchOne("/api/v1/laboratory/e31/results?limit=1", parseE31, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e32/results?limit=1", parseE32, fetcher, signal),
|
||||
fetchOne("/api/v1/laboratory/e33/results?limit=1", parseE33, fetcher, signal),
|
||||
fetchE34TemporalLayerResult({ fetcher, signal }),
|
||||
fetchE35DegradationRecoveryResult({ fetcher, signal }),
|
||||
]);
|
||||
return { e31, e32, e33, e34 };
|
||||
return { e31, e32, e33, e34, e35 };
|
||||
}
|
||||
import {
|
||||
fetchE34TemporalLayerResult,
|
||||
type E34TemporalLayerResult,
|
||||
} from "./e34TemporalLayer";
|
||||
import {
|
||||
fetchE35DegradationRecoveryResult,
|
||||
type E35DegradationRecoveryResult,
|
||||
} from "./e35DegradationRecovery";
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import type {
|
||||
E34OccupancyState,
|
||||
E34OwnerKind,
|
||||
E34Point3,
|
||||
E34TemporalState,
|
||||
} from "./e34TemporalLayer";
|
||||
|
||||
export type E35DegradationKind =
|
||||
| "camera-loss"
|
||||
| "lidar-loss"
|
||||
| "pose-staleness"
|
||||
| "delayed-frames"
|
||||
| "bounded-drop"
|
||||
| "timing-offset";
|
||||
|
||||
export type E35FaultPhase = "before" | "during" | "after";
|
||||
|
||||
export interface E35ScenarioMetrics {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
framesProcessed: number;
|
||||
injectedFrames: number;
|
||||
droppedFrames: number;
|
||||
hiddenSuccessFrames: number;
|
||||
falseFreeRows: number;
|
||||
semanticClaimsDuringCameraLoss: number;
|
||||
metricRowsDuringLidarOrPoseLoss: number;
|
||||
agreeClaimsDuringTimingOffset: number;
|
||||
lateResultsReintroduced: number;
|
||||
maximumCurrentComponentsDuringFault: number;
|
||||
maximumHeldComponentsDuringFault: number;
|
||||
expiredComponentsDuringFault: number;
|
||||
recoveryFrameIndex: number;
|
||||
recoverySeconds: number;
|
||||
}
|
||||
|
||||
export interface E35TemporalComponent {
|
||||
temporalId: number;
|
||||
state: E34TemporalState;
|
||||
occupancyState: E34OccupancyState;
|
||||
ownerKind: E34OwnerKind;
|
||||
semanticLabels: readonly string[];
|
||||
centroidMapXyzM: E34Point3;
|
||||
lastObservedAgeSeconds: number;
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewFrame {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameIndex: number;
|
||||
sessionSeconds: number;
|
||||
faultPhase: E35FaultPhase;
|
||||
action: string;
|
||||
channels: Readonly<Record<string, string>>;
|
||||
inputPointRows: number;
|
||||
transformedPointRows: number;
|
||||
layerState: "current" | "held" | "unknown";
|
||||
counts: {
|
||||
current: number;
|
||||
held: number;
|
||||
expired: number;
|
||||
};
|
||||
components: readonly E35TemporalComponent[];
|
||||
cellCentersMapXyzM: readonly E34Point3[];
|
||||
}
|
||||
|
||||
export interface E35RecoveryReviewScenario {
|
||||
scenarioId: E35DegradationKind;
|
||||
kind: E35DegradationKind;
|
||||
frameStart: number;
|
||||
frameEnd: number;
|
||||
frames: readonly E35RecoveryReviewFrame[];
|
||||
}
|
||||
|
||||
export interface E35DegradationRecoveryResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
sourceSessionId: string;
|
||||
status: "accepted-deterministic-degradation-recovery";
|
||||
e32ResultId: string;
|
||||
e33ResultId: string;
|
||||
e34ResultId: string;
|
||||
profileId: string;
|
||||
pipelineId: string;
|
||||
coordinateFrame: "map";
|
||||
configuration: {
|
||||
maximumRecoverySeconds: number;
|
||||
scenarioCount: number;
|
||||
};
|
||||
metrics: {
|
||||
sourceFrames: number;
|
||||
variantCount: number;
|
||||
variantFrameOutcomes: number;
|
||||
injectionRecords: number;
|
||||
variantFrameProcessingP95Ms: number;
|
||||
buildElapsedMs: number;
|
||||
};
|
||||
scenarios: readonly E35ScenarioMetrics[];
|
||||
reviewScenarios: readonly E35RecoveryReviewScenario[];
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
type LaboratoryFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class E35DegradationRecoveryContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E35DegradationRecoveryContractError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function list(value: unknown, label: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалась строка.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось неотрицательное число.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидалось целое число.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function exactString<T extends string>(
|
||||
value: unknown,
|
||||
expected: T,
|
||||
label: string,
|
||||
): T {
|
||||
if (value !== expected) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(
|
||||
value: unknown,
|
||||
expected: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !expected.includes(value as T)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверное значение.`,
|
||||
);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: неверный content id.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function point(value: unknown, label: string): E34Point3 {
|
||||
const coordinates = list(value, label);
|
||||
if (coordinates.length !== 3) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: ожидались XYZ.`,
|
||||
);
|
||||
}
|
||||
const result = coordinates.map(
|
||||
(coordinate, index) => {
|
||||
if (typeof coordinate !== "number" || !Number.isFinite(coordinate)) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}[${index}]: ожидалось число.`,
|
||||
);
|
||||
}
|
||||
return coordinate;
|
||||
},
|
||||
);
|
||||
return [result[0]!, result[1]!, result[2]!];
|
||||
}
|
||||
|
||||
const DEGRADATION_KINDS = [
|
||||
"camera-loss",
|
||||
"lidar-loss",
|
||||
"pose-staleness",
|
||||
"delayed-frames",
|
||||
"bounded-drop",
|
||||
"timing-offset",
|
||||
] as const;
|
||||
|
||||
function degradationKind(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35DegradationKind {
|
||||
return oneOf(value, DEGRADATION_KINDS, label);
|
||||
}
|
||||
|
||||
function parseComponent(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35TemporalComponent {
|
||||
const item = record(value, label);
|
||||
return {
|
||||
temporalId: integerValue(item.temporal_id, `${label}.temporal_id`),
|
||||
state: oneOf(
|
||||
item.state,
|
||||
["current", "held", "expired"] as const,
|
||||
`${label}.state`,
|
||||
),
|
||||
occupancyState: oneOf(
|
||||
item.occupancy_state,
|
||||
["occupied", "unknown"] as const,
|
||||
`${label}.occupancy_state`,
|
||||
),
|
||||
ownerKind: oneOf(
|
||||
item.owner_kind,
|
||||
["camera-track", "geometry-cluster"] as const,
|
||||
`${label}.owner_kind`,
|
||||
),
|
||||
semanticLabels: list(
|
||||
item.semantic_labels,
|
||||
`${label}.semantic_labels`,
|
||||
).map((entry, index) => stringValue(
|
||||
entry,
|
||||
`${label}.semantic_labels[${index}]`,
|
||||
)),
|
||||
centroidMapXyzM: point(
|
||||
item.centroid_map_xyz_m,
|
||||
`${label}.centroid_map_xyz_m`,
|
||||
),
|
||||
lastObservedAgeSeconds: numberValue(
|
||||
item.last_observed_age_seconds,
|
||||
`${label}.last_observed_age_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewFrame(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewFrame {
|
||||
const item = record(value, label);
|
||||
const counts = record(item.counts, `${label}.counts`);
|
||||
const channels = record(item.channels, `${label}.channels`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
item.scenario_id,
|
||||
`${label}.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameIndex: integerValue(item.frame_index, `${label}.frame_index`),
|
||||
sessionSeconds: numberValue(
|
||||
item.session_seconds,
|
||||
`${label}.session_seconds`,
|
||||
),
|
||||
faultPhase: oneOf(
|
||||
item.fault_phase,
|
||||
["before", "during", "after"] as const,
|
||||
`${label}.fault_phase`,
|
||||
),
|
||||
action: stringValue(item.action, `${label}.action`),
|
||||
channels: Object.fromEntries(
|
||||
Object.entries(channels).map(([key, entry]) => [
|
||||
key,
|
||||
stringValue(entry, `${label}.channels.${key}`),
|
||||
]),
|
||||
),
|
||||
inputPointRows: integerValue(
|
||||
item.input_point_rows,
|
||||
`${label}.input_point_rows`,
|
||||
),
|
||||
transformedPointRows: integerValue(
|
||||
item.transformed_point_rows,
|
||||
`${label}.transformed_point_rows`,
|
||||
),
|
||||
layerState: oneOf(
|
||||
item.layer_state,
|
||||
["current", "held", "unknown"] as const,
|
||||
`${label}.layer_state`,
|
||||
),
|
||||
counts: {
|
||||
current: integerValue(counts.current, `${label}.counts.current`),
|
||||
held: integerValue(counts.held, `${label}.counts.held`),
|
||||
expired: integerValue(counts.expired, `${label}.counts.expired`),
|
||||
},
|
||||
components: list(item.components, `${label}.components`).map(
|
||||
(entry, index) => parseComponent(
|
||||
entry,
|
||||
`${label}.components[${index}]`,
|
||||
),
|
||||
),
|
||||
cellCentersMapXyzM: list(
|
||||
item.cell_centers_map_xyz_m,
|
||||
`${label}.cell_centers_map_xyz_m`,
|
||||
).map((entry, index) => point(
|
||||
entry,
|
||||
`${label}.cell_centers_map_xyz_m[${index}]`,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReviewScenario(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35RecoveryReviewScenario {
|
||||
const item = record(value, label);
|
||||
const scenario = record(item.scenario, `${label}.scenario`);
|
||||
return {
|
||||
scenarioId: degradationKind(
|
||||
scenario.scenario_id,
|
||||
`${label}.scenario.scenario_id`,
|
||||
),
|
||||
kind: degradationKind(
|
||||
scenario.kind,
|
||||
`${label}.scenario.kind`,
|
||||
),
|
||||
frameStart: integerValue(
|
||||
scenario.frame_start,
|
||||
`${label}.scenario.frame_start`,
|
||||
),
|
||||
frameEnd: integerValue(
|
||||
scenario.frame_end,
|
||||
`${label}.scenario.frame_end`,
|
||||
),
|
||||
frames: list(item.frames, `${label}.frames`).map(
|
||||
(entry, index) => parseReviewFrame(
|
||||
entry,
|
||||
`${label}.frames[${index}]`,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseScenarioMetrics(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E35ScenarioMetrics {
|
||||
const item = record(value, label);
|
||||
const integer = (key: string) => integerValue(item[key], `${label}.${key}`);
|
||||
return {
|
||||
scenarioId: degradationKind(item.scenario_id, `${label}.scenario_id`),
|
||||
kind: degradationKind(item.kind, `${label}.kind`),
|
||||
frameStart: integer("frame_start"),
|
||||
frameEnd: integer("frame_end"),
|
||||
framesProcessed: integer("frames_processed"),
|
||||
injectedFrames: integer("injected_frames"),
|
||||
droppedFrames: integer("dropped_frames"),
|
||||
hiddenSuccessFrames: integer("hidden_success_frames"),
|
||||
falseFreeRows: integer("false_free_rows"),
|
||||
semanticClaimsDuringCameraLoss: integer(
|
||||
"semantic_claims_during_camera_loss",
|
||||
),
|
||||
metricRowsDuringLidarOrPoseLoss: integer(
|
||||
"metric_rows_during_lidar_or_pose_loss",
|
||||
),
|
||||
agreeClaimsDuringTimingOffset: integer(
|
||||
"agree_claims_during_timing_offset",
|
||||
),
|
||||
lateResultsReintroduced: integer("late_results_reintroduced"),
|
||||
maximumCurrentComponentsDuringFault: integer(
|
||||
"maximum_current_components_during_fault",
|
||||
),
|
||||
maximumHeldComponentsDuringFault: integer(
|
||||
"maximum_held_components_during_fault",
|
||||
),
|
||||
expiredComponentsDuringFault: integer(
|
||||
"expired_components_during_fault",
|
||||
),
|
||||
recoveryFrameIndex: integer("recovery_frame_index"),
|
||||
recoverySeconds: numberValue(
|
||||
item.recovery_seconds,
|
||||
`${label}.recovery_seconds`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticAuthority(value: unknown, label: string): void {
|
||||
const authority = record(value, label);
|
||||
if (
|
||||
authority.commands_enabled !== false
|
||||
|| authority.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`${label}: запрещённые полномочия.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E35DegradationRecoveryResult {
|
||||
const item = record(value, "E35");
|
||||
const configuration = record(item.configuration, "E35.configuration");
|
||||
const metrics = record(item.metrics, "E35.metrics");
|
||||
const review = record(item.review, "E35.review");
|
||||
const acceptance = record(item.acceptance, "E35.acceptance");
|
||||
diagnosticAuthority(item.authority, "E35.authority");
|
||||
if (acceptance.accepted !== true) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"E35.acceptance: результат отклонён.",
|
||||
);
|
||||
}
|
||||
exactString(
|
||||
review.schema_version,
|
||||
"missioncore.e35-recovery-review/v1",
|
||||
"E35.review.schema_version",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
item.result_id,
|
||||
"e35-degradation-recovery",
|
||||
"E35.result_id",
|
||||
),
|
||||
createdAtUtc: optionalString(item.created_at_utc, "E35.created_at_utc"),
|
||||
sourceSessionId: stringValue(
|
||||
item.source_session_id,
|
||||
"E35.source_session_id",
|
||||
),
|
||||
status: exactString(
|
||||
item.status,
|
||||
"accepted-deterministic-degradation-recovery",
|
||||
"E35.status",
|
||||
),
|
||||
e32ResultId: contentId(
|
||||
item.e32_result_id,
|
||||
"e32-track-geometry",
|
||||
"E35.e32_result_id",
|
||||
),
|
||||
e33ResultId: contentId(
|
||||
item.e33_result_id,
|
||||
"e33-worker-shadow",
|
||||
"E35.e33_result_id",
|
||||
),
|
||||
e34ResultId: contentId(
|
||||
item.e34_result_id,
|
||||
"e34-temporal-occupied",
|
||||
"E35.e34_result_id",
|
||||
),
|
||||
profileId: stringValue(item.profile_id, "E35.profile_id"),
|
||||
pipelineId: stringValue(item.pipeline_id, "E35.pipeline_id"),
|
||||
coordinateFrame: exactString(
|
||||
item.coordinate_frame,
|
||||
"map",
|
||||
"E35.coordinate_frame",
|
||||
),
|
||||
configuration: {
|
||||
maximumRecoverySeconds: numberValue(
|
||||
configuration.maximum_recovery_seconds,
|
||||
"E35.configuration.maximum_recovery_seconds",
|
||||
),
|
||||
scenarioCount: integerValue(
|
||||
configuration.scenario_count,
|
||||
"E35.configuration.scenario_count",
|
||||
),
|
||||
},
|
||||
metrics: {
|
||||
sourceFrames: integerValue(
|
||||
metrics.source_frames,
|
||||
"E35.metrics.source_frames",
|
||||
),
|
||||
variantCount: integerValue(
|
||||
metrics.variant_count,
|
||||
"E35.metrics.variant_count",
|
||||
),
|
||||
variantFrameOutcomes: integerValue(
|
||||
metrics.variant_frame_outcomes,
|
||||
"E35.metrics.variant_frame_outcomes",
|
||||
),
|
||||
injectionRecords: integerValue(
|
||||
metrics.injection_records,
|
||||
"E35.metrics.injection_records",
|
||||
),
|
||||
variantFrameProcessingP95Ms: numberValue(
|
||||
metrics.variant_frame_processing_p95_ms,
|
||||
"E35.metrics.variant_frame_processing_p95_ms",
|
||||
),
|
||||
buildElapsedMs: numberValue(
|
||||
metrics.build_elapsed_ms,
|
||||
"E35.metrics.build_elapsed_ms",
|
||||
),
|
||||
},
|
||||
scenarios: list(item.scenarios, "E35.scenarios").map(
|
||||
(entry, index) => parseScenarioMetrics(
|
||||
entry,
|
||||
`E35.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
reviewScenarios: list(review.scenarios, "E35.review.scenarios").map(
|
||||
(entry, index) => parseReviewScenario(
|
||||
entry,
|
||||
`E35.review.scenarios[${index}]`,
|
||||
),
|
||||
),
|
||||
access: exactString(item.access, "read-only", "E35.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchE35DegradationRecoveryResult({
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
} = {}): Promise<E35DegradationRecoveryResult | null> {
|
||||
const response = await fetcher(
|
||||
"/api/v1/laboratory/e35/results?limit=1",
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
`Каталог LAB E35 недоступен: HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
const catalog = record(await response.json(), "Каталог LAB E35");
|
||||
exactString(
|
||||
catalog.schema_version,
|
||||
"missioncore.laboratory-advanced-catalog/v1",
|
||||
"Каталог LAB E35.schema_version",
|
||||
);
|
||||
if (typeof catalog.configured !== "boolean") {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35.configured: ожидался boolean.",
|
||||
);
|
||||
}
|
||||
integerValue(catalog.candidate_total, "Каталог LAB E35.candidate_total");
|
||||
integerValue(catalog.invalid_total, "Каталог LAB E35.invalid_total");
|
||||
exactString(catalog.access, "read-only", "Каталог LAB E35.access");
|
||||
const items = list(catalog.items, "Каталог LAB E35.items");
|
||||
if (items.length > 1) {
|
||||
throw new E35DegradationRecoveryContractError(
|
||||
"Каталог LAB E35: нарушен limit=1.",
|
||||
);
|
||||
}
|
||||
return items.length ? parseResult(items[0]) : null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/e35-degradation-recovery.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
.e35-degradation-evidence {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__selectors {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__selectors .nodedc-select-anchor {
|
||||
width: clamp(10rem, 20vw, 15rem);
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__timeline {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 3.7rem;
|
||||
right: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
max-width: calc(100% - 1.2rem);
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__timeline .nodedc-button {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__telemetry {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: grid;
|
||||
width: min(19rem, calc(100% - 1.2rem));
|
||||
gap: 0.34rem;
|
||||
margin: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.55rem 0.65rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__telemetry > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__telemetry dt,
|
||||
.e35-degradation-evidence__telemetry dd {
|
||||
margin: 0;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__telemetry dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__telemetry dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.e35-degradation-evidence__timeline {
|
||||
top: 4rem;
|
||||
}
|
||||
|
||||
.e35-degradation-evidence__timeline .nodedc-button {
|
||||
padding-inline: 0.55rem;
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,15 @@ import { E31Result } from "./E31Result";
|
||||
import { E32Result } from "./E32Result";
|
||||
import { E33Result } from "./E33Result";
|
||||
import { E34Result } from "./E34Result";
|
||||
import { E35Result } from "./E35Result";
|
||||
import { RecordedReplayEvidence } from "./RecordedReplayEvidence";
|
||||
|
||||
export type AdvancedLaboratoryWorkId =
|
||||
| "e31-source-binding"
|
||||
| "e32-track-geometry"
|
||||
| "e33-worker-shadow"
|
||||
| "e34-temporal-layer";
|
||||
| "e34-temporal-layer"
|
||||
| "e35-degradation-recovery";
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
@@ -28,6 +30,7 @@ export function isAdvancedLaboratoryWorkId(
|
||||
|| value === "e32-track-geometry"
|
||||
|| value === "e33-worker-shadow"
|
||||
|| value === "e34-temporal-layer"
|
||||
|| value === "e35-degradation-recovery"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +63,12 @@ export function advancedLaboratoryWorkOptions(
|
||||
label: "LAB E34 · temporal occupied/unknown",
|
||||
});
|
||||
}
|
||||
if (results.e35) {
|
||||
options.push({
|
||||
id: "e35-degradation-recovery",
|
||||
label: "LAB E35 · degradation recovery",
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -97,6 +106,9 @@ export function AdvancedLaboratoryResult({
|
||||
failedSessionId: string | null;
|
||||
replayError: string | null;
|
||||
}) {
|
||||
if (workId === "e35-degradation-recovery" && results.e35) {
|
||||
return <E35Result rigLabel={rigLabel} result={results.e35} />;
|
||||
}
|
||||
if (workId === "e34-temporal-layer" && results.e34) {
|
||||
return <E34Result rigLabel={rigLabel} result={results.e34} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Select } from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratoryResultSummary,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type {
|
||||
E35DegradationKind,
|
||||
E35DegradationRecoveryResult,
|
||||
E35RecoveryReviewFrame,
|
||||
E35RecoveryReviewScenario,
|
||||
} from "../../core/laboratory/e35DegradationRecovery";
|
||||
import type { E34TemporalReviewFrame } from "../../core/laboratory/e34TemporalLayer";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import {
|
||||
E34TemporalLayerScene,
|
||||
type E34TemporalViewMode,
|
||||
} from "./E34TemporalLayerScene";
|
||||
|
||||
const SCENARIO_LABELS: Record<E35DegradationKind, string> = {
|
||||
"camera-loss": "Потеря камеры",
|
||||
"lidar-loss": "Потеря LiDAR",
|
||||
"pose-staleness": "Устаревшая поза",
|
||||
"delayed-frames": "Просроченные кадры",
|
||||
"bounded-drop": "Ограниченные пропуски",
|
||||
"timing-offset": "Рассинхрон 250 мс",
|
||||
};
|
||||
|
||||
const PHASE_LABELS = {
|
||||
before: "До отказа",
|
||||
during: "Во время отказа",
|
||||
after: "После восстановления",
|
||||
} as const;
|
||||
|
||||
function sceneFrame(frame: E35RecoveryReviewFrame): E34TemporalReviewFrame {
|
||||
return {
|
||||
frameIndex: frame.frameIndex,
|
||||
sessionSeconds: frame.sessionSeconds,
|
||||
sourceAvailable: frame.transformedPointRows > 0,
|
||||
layerState: frame.layerState,
|
||||
counts: frame.counts,
|
||||
cellCentersMapXyzM: frame.cellCentersMapXyzM,
|
||||
components: frame.components.map((component) => ({
|
||||
temporalId: component.temporalId,
|
||||
state: component.state,
|
||||
occupancyState: component.occupancyState,
|
||||
ownerKind: component.ownerKind,
|
||||
centroidMapXyzM: component.centroidMapXyzM,
|
||||
lastObservedAgeSeconds: component.lastObservedAgeSeconds,
|
||||
associationReason: frame.action,
|
||||
history: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function channelSummary(frame: E35RecoveryReviewFrame): string {
|
||||
return Object.entries(frame.channels)
|
||||
.map(([channel, state]) => `${channel}: ${state}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function unsafeClaims(
|
||||
result: E35DegradationRecoveryResult,
|
||||
): number {
|
||||
return result.scenarios.reduce((total, scenario) => (
|
||||
total
|
||||
+ scenario.hiddenSuccessFrames
|
||||
+ scenario.falseFreeRows
|
||||
+ scenario.semanticClaimsDuringCameraLoss
|
||||
+ scenario.metricRowsDuringLidarOrPoseLoss
|
||||
+ scenario.agreeClaimsDuringTimingOffset
|
||||
+ scenario.lateResultsReintroduced
|
||||
), 0);
|
||||
}
|
||||
|
||||
function maximumRecovery(
|
||||
result: E35DegradationRecoveryResult,
|
||||
): number {
|
||||
return Math.max(...result.scenarios.map(
|
||||
(scenario) => scenario.recoverySeconds,
|
||||
));
|
||||
}
|
||||
|
||||
function defaultFrame(
|
||||
scenario: E35RecoveryReviewScenario,
|
||||
): E35RecoveryReviewFrame | null {
|
||||
return scenario.frames.find((frame) => (
|
||||
frame.faultPhase === "during"
|
||||
&& (frame.counts.held > 0 || frame.counts.expired > 0)
|
||||
)) ?? scenario.frames[0] ?? null;
|
||||
}
|
||||
|
||||
function E35Evidence({
|
||||
result,
|
||||
}: {
|
||||
result: E35DegradationRecoveryResult;
|
||||
}) {
|
||||
const initialScenario = result.reviewScenarios[0] ?? null;
|
||||
const [scenarioId, setScenarioId] = useState<E35DegradationKind>(
|
||||
initialScenario?.scenarioId ?? "camera-loss",
|
||||
);
|
||||
const scenario = result.reviewScenarios.find(
|
||||
(item) => item.scenarioId === scenarioId,
|
||||
) ?? initialScenario;
|
||||
const initialFrame = scenario ? defaultFrame(scenario) : null;
|
||||
const [frameIndex, setFrameIndex] = useState(initialFrame?.frameIndex ?? 0);
|
||||
const frame = scenario?.frames.find(
|
||||
(item) => item.frameIndex === frameIndex,
|
||||
) ?? initialFrame;
|
||||
const [mode, setMode] = useState<E34TemporalViewMode>("3d");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const renderedFrame = useMemo(
|
||||
() => frame ? sceneFrame(frame) : null,
|
||||
[frame],
|
||||
);
|
||||
|
||||
if (!scenario || !frame || !renderedFrame) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
Контрольные состояния деградации не опубликованы.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectScenario = (value: string) => {
|
||||
const nextId = value as E35DegradationKind;
|
||||
const nextScenario = result.reviewScenarios.find(
|
||||
(item) => item.scenarioId === nextId,
|
||||
);
|
||||
setScenarioId(nextId);
|
||||
setFrameIndex(defaultFrame(nextScenario ?? scenario)?.frameIndex ?? 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="e35-degradation-evidence">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Деградация и восстановление E35"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "План" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
onModeChange={setMode}
|
||||
onExpandedChange={setExpanded}
|
||||
actions={(
|
||||
<div className="e35-degradation-evidence__selectors">
|
||||
<Select
|
||||
label="Сценарий отказа"
|
||||
value={scenario.scenarioId}
|
||||
options={result.reviewScenarios.map((item) => ({
|
||||
value: item.scenarioId,
|
||||
label: SCENARIO_LABELS[item.scenarioId],
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
onChange={selectScenario}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
overlay={(
|
||||
<>
|
||||
<div className="e35-degradation-evidence__timeline">
|
||||
{scenario.frames.map((item, index) => (
|
||||
<Button
|
||||
key={item.frameIndex}
|
||||
size="compact"
|
||||
variant={item.frameIndex === frame.frameIndex
|
||||
? "primary"
|
||||
: "secondary"}
|
||||
onClick={() => setFrameIndex(item.frameIndex)}
|
||||
>
|
||||
{index + 1}
|
||||
{" · "}
|
||||
{PHASE_LABELS[item.faultPhase]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<dl className="e35-degradation-evidence__telemetry">
|
||||
<div>
|
||||
<dt>Сценарий / фаза</dt>
|
||||
<dd>
|
||||
{SCENARIO_LABELS[frame.kind]}
|
||||
{" · "}
|
||||
{PHASE_LABELS[frame.faultPhase]}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Кадр / действие</dt>
|
||||
<dd>
|
||||
{formatNumber(frame.frameIndex, 0)}
|
||||
{" · "}
|
||||
{frame.action}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Точки до / после</dt>
|
||||
<dd>
|
||||
{formatNumber(frame.inputPointRows, 0)}
|
||||
{" / "}
|
||||
{formatNumber(frame.transformedPointRows, 0)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Слой</dt>
|
||||
<dd>
|
||||
{frame.counts.current} current · {frame.counts.held} held · {frame.counts.expired} expired
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Каналы</dt>
|
||||
<dd>{channelSummary(frame)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<E34TemporalLayerScene frame={renderedFrame} mode={mode} />
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function E35Result({
|
||||
rigLabel,
|
||||
result,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E35DegradationRecoveryResult;
|
||||
}) {
|
||||
const metrics = result.metrics;
|
||||
const maximum = maximumRecovery(result);
|
||||
const unsafe = unsafeClaims(result);
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E35 · деградация и восстановление"
|
||||
description="Проверяли, теряет ли система только неподтверждённую способность при отказе камеры, LiDAR, позы или времени — и возвращается ли она в штатное состояние без скрытого успеха и ложного свободного пространства."
|
||||
status="Безопасная деградация принята"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Источник", value: `${rigLabel} · цепочка E32–E34` },
|
||||
{
|
||||
label: "Объём проверки",
|
||||
value: `${formatNumber(metrics.variantCount, 0)} × ${formatNumber(metrics.sourceFrames, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Инъекции отказов",
|
||||
value: `${formatNumber(metrics.injectionRecords, 0)} записей · полный журнал`,
|
||||
},
|
||||
{ label: "Полномочия", value: "Диагностика · команды и safety выключены" },
|
||||
]}
|
||||
brief={{
|
||||
question: "Станет ли отказ источника явным и ограниченным, или система продолжит публиковать неподтверждённую семантику, метрику либо свободное пространство?",
|
||||
approach: "Шесть неизменяемых полных replay-вариантов получили по одному заранее объявленному отказу: потеря камеры, LiDAR, актуальной позы, просрочка, ограниченные пропуски и рассинхрон 250 мс. Каждый изменённый кадр и его восстановление записаны отдельно.",
|
||||
principalResult: `Да, для этой записи и замороженного профиля. Все ${formatNumber(metrics.variantFrameOutcomes, 0)} исходов закрыты, небезопасных утверждений — ${unsafe}, максимальное восстановление — ${maximum.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с при gate ${result.configuration.maximumRecoverySeconds.toLocaleString("ru-RU")} с.`,
|
||||
limitation: "Это source-scoped shadow qualification. Она не доказывает перенос на другой риг, свободное пространство, traversability, качество детектора, планирование или safety.",
|
||||
}}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "deterministic",
|
||||
pipelineId: "E32 TrackGeometry → frozen E34 layer → E35 fault variants",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: "Принятая цепочка E32 / E33 / E34",
|
||||
version: `${formatNumber(metrics.sourceFrames, 0)} кадров · map-frame`,
|
||||
role: "неизменяемый TrackGeometry, runtime envelope и temporal layer",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Deterministic degradation transforms",
|
||||
version: `${formatNumber(result.configuration.scenarioCount, 0)} сценариев · 60 кадров каждый`,
|
||||
role: "явно удаляет неподтверждённый канал или разделяет несинхронные evidence claims",
|
||||
identitySha256: null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Independent frozen-layer replay",
|
||||
version: `${metrics.variantFrameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс/variant-frame p95`,
|
||||
role: "полный terminal accounting, injection journal, recovery и проверка upstream digest",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ДОКАЗАТЕЛЬСТВО ОТКАЗА И ВОССТАНОВЛЕНИЯ"
|
||||
title="До отказа, потеря способности и возврат текущего evidence"
|
||||
kind="diagnostic-model"
|
||||
resizable
|
||||
>
|
||||
<E35Evidence result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Все шесть отказов становятся явными и восстанавливаются на следующем кадре"
|
||||
status="14 / 14 gate"
|
||||
statusTone="success"
|
||||
metrics={[
|
||||
{
|
||||
label: "Полное покрытие",
|
||||
value: formatNumber(metrics.variantFrameOutcomes, 0),
|
||||
hint: `${formatNumber(metrics.variantCount, 0)} полных replay-варианта`,
|
||||
},
|
||||
{
|
||||
label: "Макс. восстановление",
|
||||
value: `${maximum.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`,
|
||||
hint: `gate ≤ ${result.configuration.maximumRecoverySeconds.toLocaleString("ru-RU")} с`,
|
||||
},
|
||||
{
|
||||
label: "Небезопасные claims",
|
||||
value: formatNumber(unsafe, 0),
|
||||
hint: "нет hidden success, false free или late return",
|
||||
},
|
||||
{
|
||||
label: "Журнал инъекций",
|
||||
value: formatNumber(metrics.injectionRecords, 0),
|
||||
hint: "каждое изменение имеет исходный и итоговый digest",
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "На RAVNOVES00 потеря камеры, LiDAR, актуальной позы и времени явно понижает доступную способность: evidence становится geometry-only, camera-only, held/expired unknown или отбрасывается. Номинальное current evidence возвращается за 0,086–0,102 с.",
|
||||
notProved: "Работа не доказывает переносимость на другую запись или mount, detector accuracy, свободное пространство, traversability, planner input, навигацию или safety.",
|
||||
decision: "E35 принимается и закрывает A8. Следующий критический gate — A9/E36: аудит каталога и frozen-profile replay на подходящем втором mounted real source без retuning.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ const EMPTY_ADVANCED_RESULTS: AdvancedLaboratoryResults = {
|
||||
e32: null,
|
||||
e33: null,
|
||||
e34: null,
|
||||
e35: null,
|
||||
};
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
@@ -609,7 +610,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
advanced.status === "rejected" ? "E31–E34" : null,
|
||||
advanced.status === "rejected" ? "E31–E35" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
|
||||
@@ -206,6 +206,99 @@ function e34() {
|
||||
};
|
||||
}
|
||||
|
||||
function e35() {
|
||||
return {
|
||||
result_id: `e35-degradation-recovery-${"5".repeat(64)}`,
|
||||
created_at_utc: "2026-07-27T15:00:00Z",
|
||||
source_session_id: "20260720T065719Z_viewer_live",
|
||||
status: "accepted-deterministic-degradation-recovery",
|
||||
e32_result_id: e32().result_id,
|
||||
e33_result_id: e33().result_id,
|
||||
e34_result_id: e34().result_id,
|
||||
profile_id: "e35-six-channel-degradation-recovery/v1",
|
||||
pipeline_id: "track-geometry/temporal-layer/degradation-recovery/v1",
|
||||
coordinate_frame: "map",
|
||||
configuration: {
|
||||
maximum_recovery_seconds: 0.25,
|
||||
scenario_count: 6,
|
||||
},
|
||||
metrics: {
|
||||
source_frames: 4489,
|
||||
variant_count: 6,
|
||||
variant_frame_outcomes: 26934,
|
||||
injection_records: 360,
|
||||
variant_frame_processing_p95_ms: 1.08,
|
||||
build_elapsed_ms: 18618,
|
||||
},
|
||||
scenarios: [{
|
||||
scenario_id: "camera-loss",
|
||||
kind: "camera-loss",
|
||||
frame_start: 600,
|
||||
frame_end: 659,
|
||||
frames_processed: 4489,
|
||||
injected_frames: 60,
|
||||
dropped_frames: 0,
|
||||
hidden_success_frames: 0,
|
||||
false_free_rows: 0,
|
||||
semantic_claims_during_camera_loss: 0,
|
||||
metric_rows_during_lidar_or_pose_loss: 0,
|
||||
agree_claims_during_timing_offset: 0,
|
||||
late_results_reintroduced: 0,
|
||||
maximum_current_components_during_fault: 13,
|
||||
maximum_held_components_during_fault: 24,
|
||||
expired_components_during_fault: 60,
|
||||
recovery_frame_index: 660,
|
||||
recovery_seconds: 0.086,
|
||||
}],
|
||||
review: {
|
||||
schema_version: "missioncore.e35-recovery-review/v1",
|
||||
result_id: `e35-degradation-recovery-${"5".repeat(64)}`,
|
||||
scenarios: [{
|
||||
scenario: {
|
||||
schema_version: "missioncore.e35-degradation-scenario/v1",
|
||||
scenario_id: "camera-loss",
|
||||
kind: "camera-loss",
|
||||
frame_start: 600,
|
||||
frame_end: 659,
|
||||
parameters: { drop_camera_observations: true },
|
||||
},
|
||||
frames: [{
|
||||
scenario_id: "camera-loss",
|
||||
kind: "camera-loss",
|
||||
frame_index: 600,
|
||||
source_frame_index: 600,
|
||||
session_seconds: 95.4,
|
||||
fault_phase: "during",
|
||||
action: "camera-observations-removed",
|
||||
channels: {
|
||||
camera: "unavailable",
|
||||
lidar: "available",
|
||||
pose: "available",
|
||||
delivery: "on-time",
|
||||
},
|
||||
input_point_rows: 300,
|
||||
transformed_point_rows: 300,
|
||||
layer_state: "current",
|
||||
counts: { current: 5, held: 2, expired: 0 },
|
||||
components: [{
|
||||
temporal_id: 10,
|
||||
state: "current",
|
||||
occupancy_state: "occupied",
|
||||
owner_kind: "geometry-cluster",
|
||||
semantic_labels: [],
|
||||
last_observed_age_seconds: 0,
|
||||
centroid_map_xyz_m: [1, 2, 0.5],
|
||||
}],
|
||||
cell_centers_map_xyz_m: [[1, 2, 0.5]],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
acceptance: { accepted: true },
|
||||
authority,
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
@@ -222,9 +315,9 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes E31–E34 from separate read-only catalogs", async () => {
|
||||
test("decodes E31–E35 from separate read-only catalogs", async () => {
|
||||
const requests = [];
|
||||
const items = [e31(), e32(), e33(), e34()];
|
||||
const items = [e31(), e32(), e33(), e34(), e35()];
|
||||
const decoded = await fetchAdvancedLaboratoryResults({
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
@@ -241,11 +334,15 @@ test("decodes E31–E34 from separate read-only catalogs", async () => {
|
||||
assert.equal(decoded.e33.metrics.resultAgeMaxMs, 13.638);
|
||||
assert.equal(decoded.e34.metrics.processedFrames, 4489);
|
||||
assert.equal(decoded.e34.reviewFrames[0].components[0].state, "expired");
|
||||
assert.equal(decoded.e35.metrics.variantFrameOutcomes, 26934);
|
||||
assert.equal(decoded.e35.scenarios[0].recoverySeconds, 0.086);
|
||||
assert.equal(decoded.e35.reviewScenarios[0].frames[0].faultPhase, "during");
|
||||
assert.deepEqual(requests, [
|
||||
{ input: "/api/v1/laboratory/e31/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e32/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e33/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e34/results?limit=1", method: "GET" },
|
||||
{ input: "/api/v1/laboratory/e35/results?limit=1", method: "GET" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -259,7 +356,8 @@ test("rejects authority escalation in an accepted-looking result", async () => {
|
||||
fetcher: async (input) => new Response(JSON.stringify(catalog(
|
||||
String(input).includes("/e31/") ? forged
|
||||
: String(input).includes("/e32/") ? e32()
|
||||
: String(input).includes("/e33/") ? e33() : e34(),
|
||||
: String(input).includes("/e33/") ? e33()
|
||||
: String(input).includes("/e34/") ? e34() : e35(),
|
||||
)), { status: 200 }),
|
||||
}),
|
||||
AdvancedLaboratoryContractError,
|
||||
|
||||
@@ -26,6 +26,10 @@ const e34ResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/E34Result.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const e35ResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/E35Result.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const advancedLaboratoryResultUrl = new URL(
|
||||
"../src/workspaces/laboratory/AdvancedLaboratoryResult.tsx",
|
||||
import.meta.url,
|
||||
@@ -161,6 +165,25 @@ test("E34 keeps temporal evidence inside the canonical LAB and viewer contracts"
|
||||
assert.match(advancedSource, /<E34Result/);
|
||||
});
|
||||
|
||||
test("E35 extends the canonical LAB with fault and recovery evidence", async () => {
|
||||
const [e35Source, advancedSource] = await Promise.all([
|
||||
readFile(e35ResultUrl, "utf8"),
|
||||
readFile(advancedLaboratoryResultUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(e35Source, /<LaboratoryWorkTemplate/);
|
||||
assert.match(e35Source, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(e35Source, /\{ value: "3d", label: "3D" \}/);
|
||||
assert.match(e35Source, /\{ value: "plan", label: "План" \}/);
|
||||
assert.match(e35Source, /Шесть неизменяемых полных replay-вариантов/);
|
||||
assert.match(e35Source, /небезопасных утверждений —/);
|
||||
assert.match(e35Source, /Следующий критический gate — A9\/E36/);
|
||||
assert.match(e35Source, /SCENARIO_LABELS/);
|
||||
assert.match(e35Source, /PHASE_LABELS/);
|
||||
assert.match(advancedSource, /id: "e35-degradation-recovery"/);
|
||||
assert.match(advancedSource, /<E35Result/);
|
||||
});
|
||||
|
||||
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
|
||||
const workspacesSource = await readFile(workspacesUrl, "utf8");
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ complete; full GOOSE and RELLIS qualification complete; L2.6d K1 replay
|
||||
local-surface temporal qualification, operator triage and prior-plane residual
|
||||
explainability implemented; L2.6e recorded-source-paced bounded shadow
|
||||
qualified; E28 complete worker replay accepted; E29 camera-first semantic and
|
||||
parallel geometry-only replay implemented; operator review and shadow gate next
|
||||
parallel geometry-only replay implemented; E30–E35 source-scoped qualification
|
||||
accepted; E36 transfer gate next
|
||||
Scope: passively received real-time K1 point/pose evidence, immutable replay and
|
||||
future live shadow processing
|
||||
Explicitly out of scope: K1 firmware modification, a new onboard exporter, new
|
||||
@@ -693,11 +694,18 @@ source gap plus an incoherent nearest-neighbor jump detector; the fixed
|
||||
implementation uses an independent TTL deadline and coherent translation
|
||||
support without changing the frozen profile.
|
||||
|
||||
Execution was strictly sequential through E34: E30 determined what E31 was
|
||||
E35 accepted immutable degradation result
|
||||
`e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288`.
|
||||
Six deterministic variants each replay all `4,489` source frames and record
|
||||
`26,934` terminal outcomes plus `360` injection records. Maximum recovery is
|
||||
`0.102 s` against the frozen `0.25 s` gate. Hidden success, false-free rows,
|
||||
camera-less semantic claims, LiDAR/pose-less metric rows, timing-mismatch
|
||||
`agree`, late-result return and upstream mutation are all zero.
|
||||
|
||||
Execution was strictly sequential through E35: E30 determined what E31 was
|
||||
allowed to change; E31 determined the E32 profile; E32 determined the E33
|
||||
runtime input; E32/E33 then bound the E34 layer. Exact accounting and the
|
||||
bounded nominal temporal layer are now closed, so E35 is the active critical
|
||||
path.
|
||||
runtime input; E32/E33 then bound the E34 layer; E32–E34 then bound E35.
|
||||
Exact nominal and degradation accounting are closed, so A8 is complete.
|
||||
E36 is the first generalization gate. A separate product decision follows:
|
||||
either keep the result as operator/shadow evidence, or start L5 occupied-space
|
||||
integration. No LAB in this cycle can enable navigation, commands or safety
|
||||
|
||||
@@ -202,7 +202,14 @@ cells per component, and emits no free cells. Geometry-only re-association is
|
||||
independent `0.75 s` deadline and records the `0.283 s` worst source-gap
|
||||
materialization delay separately. The first immutable run remains a rejected
|
||||
artifact documenting the frame-clock and incoherent-jump implementation
|
||||
failures. E35 is now the critical path.
|
||||
failures. A8/E35 is complete in immutable result
|
||||
`e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288`.
|
||||
Six deterministic variants each replay all 4,489 frames; the result records
|
||||
26,934 terminal outcomes and 360 injections. Maximum recovery is `0.102 s`
|
||||
against the `0.25 s` gate. Hidden success, false free-space, unsupported
|
||||
semantic/metric claims, timing-mismatch `agree`, late-result return and
|
||||
upstream changes are all zero. A0–A8 are closed. A9/E36 is now the critical
|
||||
path and requires an eligible second mounted real source.
|
||||
|
||||
- [x] Reproduce all 4,489 immutable E29 frames with the exact frozen profile
|
||||
before applying E31/E30 changes.
|
||||
@@ -224,7 +231,7 @@ failures. E35 is now the critical path.
|
||||
pinned container image; independently verify all result artifacts.
|
||||
- [x] Build E34 as a separate short-TTL occupied/unknown temporal layer over
|
||||
accepted E32/E33 evidence.
|
||||
- [ ] Run E35 deterministic degradation/recovery variants without changing
|
||||
- [x] Run E35 deterministic degradation/recovery variants without changing
|
||||
the immutable source or accepted E32/E33 results.
|
||||
|
||||
### A3 residual and human-exception policy
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# ADR 0028 — E35 deterministic degradation and recovery
|
||||
|
||||
Date: 2026-07-27
|
||||
|
||||
Status: accepted and executed
|
||||
|
||||
## Context
|
||||
|
||||
E32 fixes camera-owned semantic identity and exclusive map-frame point
|
||||
ownership over all 4,489 immutable RAVNOVES00 frames. E33 proves bounded
|
||||
recorded-source-paced delivery. E34 adds a separate hit-only temporal layer
|
||||
with explicit `current/occupied`, `held/unknown` and `expired/unknown` state.
|
||||
|
||||
Those nominal results do not yet prove that the composed pipeline fails safely
|
||||
when camera, LiDAR, pose or delivery timing degrades. A fault must not retain a
|
||||
semantic or metric claim that its required evidence no longer supports. It
|
||||
must also not turn missing data into free space or reintroduce a stale result
|
||||
after the fault window.
|
||||
|
||||
## Predeclared decision
|
||||
|
||||
1. E35 derives six immutable variants from the exact accepted E32/E33/E34
|
||||
chain. It never mutates the source or any accepted upstream result.
|
||||
2. Each variant replays all 4,489 source frames. One bounded, non-overlapping
|
||||
interval carries exactly one deterministic transformation:
|
||||
camera loss, LiDAR loss, pose staleness, delayed frames, bounded frame drops
|
||||
or camera↔LiDAR timing offset.
|
||||
3. Camera loss removes camera observations. Current metric point ownership is
|
||||
retained only as unknown-class `geometry-only`; E35 cannot preserve or
|
||||
invent semantic class.
|
||||
4. LiDAR loss retains camera identity as `camera-only` but removes current
|
||||
metric rows. Prior occupied evidence may age through the E34 held/expired
|
||||
contract, but no new occupied or free cells may be created.
|
||||
5. Pose age of `0.5 s` exceeds the accepted `0.1 s` binding gate. Map-frame
|
||||
points are therefore withheld and camera claims remain non-metric.
|
||||
6. A deterministic `1.0 s` delivery delay exceeds the `0.75 s` occupied TTL.
|
||||
Delayed results are explicitly discarded and may not re-enter after their
|
||||
logical deadline.
|
||||
7. The bounded-drop variant drops every third frame in its 60-frame interval.
|
||||
Every drop is journalled; intervening frames remain ordered and the layer
|
||||
exposes held/expired uncertainty instead of silent continuity.
|
||||
8. A `250 ms` camera↔LiDAR offset exceeds the admitted binding. Camera
|
||||
observations and point-backed geometry are split into separate
|
||||
`camera-only` and `geometry-only` evidence. The mismatched pair cannot
|
||||
publish `agree`.
|
||||
9. Every variant records one terminal outcome per source frame, every injected
|
||||
transformation, layer-state transitions, current/held/expired counts,
|
||||
excluded semantic/metric claims, recovery time and artifact digests.
|
||||
10. Acceptance requires six complete 4,489-frame variants, exact unchanged
|
||||
upstream artifacts, zero hidden success, zero false-free rows, zero
|
||||
semantic claims during camera loss, zero metric rows during LiDAR or pose
|
||||
loss, zero late-result reintroduction and explicit recovery no later than
|
||||
`0.25 s` after the fault interval.
|
||||
11. The E34 profile, TTL, association gates and bounds remain frozen. E35 may
|
||||
vary only the declared source/channel transformation.
|
||||
12. Persistent reconstruction, planner input, commands, navigation and safety
|
||||
authority remain unavailable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A passed E35 closes A8 only as a source-scoped diagnostic/shadow
|
||||
degradation contract.
|
||||
- Safe degradation is represented as explicit loss of confidence and
|
||||
capability, not as guessed class, extrapolated free space or a green
|
||||
aggregate status.
|
||||
- A failed scenario remains immutable evidence. Threshold relaxation or a new
|
||||
transformation definition requires a new profile and result rather than
|
||||
rewriting E35.
|
||||
- E36 remains the first transfer/generalization gate and requires an eligible
|
||||
second mounted real source with the frozen E32–E35 profile.
|
||||
|
||||
## Outcome
|
||||
|
||||
Immutable result:
|
||||
`e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288`.
|
||||
|
||||
All six variants replayed all 4,489 frames, producing 26,934 terminal outcomes
|
||||
and 360 injection records. Maximum recovery was 0.102 s against the
|
||||
predeclared 0.25 s gate. Hidden success, false-free rows, camera-less semantic
|
||||
claims, LiDAR/pose-less metric rows, timing-mismatch `agree`, late-result
|
||||
reintroduction and upstream mutations were all zero. E35 is accepted and A8
|
||||
is closed; A9/E36 is next.
|
||||
@@ -0,0 +1,153 @@
|
||||
# LAB E35 — deterministic degradation and recovery
|
||||
|
||||
Date: 2026-07-27
|
||||
Status: accepted for source-scoped diagnostic/shadow use; persistent
|
||||
reconstruction, planner, navigation, safety and command authority remain
|
||||
unavailable
|
||||
Immutable result:
|
||||
`e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288`
|
||||
|
||||
## Objective and architecture stage
|
||||
|
||||
E35 closes the degradation half of A8. It asks whether the accepted
|
||||
E32 TrackGeometry, E33 delivery envelope and E34 temporal occupied/unknown
|
||||
layer fail safely and recover explicitly when one required source or timing
|
||||
property degrades.
|
||||
|
||||
The experiment does not mutate or replace any accepted input. It derives six
|
||||
complete deterministic variants from the exact RAVNOVES00 replay, applies one
|
||||
bounded transformation in each variant and replays every variant through a
|
||||
fresh E34 state machine with its frozen profile.
|
||||
|
||||
## Immutable inputs and predeclared profile
|
||||
|
||||
- E32:
|
||||
`e32-track-geometry-a14ca0e7fb3850ca0dfa3c41634e1b490a2d58ab74d101afc6d6921fbdb0e6fd`.
|
||||
- E33:
|
||||
`e33-worker-shadow-05cc0bb264410fd49536df90e94067ac39731aff0322a8873700d40008a8bb3a`.
|
||||
- E34:
|
||||
`e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73`.
|
||||
- Source: RAVNOVES00 / `20260720T065719Z_viewer_live`.
|
||||
- Frames: `4,489` per variant, `26,934` terminal variant-frame outcomes.
|
||||
- Profile:
|
||||
`missioncore.e35-degradation-profile/v1`,
|
||||
`e35-six-channel-degradation-recovery/v1`.
|
||||
- Frozen E34 layer: `0.45 m` voxels, `0.75 s` occupied TTL and the accepted
|
||||
component/association bounds.
|
||||
|
||||
The profile and ADR 0028 were written before the full replay. Each fault
|
||||
occupies a distinct 60-frame interval:
|
||||
|
||||
1. camera observations unavailable;
|
||||
2. LiDAR points unavailable;
|
||||
3. pose age `0.5 s`, above the admitted `0.1 s` binding;
|
||||
4. result delay `1.0 s`, above the `0.75 s` TTL, with discard policy;
|
||||
5. every third input frame dropped;
|
||||
6. camera↔LiDAR offset `250 ms`, above the admitted `100 ms` gate.
|
||||
|
||||
Acceptance required complete accounting, an explicit injection journal, all
|
||||
variants recovered within `0.25 s`, and zero hidden success, false free-space,
|
||||
semantic claims without camera, metric rows without LiDAR/current pose, `agree`
|
||||
claims under timing mismatch and late-result reintroduction.
|
||||
|
||||
## Method and algorithms
|
||||
|
||||
For every source frame and every scenario E35:
|
||||
|
||||
1. reconstructs the exact validated E32 `TrackGeometryFrame` and `PointSlab`;
|
||||
2. records a digest of the original frame;
|
||||
3. applies one pure frame-local transformation only inside the declared
|
||||
interval;
|
||||
4. removes semantic identity on camera loss while retaining hit-backed points
|
||||
only as `geometry-only`;
|
||||
5. removes map-frame point rows on LiDAR loss or stale pose while retaining
|
||||
camera observations only as non-metric `camera-only`;
|
||||
6. discards late frames and prevents them from returning after their logical
|
||||
deadline;
|
||||
7. journals every dropped frame and passes intervening bounded-drop frames in
|
||||
original order;
|
||||
8. splits mismatched camera and LiDAR evidence into separate `camera-only` and
|
||||
`geometry-only` claims so the pair cannot publish `agree`;
|
||||
9. feeds the transformed frame into an independent frozen E34 temporal layer;
|
||||
10. records terminal layer state, current/held/expired counts, recovery,
|
||||
transformation digests, policy and authority.
|
||||
|
||||
No missing point set is treated as free space. No transform writes into the
|
||||
persistent reconstruction or any accepted upstream artifact.
|
||||
|
||||
## Accepted result
|
||||
|
||||
Result:
|
||||
`e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288`.
|
||||
|
||||
| Scenario | Injected frames | Explicit drops | Recovery | Unsafe claims |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| camera loss | 60 | 0 | 0.086 s | 0 semantics |
|
||||
| LiDAR loss | 60 | 0 | 0.100 s | 0 metric rows |
|
||||
| pose staleness | 60 | 0 | 0.101 s | 0 metric rows |
|
||||
| delayed frames | 60 | 60 | 0.088 s | 0 late returns |
|
||||
| bounded drop | 60 | 20 | 0.101 s | 0 hidden success |
|
||||
| timing offset | 60 | 0 | 0.102 s | 0 `agree` |
|
||||
|
||||
All six variants processed `4,489 / 4,489` frames. The immutable artifacts
|
||||
contain `26,934` terminal outcomes and `360` explicit injection records.
|
||||
Maximum recovery was `0.102 s`, below the predeclared `0.25 s` gate.
|
||||
|
||||
Across all variants:
|
||||
|
||||
- hidden success frames: `0`;
|
||||
- false free cell rows: `0`;
|
||||
- semantic claims during camera loss: `0`;
|
||||
- metric rows during LiDAR loss or stale pose: `0`;
|
||||
- `agree` claims during timing offset: `0`;
|
||||
- late results reintroduced: `0`;
|
||||
- upstream artifact changes: `0`;
|
||||
- variant-frame processing p95: `1.080 ms`.
|
||||
|
||||
## Product materialization
|
||||
|
||||
The accepted result is exposed through the path-free read-only endpoint
|
||||
`GET /api/v1/laboratory/e35/results?limit=1` and a separate
|
||||
`LAB E35 · degradation recovery` entry in the laboratory contour.
|
||||
|
||||
The UI extends the fixed `missioncore.laboratory-report/v1` template. It shows:
|
||||
|
||||
- the task, immutable method and human-readable conclusion;
|
||||
- six selectable fault scenarios;
|
||||
- a compact before/during/after recovery timeline;
|
||||
- current, held and expired spatial evidence in the existing laboratory
|
||||
viewer with admitted 3D/plan and fullscreen controls;
|
||||
- explicit fault action, channel state, recovery time, unsafe-claim count and
|
||||
acceptance decision;
|
||||
- proved, not proved and next-gate statements.
|
||||
|
||||
E35 does not add a new generic control, a separate page anatomy or a
|
||||
run-specific visual language.
|
||||
|
||||
## Interpretation and limitations
|
||||
|
||||
E35 proves, on this immutable source and frozen profile:
|
||||
|
||||
- each declared channel/timing failure loses unsupported capability explicitly;
|
||||
- the temporal layer ages prior evidence as held/expired unknown rather than
|
||||
inventing free space;
|
||||
- late evidence is discarded and cannot silently return;
|
||||
- nominal current evidence returns on the next source frame after every fault;
|
||||
- accounting and authority remain closed.
|
||||
|
||||
E35 does not prove:
|
||||
|
||||
- detector accuracy or human ground truth;
|
||||
- transfer to another capture, device or mount;
|
||||
- ray-cleared free space, traversability, TSDF or ESDF;
|
||||
- planner, navigation, safety or command acceptance.
|
||||
|
||||
## Decision and next stage
|
||||
|
||||
E35 is accepted and closes A8. Workstream A has completed A0–A8.
|
||||
|
||||
The next critical-path gate is A9/E36: run the frozen E32–E35 profile without
|
||||
retuning on an eligible second mounted real source. The source catalog must be
|
||||
audited first. If no recording contains the required camera, LiDAR, pose,
|
||||
time, calibration and mount identities, E36 remains explicitly blocked; no
|
||||
empty LAB, fabricated result or new capture is created by default.
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"schema_version": "missioncore.e35-degradation-profile/v1",
|
||||
"profile_id": "e35-six-channel-degradation-recovery/v1",
|
||||
"expected_e32_result_id": "e32-track-geometry-a14ca0e7fb3850ca0dfa3c41634e1b490a2d58ab74d101afc6d6921fbdb0e6fd",
|
||||
"expected_e33_result_id": "e33-worker-shadow-05cc0bb264410fd49536df90e94067ac39731aff0322a8873700d40008a8bb3a",
|
||||
"expected_e34_result_id": "e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "camera-loss",
|
||||
"kind": "camera-loss",
|
||||
"frame_start": 600,
|
||||
"frame_end": 659,
|
||||
"parameters": {
|
||||
"drop_camera_observations": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "lidar-loss",
|
||||
"kind": "lidar-loss",
|
||||
"frame_start": 1200,
|
||||
"frame_end": 1259,
|
||||
"parameters": {
|
||||
"drop_lidar_points": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "pose-staleness",
|
||||
"kind": "pose-staleness",
|
||||
"frame_start": 1800,
|
||||
"frame_end": 1859,
|
||||
"parameters": {
|
||||
"pose_age_seconds": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "delayed-frames",
|
||||
"kind": "delayed-frames",
|
||||
"frame_start": 2400,
|
||||
"frame_end": 2459,
|
||||
"parameters": {
|
||||
"delay_seconds": 1.0,
|
||||
"late_result_policy": "discard"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "bounded-drop",
|
||||
"kind": "bounded-drop",
|
||||
"frame_start": 3000,
|
||||
"frame_end": 3059,
|
||||
"parameters": {
|
||||
"drop_every_nth_frame": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "timing-offset",
|
||||
"kind": "timing-offset",
|
||||
"frame_start": 3600,
|
||||
"frame_end": 3659,
|
||||
"parameters": {
|
||||
"camera_lidar_offset_ms": 250
|
||||
}
|
||||
}
|
||||
],
|
||||
"acceptance": {
|
||||
"expected_scenario_count": 6,
|
||||
"maximum_recovery_seconds": 0.25,
|
||||
"maximum_hidden_success_frames": 0,
|
||||
"maximum_false_free_rows": 0,
|
||||
"maximum_semantic_claims_during_camera_loss": 0,
|
||||
"maximum_metric_rows_during_lidar_or_pose_loss": 0,
|
||||
"maximum_late_results_reintroduced": 0,
|
||||
"require_complete_variant_frame_accounting": true,
|
||||
"require_exact_upstream_artifact_identity": true,
|
||||
"require_explicit_injection_journal": true,
|
||||
"require_every_variant_to_recover": true
|
||||
},
|
||||
"policy": {
|
||||
"absence_of_points_means_free": false,
|
||||
"late_results_may_reenter": false,
|
||||
"timing_mismatch_may_publish_agree": false,
|
||||
"pose_stale_points_may_publish_map_occupancy": false,
|
||||
"persistent_reconstruction_mutation_allowed": false
|
||||
},
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the immutable E35 degradation and recovery qualification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.compute.e35_degradation_replay import (
|
||||
build_e35_degradation_replay,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--e32-result", type=Path, required=True)
|
||||
parser.add_argument("--e33-result", type=Path, required=True)
|
||||
parser.add_argument("--e34-result", type=Path, required=True)
|
||||
parser.add_argument("--e34-profile", type=Path, required=True)
|
||||
parser.add_argument("--profile", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = build_e35_degradation_replay(
|
||||
e32_result_root=args.e32_result,
|
||||
e33_result_root=args.e33_result,
|
||||
e34_result_root=args.e34_result,
|
||||
e34_profile_path=args.e34_profile,
|
||||
profile_path=args.profile,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"accepted": result.accepted,
|
||||
"metrics": result.report["metrics"],
|
||||
"rejection_reasons": result.report["acceptance"][
|
||||
"rejection_reasons"
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0 if result.accepted else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Deterministic E35 source degradation transforms over TrackGeometry v1.
|
||||
|
||||
The transforms are pure and frame-local. They never alter the accepted E32
|
||||
source, infer free space, retain evidence whose required channel is absent, or
|
||||
grant runtime authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .track_geometry import (
|
||||
PointSlab,
|
||||
TrackGeometry,
|
||||
TrackGeometryCurrentness,
|
||||
TrackGeometryEvidenceState,
|
||||
TrackGeometryFrame,
|
||||
TrackGeometryMetricBasis,
|
||||
TrackGeometryOwnerKind,
|
||||
)
|
||||
|
||||
E35_SCENARIO_SCHEMA: Final = "missioncore.e35-degradation-scenario/v1"
|
||||
E35_TRANSFORMATION_SCHEMA: Final = "missioncore.e35-frame-transformation/v1"
|
||||
|
||||
|
||||
class DegradationRecoveryError(ValueError):
|
||||
"""An E35 scenario or transformed frame violates the frozen contract."""
|
||||
|
||||
|
||||
class DegradationKind(StrEnum):
|
||||
CAMERA_LOSS = "camera-loss"
|
||||
LIDAR_LOSS = "lidar-loss"
|
||||
POSE_STALENESS = "pose-staleness"
|
||||
DELAYED_FRAMES = "delayed-frames"
|
||||
BOUNDED_DROP = "bounded-drop"
|
||||
TIMING_OFFSET = "timing-offset"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DegradationScenario:
|
||||
"""One bounded deterministic source transformation."""
|
||||
|
||||
scenario_id: str
|
||||
kind: DegradationKind
|
||||
frame_start: int
|
||||
frame_end: int
|
||||
parameters: dict[str, Any]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not self.scenario_id
|
||||
or len(self.scenario_id) > 80
|
||||
or self.scenario_id != self.kind.value
|
||||
or not isinstance(self.frame_start, int)
|
||||
or isinstance(self.frame_start, bool)
|
||||
or not isinstance(self.frame_end, int)
|
||||
or isinstance(self.frame_end, bool)
|
||||
or self.frame_start < 1
|
||||
or self.frame_end < self.frame_start
|
||||
):
|
||||
raise DegradationRecoveryError("degradation scenario bounds are invalid")
|
||||
self._validate_parameters()
|
||||
|
||||
def _validate_parameters(self) -> None:
|
||||
expected: dict[DegradationKind, set[str]] = {
|
||||
DegradationKind.CAMERA_LOSS: {"drop_camera_observations"},
|
||||
DegradationKind.LIDAR_LOSS: {"drop_lidar_points"},
|
||||
DegradationKind.POSE_STALENESS: {"pose_age_seconds"},
|
||||
DegradationKind.DELAYED_FRAMES: {
|
||||
"delay_seconds",
|
||||
"late_result_policy",
|
||||
},
|
||||
DegradationKind.BOUNDED_DROP: {"drop_every_nth_frame"},
|
||||
DegradationKind.TIMING_OFFSET: {"camera_lidar_offset_ms"},
|
||||
}
|
||||
if set(self.parameters) != expected[self.kind]:
|
||||
raise DegradationRecoveryError(
|
||||
"degradation scenario parameters are incompatible"
|
||||
)
|
||||
if self.kind is DegradationKind.CAMERA_LOSS:
|
||||
_require_true(self.parameters["drop_camera_observations"])
|
||||
elif self.kind is DegradationKind.LIDAR_LOSS:
|
||||
_require_true(self.parameters["drop_lidar_points"])
|
||||
elif self.kind is DegradationKind.POSE_STALENESS:
|
||||
_positive_number(self.parameters["pose_age_seconds"], "pose age")
|
||||
elif self.kind is DegradationKind.DELAYED_FRAMES:
|
||||
_positive_number(self.parameters["delay_seconds"], "delivery delay")
|
||||
if self.parameters["late_result_policy"] != "discard":
|
||||
raise DegradationRecoveryError(
|
||||
"late E35 results must be discarded"
|
||||
)
|
||||
elif self.kind is DegradationKind.BOUNDED_DROP:
|
||||
value = self.parameters["drop_every_nth_frame"]
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or value < 2
|
||||
):
|
||||
raise DegradationRecoveryError(
|
||||
"bounded drop cadence is invalid"
|
||||
)
|
||||
else:
|
||||
value = self.parameters["camera_lidar_offset_ms"]
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or abs(value) <= 100
|
||||
or abs(value) > 1_000
|
||||
):
|
||||
raise DegradationRecoveryError(
|
||||
"timing offset must exceed the admitted binding"
|
||||
)
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return self.frame_end - self.frame_start + 1
|
||||
|
||||
def active(self, frame_index: int) -> bool:
|
||||
return self.frame_start <= frame_index <= self.frame_end
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": E35_SCENARIO_SCHEMA,
|
||||
"scenario_id": self.scenario_id,
|
||||
"kind": self.kind.value,
|
||||
"frame_start": self.frame_start,
|
||||
"frame_end": self.frame_end,
|
||||
"parameters": self.parameters,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> DegradationScenario:
|
||||
document = _object(value, "degradation scenario")
|
||||
if set(document) != {
|
||||
"scenario_id",
|
||||
"kind",
|
||||
"frame_start",
|
||||
"frame_end",
|
||||
"parameters",
|
||||
}:
|
||||
raise DegradationRecoveryError(
|
||||
"degradation scenario fields are incompatible"
|
||||
)
|
||||
try:
|
||||
kind = DegradationKind(
|
||||
_string(document.get("kind"), "degradation kind")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise DegradationRecoveryError(
|
||||
"degradation scenario kind is invalid"
|
||||
) from exc
|
||||
parameters = _object(
|
||||
document.get("parameters"),
|
||||
"degradation scenario parameters",
|
||||
)
|
||||
return cls(
|
||||
scenario_id=_string(document.get("scenario_id"), "scenario id"),
|
||||
kind=kind,
|
||||
frame_start=_integer(document.get("frame_start"), "frame start"),
|
||||
frame_end=_integer(document.get("frame_end"), "frame end"),
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TransformedTrackGeometryFrame:
|
||||
"""One E35 frame and its explicit transformation document."""
|
||||
|
||||
frame: TrackGeometryFrame
|
||||
transformation: dict[str, Any]
|
||||
|
||||
|
||||
def transform_track_geometry_frame(
|
||||
frame: TrackGeometryFrame,
|
||||
scenario: DegradationScenario,
|
||||
) -> TransformedTrackGeometryFrame:
|
||||
"""Apply one scenario without mutating the accepted source frame."""
|
||||
|
||||
if not scenario.active(frame.frame_index):
|
||||
return TransformedTrackGeometryFrame(
|
||||
frame=frame,
|
||||
transformation=_transformation(
|
||||
frame,
|
||||
scenario,
|
||||
active=False,
|
||||
action="none",
|
||||
channels=_nominal_channels(frame),
|
||||
transformed_frame=frame,
|
||||
),
|
||||
)
|
||||
|
||||
if scenario.kind is DegradationKind.CAMERA_LOSS:
|
||||
transformed = _camera_loss(frame)
|
||||
action = "camera-observations-removed"
|
||||
channels = {
|
||||
"camera": "unavailable",
|
||||
"lidar": _lidar_state(frame),
|
||||
"pose": "available",
|
||||
"delivery": "on-time",
|
||||
}
|
||||
elif scenario.kind is DegradationKind.LIDAR_LOSS:
|
||||
transformed = _metric_unavailable(
|
||||
frame,
|
||||
reason="e35-lidar-unavailable",
|
||||
source_available=False,
|
||||
)
|
||||
action = "lidar-points-withheld"
|
||||
channels = {
|
||||
"camera": "available",
|
||||
"lidar": "unavailable",
|
||||
"pose": "available",
|
||||
"delivery": "on-time",
|
||||
}
|
||||
elif scenario.kind is DegradationKind.POSE_STALENESS:
|
||||
transformed = _metric_unavailable(
|
||||
frame,
|
||||
reason="e35-pose-stale",
|
||||
source_available=frame.source_available,
|
||||
)
|
||||
action = "map-points-withheld-for-stale-pose"
|
||||
channels = {
|
||||
"camera": "available",
|
||||
"lidar": _lidar_state(frame),
|
||||
"pose": "stale",
|
||||
"delivery": "on-time",
|
||||
}
|
||||
elif scenario.kind is DegradationKind.DELAYED_FRAMES:
|
||||
transformed = _empty_frame(frame)
|
||||
action = "late-frame-discarded"
|
||||
channels = {
|
||||
"camera": "late-discarded",
|
||||
"lidar": "late-discarded",
|
||||
"pose": "late-discarded",
|
||||
"delivery": "late-discarded",
|
||||
}
|
||||
elif scenario.kind is DegradationKind.BOUNDED_DROP:
|
||||
cadence = int(scenario.parameters["drop_every_nth_frame"])
|
||||
dropped = (frame.frame_index - scenario.frame_start) % cadence == 0
|
||||
transformed = _empty_frame(frame) if dropped else frame
|
||||
action = "input-frame-dropped" if dropped else "bounded-drop-pass"
|
||||
channels = (
|
||||
{
|
||||
"camera": "dropped",
|
||||
"lidar": "dropped",
|
||||
"pose": "dropped",
|
||||
"delivery": "dropped",
|
||||
}
|
||||
if dropped
|
||||
else _nominal_channels(frame)
|
||||
)
|
||||
else:
|
||||
transformed = _timing_offset(frame)
|
||||
action = "camera-lidar-evidence-split"
|
||||
channels = {
|
||||
"camera": "offset",
|
||||
"lidar": _lidar_state(frame),
|
||||
"pose": "available",
|
||||
"delivery": "on-time",
|
||||
}
|
||||
|
||||
return TransformedTrackGeometryFrame(
|
||||
frame=transformed,
|
||||
transformation=_transformation(
|
||||
frame,
|
||||
scenario,
|
||||
active=True,
|
||||
action=action,
|
||||
channels=channels,
|
||||
transformed_frame=transformed,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def frame_digest(frame: TrackGeometryFrame) -> str:
|
||||
"""Digest one complete frame without serializing large point arrays."""
|
||||
|
||||
digest = hashlib.sha256()
|
||||
compact = {
|
||||
"binding": frame.binding.to_dict(),
|
||||
"frame_index": frame.frame_index,
|
||||
"source_frame_index": frame.source_frame_index,
|
||||
"session_seconds": frame.session_seconds,
|
||||
"source_available": frame.source_available,
|
||||
"source_point_count": frame.point_slab.source_point_count,
|
||||
"coordinate_frame": frame.point_slab.coordinate_frame,
|
||||
"owner_keys": list(frame.point_slab.owner_keys),
|
||||
"geometries": [geometry.to_dict() for geometry in frame.geometries],
|
||||
}
|
||||
digest.update(_canonical_json(compact))
|
||||
digest.update(frame.point_slab.source_indices.astype("<i8", copy=False).tobytes())
|
||||
digest.update(frame.point_slab.points_xyz_m.astype("<f4", copy=False).tobytes())
|
||||
digest.update(frame.point_slab.owner_indices.astype("<u4", copy=False).tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _camera_loss(frame: TrackGeometryFrame) -> TrackGeometryFrame:
|
||||
geometries: list[TrackGeometry] = []
|
||||
owner_map: dict[str, str | None] = {}
|
||||
for geometry in frame.geometries:
|
||||
if geometry.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER:
|
||||
geometries.append(geometry)
|
||||
if geometry.metric_basis is TrackGeometryMetricBasis.CURRENT_POINTS:
|
||||
owner_map[geometry.owner_key] = geometry.owner_key
|
||||
continue
|
||||
if geometry.metric_basis is not TrackGeometryMetricBasis.CURRENT_POINTS:
|
||||
owner_map[geometry.owner_key] = None
|
||||
continue
|
||||
owner_key = f"e35-camera-loss:{geometry.owner_key}"
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=owner_key,
|
||||
owner_kind=TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
evidence_state=TrackGeometryEvidenceState.GEOMETRY_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=(
|
||||
"e35-camera-unavailable",
|
||||
"geometry-retained-without-semantics",
|
||||
),
|
||||
range_m=geometry.range_m,
|
||||
)
|
||||
)
|
||||
owner_map[geometry.owner_key] = owner_key
|
||||
return _frame_with(
|
||||
frame,
|
||||
geometries=geometries,
|
||||
point_slab=_remap_slab(frame, geometries, owner_map),
|
||||
source_available=frame.source_available,
|
||||
)
|
||||
|
||||
|
||||
def _metric_unavailable(
|
||||
frame: TrackGeometryFrame,
|
||||
*,
|
||||
reason: str,
|
||||
source_available: bool,
|
||||
) -> TrackGeometryFrame:
|
||||
geometries: list[TrackGeometry] = []
|
||||
for geometry in frame.geometries:
|
||||
if geometry.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER:
|
||||
continue
|
||||
if geometry.metric_basis is TrackGeometryMetricBasis.CURRENT_POINTS:
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=geometry.owner_key,
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.UNAVAILABLE,
|
||||
reason_codes=(reason, "camera-remains-nonmetric"),
|
||||
semantic_track_id=geometry.semantic_track_id,
|
||||
semantic_label=geometry.semantic_label,
|
||||
bbox_xyxy=geometry.bbox_xyxy,
|
||||
)
|
||||
)
|
||||
else:
|
||||
geometries.append(geometry)
|
||||
return _frame_with(
|
||||
frame,
|
||||
geometries=geometries,
|
||||
point_slab=_empty_slab(frame),
|
||||
source_available=source_available,
|
||||
)
|
||||
|
||||
|
||||
def _timing_offset(frame: TrackGeometryFrame) -> TrackGeometryFrame:
|
||||
geometries: list[TrackGeometry] = []
|
||||
owner_map: dict[str, str | None] = {}
|
||||
for geometry in frame.geometries:
|
||||
if (
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
and geometry.metric_basis is TrackGeometryMetricBasis.CURRENT_POINTS
|
||||
):
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=geometry.owner_key,
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.UNAVAILABLE,
|
||||
reason_codes=(
|
||||
"e35-camera-lidar-offset-unqualified",
|
||||
"camera-remains-nonmetric",
|
||||
),
|
||||
semantic_track_id=geometry.semantic_track_id,
|
||||
semantic_label=geometry.semantic_label,
|
||||
bbox_xyxy=geometry.bbox_xyxy,
|
||||
)
|
||||
)
|
||||
geometry_owner = f"e35-timing-offset:{geometry.owner_key}"
|
||||
geometries.append(
|
||||
TrackGeometry(
|
||||
owner_key=geometry_owner,
|
||||
owner_kind=TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
evidence_state=TrackGeometryEvidenceState.GEOMETRY_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=(
|
||||
"e35-camera-lidar-offset-unqualified",
|
||||
"geometry-retained-without-semantics",
|
||||
),
|
||||
range_m=geometry.range_m,
|
||||
)
|
||||
)
|
||||
owner_map[geometry.owner_key] = geometry_owner
|
||||
else:
|
||||
geometries.append(geometry)
|
||||
if geometry.metric_basis is TrackGeometryMetricBasis.CURRENT_POINTS:
|
||||
owner_map[geometry.owner_key] = geometry.owner_key
|
||||
return _frame_with(
|
||||
frame,
|
||||
geometries=geometries,
|
||||
point_slab=_remap_slab(frame, geometries, owner_map),
|
||||
source_available=frame.source_available,
|
||||
)
|
||||
|
||||
|
||||
def _empty_frame(frame: TrackGeometryFrame) -> TrackGeometryFrame:
|
||||
return _frame_with(
|
||||
frame,
|
||||
geometries=[],
|
||||
point_slab=_empty_slab(frame),
|
||||
source_available=False,
|
||||
)
|
||||
|
||||
|
||||
def _frame_with(
|
||||
frame: TrackGeometryFrame,
|
||||
*,
|
||||
geometries: list[TrackGeometry],
|
||||
point_slab: PointSlab,
|
||||
source_available: bool,
|
||||
) -> TrackGeometryFrame:
|
||||
return TrackGeometryFrame(
|
||||
binding=frame.binding,
|
||||
frame_index=frame.frame_index,
|
||||
source_frame_index=frame.source_frame_index,
|
||||
session_seconds=frame.session_seconds,
|
||||
source_available=source_available,
|
||||
point_slab=point_slab,
|
||||
geometries=tuple(geometries),
|
||||
)
|
||||
|
||||
|
||||
def _empty_slab(frame: TrackGeometryFrame) -> PointSlab:
|
||||
return PointSlab(
|
||||
frame_index=frame.frame_index,
|
||||
source_frame_index=frame.source_frame_index,
|
||||
source_point_count=frame.point_slab.source_point_count,
|
||||
coordinate_frame=frame.point_slab.coordinate_frame,
|
||||
owner_keys=(),
|
||||
source_indices=np.empty(0, dtype="<i8"),
|
||||
points_xyz_m=np.empty((0, 3), dtype="<f4"),
|
||||
owner_indices=np.empty(0, dtype="<u4"),
|
||||
)
|
||||
|
||||
|
||||
def _remap_slab(
|
||||
frame: TrackGeometryFrame,
|
||||
geometries: list[TrackGeometry],
|
||||
owner_map: dict[str, str | None],
|
||||
) -> PointSlab:
|
||||
owner_keys = tuple(
|
||||
geometry.owner_key
|
||||
for geometry in geometries
|
||||
if geometry.metric_basis is TrackGeometryMetricBasis.CURRENT_POINTS
|
||||
)
|
||||
owner_indices_by_key = {
|
||||
owner_key: owner_index
|
||||
for owner_index, owner_key in enumerate(owner_keys)
|
||||
}
|
||||
source_indices: list[int] = []
|
||||
points: list[list[float]] = []
|
||||
owner_indices: list[int] = []
|
||||
for row_index, source_index in enumerate(frame.point_slab.source_indices):
|
||||
old_owner = frame.point_slab.owner_keys[
|
||||
int(frame.point_slab.owner_indices[row_index])
|
||||
]
|
||||
new_owner = owner_map.get(old_owner)
|
||||
if new_owner is None:
|
||||
continue
|
||||
source_indices.append(int(source_index))
|
||||
points.append(frame.point_slab.points_xyz_m[row_index].tolist())
|
||||
owner_indices.append(owner_indices_by_key[new_owner])
|
||||
return PointSlab(
|
||||
frame_index=frame.frame_index,
|
||||
source_frame_index=frame.source_frame_index,
|
||||
source_point_count=frame.point_slab.source_point_count,
|
||||
coordinate_frame=frame.point_slab.coordinate_frame,
|
||||
owner_keys=owner_keys,
|
||||
source_indices=np.asarray(source_indices, dtype="<i8"),
|
||||
points_xyz_m=(
|
||||
np.asarray(points, dtype="<f4")
|
||||
if points
|
||||
else np.empty((0, 3), dtype="<f4")
|
||||
),
|
||||
owner_indices=np.asarray(owner_indices, dtype="<u4"),
|
||||
)
|
||||
|
||||
|
||||
def _transformation(
|
||||
original_frame: TrackGeometryFrame,
|
||||
scenario: DegradationScenario,
|
||||
*,
|
||||
active: bool,
|
||||
action: str,
|
||||
channels: dict[str, str],
|
||||
transformed_frame: TrackGeometryFrame,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": E35_TRANSFORMATION_SCHEMA,
|
||||
"scenario_id": scenario.scenario_id,
|
||||
"kind": scenario.kind.value,
|
||||
"frame_index": original_frame.frame_index,
|
||||
"active": active,
|
||||
"action": action,
|
||||
"parameters": scenario.parameters if active else {},
|
||||
"channels": channels,
|
||||
"original_frame_sha256": frame_digest(original_frame),
|
||||
"transformed_frame_sha256": frame_digest(transformed_frame),
|
||||
"original": _frame_counts(original_frame),
|
||||
"transformed": _frame_counts(transformed_frame),
|
||||
"policy": {
|
||||
"absence_of_points_means_free": False,
|
||||
"late_results_may_reenter": False,
|
||||
"persistent_reconstruction_modified": False,
|
||||
},
|
||||
"authority": _authority(),
|
||||
}
|
||||
|
||||
|
||||
def _frame_counts(frame: TrackGeometryFrame) -> dict[str, int | bool]:
|
||||
return {
|
||||
"source_available": frame.source_available,
|
||||
"point_rows": frame.point_slab.row_count,
|
||||
"camera_tracks": sum(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
for geometry in frame.geometries
|
||||
),
|
||||
"geometry_clusters": sum(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER
|
||||
for geometry in frame.geometries
|
||||
),
|
||||
"agree": sum(
|
||||
geometry.evidence_state is TrackGeometryEvidenceState.AGREE
|
||||
for geometry in frame.geometries
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _nominal_channels(frame: TrackGeometryFrame) -> dict[str, str]:
|
||||
return {
|
||||
"camera": "available",
|
||||
"lidar": _lidar_state(frame),
|
||||
"pose": "available",
|
||||
"delivery": "on-time",
|
||||
}
|
||||
|
||||
|
||||
def _lidar_state(frame: TrackGeometryFrame) -> str:
|
||||
return "available" if frame.source_available else "source-unavailable"
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _authority() -> dict[str, bool]:
|
||||
return {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def _require_true(value: object) -> None:
|
||||
if value is not True:
|
||||
raise DegradationRecoveryError(
|
||||
"degradation source removal must be enabled"
|
||||
)
|
||||
|
||||
|
||||
def _positive_number(value: object, label: str) -> float:
|
||||
if (
|
||||
not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or float(value) <= 0.0
|
||||
):
|
||||
raise DegradationRecoveryError(f"{label} is invalid")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise DegradationRecoveryError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _string(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise DegradationRecoveryError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(
|
||||
not isinstance(key, str) for key in value
|
||||
):
|
||||
raise DegradationRecoveryError(f"{label} must be an object")
|
||||
return value
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,11 @@ from k1link.compute.e34_temporal_occupied_replay import (
|
||||
E34TemporalOccupiedReplayError,
|
||||
read_e34_temporal_occupied_replay,
|
||||
)
|
||||
from k1link.compute.e35_degradation_replay import (
|
||||
E35DegradationReplay,
|
||||
E35DegradationReplayError,
|
||||
read_e35_degradation_replay,
|
||||
)
|
||||
|
||||
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-advanced-catalog/v1"
|
||||
@@ -38,6 +43,7 @@ _E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$")
|
||||
_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$")
|
||||
_E33_RESULT_ID = re.compile(r"^e33-worker-shadow-[a-f0-9]{64}$")
|
||||
_E34_RESULT_ID = re.compile(r"^e34-temporal-occupied-[a-f0-9]{64}$")
|
||||
_E35_RESULT_ID = re.compile(r"^e35-degradation-recovery-[a-f0-9]{64}$")
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
@@ -88,6 +94,15 @@ def _read_e34_cached(
|
||||
return read_e34_temporal_occupied_replay(Path(root_text))
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _read_e35_cached(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> E35DegradationReplay:
|
||||
del signature
|
||||
return read_e35_degradation_replay(Path(root_text))
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
value = provider()
|
||||
if value is None:
|
||||
@@ -416,6 +431,61 @@ def _project_e34(result: E34TemporalOccupiedReplay) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _project_e35(result: E35DegradationReplay) -> dict[str, object]:
|
||||
identity = _object(result.manifest.get("identity"), "E35 identity")
|
||||
profile = _object(identity.get("profile"), "E35 profile")
|
||||
acceptance_profile = _object(
|
||||
profile.get("acceptance"),
|
||||
"E35 acceptance profile",
|
||||
)
|
||||
metrics = _object(result.report.get("metrics"), "E35 metrics")
|
||||
accounting = _object(metrics.get("accounting"), "E35 accounting")
|
||||
runtime = _object(metrics.get("runtime"), "E35 runtime")
|
||||
processing = _object(
|
||||
runtime.get("variant_frame_processing_ms"),
|
||||
"E35 processing",
|
||||
)
|
||||
acceptance = _object(result.report.get("acceptance"), "E35 acceptance")
|
||||
if acceptance.get("accepted") is not True:
|
||||
raise ValueError("E35 result is not accepted")
|
||||
return {
|
||||
"result_id": result.result_id,
|
||||
"created_at_utc": result.manifest.get("created_at_utc"),
|
||||
"source_session_id": identity.get("source_session_id"),
|
||||
"status": result.manifest.get("acceptance_state"),
|
||||
"e32_result_id": identity.get("e32_result_id"),
|
||||
"e33_result_id": identity.get("e33_result_id"),
|
||||
"e34_result_id": identity.get("e34_result_id"),
|
||||
"profile_id": profile.get("profile_id"),
|
||||
"pipeline_id": identity.get("pipeline"),
|
||||
"coordinate_frame": identity.get("coordinate_frame"),
|
||||
"configuration": {
|
||||
"maximum_recovery_seconds": acceptance_profile.get(
|
||||
"maximum_recovery_seconds"
|
||||
),
|
||||
"scenario_count": acceptance_profile.get(
|
||||
"expected_scenario_count"
|
||||
),
|
||||
},
|
||||
"metrics": {
|
||||
"source_frames": accounting.get("source_frames"),
|
||||
"variant_count": accounting.get("variant_count"),
|
||||
"variant_frame_outcomes": accounting.get(
|
||||
"variant_frame_outcomes"
|
||||
),
|
||||
"injection_records": accounting.get("injection_records"),
|
||||
"variant_frame_processing_p95_ms": processing.get("p95"),
|
||||
"build_elapsed_ms": runtime.get("build_elapsed_ms"),
|
||||
},
|
||||
"scenarios": copy.deepcopy(metrics.get("scenarios")),
|
||||
"review": copy.deepcopy(result.review),
|
||||
"acceptance": copy.deepcopy(acceptance),
|
||||
"decision": copy.deepcopy(result.report.get("decision")),
|
||||
"authority": copy.deepcopy(result.report.get("authority")),
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _empty_catalog(configured: bool) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": LABORATORY_ADVANCED_CATALOG_SCHEMA,
|
||||
@@ -433,6 +503,7 @@ def build_advanced_laboratory_router(
|
||||
e32_root_provider: RootProvider = lambda: None,
|
||||
e33_root_provider: RootProvider = lambda: None,
|
||||
e34_root_provider: RootProvider = lambda: None,
|
||||
e35_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||
|
||||
@@ -592,4 +663,39 @@ def build_advanced_laboratory_router(
|
||||
"invalid_total": invalid_total,
|
||||
}
|
||||
|
||||
@router.get("/e35/results")
|
||||
def list_e35_results(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
root = _configured_root(e35_root_provider)
|
||||
if root is None:
|
||||
return _empty_catalog(False)
|
||||
candidates = _candidates(root, _E35_RESULT_ID)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
result = _read_e35_cached(
|
||||
str(candidate.resolve()),
|
||||
_result_signature(candidate),
|
||||
)
|
||||
if not result.accepted:
|
||||
raise ValueError("E35 result is not accepted")
|
||||
if len(items) < limit:
|
||||
items.append(_project_e35(result))
|
||||
except (
|
||||
E35DegradationReplayError,
|
||||
KeyError,
|
||||
OSError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
):
|
||||
invalid_total += 1
|
||||
return {
|
||||
**_empty_catalog(True),
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
@@ -527,6 +527,13 @@ app.include_router(
|
||||
/ "e34"
|
||||
/ "results"
|
||||
),
|
||||
e35_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e35"
|
||||
/ "results"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -26,7 +26,7 @@ def _endpoint(router: APIRouter, path: str) -> object:
|
||||
def test_advanced_catalogs_are_empty_when_not_configured() -> None:
|
||||
router = build_advanced_laboratory_router()
|
||||
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
for name in ("e31", "e32", "e33", "e34", "e35"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog == {
|
||||
@@ -46,20 +46,23 @@ def test_advanced_catalogs_fail_closed_on_incomplete_results(
|
||||
e32 = tmp_path / "e32"
|
||||
e33 = tmp_path / "e33"
|
||||
e34 = tmp_path / "e34"
|
||||
for root in (e31, e32, e33, e34):
|
||||
e35 = tmp_path / "e35"
|
||||
for root in (e31, e32, e33, e34, e35):
|
||||
root.mkdir()
|
||||
(e31 / f"e31-source-qualification-{'1' * 64}").mkdir()
|
||||
(e32 / f"e32-track-geometry-{'2' * 64}").mkdir()
|
||||
(e33 / f"e33-worker-shadow-{'3' * 64}").mkdir()
|
||||
(e34 / f"e34-temporal-occupied-{'4' * 64}").mkdir()
|
||||
(e35 / f"e35-degradation-recovery-{'5' * 64}").mkdir()
|
||||
router = build_advanced_laboratory_router(
|
||||
e31_root_provider=lambda: e31,
|
||||
e32_root_provider=lambda: e32,
|
||||
e33_root_provider=lambda: e33,
|
||||
e34_root_provider=lambda: e34,
|
||||
e35_root_provider=lambda: e35,
|
||||
)
|
||||
|
||||
for name in ("e31", "e32", "e33", "e34"):
|
||||
for name in ("e31", "e32", "e33", "e34", "e35"):
|
||||
route = _endpoint(router, f"/api/v1/laboratory/{name}/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
assert catalog["configured"] is True
|
||||
@@ -209,3 +212,103 @@ def test_e34_catalog_projects_only_accepted_read_only_evidence(
|
||||
assert item["metrics"]["free_cell_rows"] == 0
|
||||
assert item["authority"] == authority
|
||||
assert item["access"] == "read-only"
|
||||
|
||||
|
||||
def test_e35_catalog_projects_recovery_and_review(
|
||||
tmp_path: Path,
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
result_id = f"e35-degradation-recovery-{'5' * 64}"
|
||||
root = tmp_path / "e35"
|
||||
candidate = root / result_id
|
||||
candidate.mkdir(parents=True)
|
||||
(candidate / "manifest.json").write_text("{}", encoding="utf-8")
|
||||
authority = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
result_id=result_id,
|
||||
accepted=True,
|
||||
manifest={
|
||||
"created_at_utc": "2026-07-27T15:00:00Z",
|
||||
"acceptance_state": (
|
||||
"accepted-deterministic-degradation-recovery"
|
||||
),
|
||||
"identity": {
|
||||
"source_session_id": "source-session",
|
||||
"e32_result_id": f"e32-track-geometry-{'2' * 64}",
|
||||
"e33_result_id": f"e33-worker-shadow-{'3' * 64}",
|
||||
"e34_result_id": f"e34-temporal-occupied-{'4' * 64}",
|
||||
"pipeline": (
|
||||
"track-geometry/temporal-layer/"
|
||||
"degradation-recovery/v1"
|
||||
),
|
||||
"coordinate_frame": "map",
|
||||
"profile": {
|
||||
"profile_id": (
|
||||
"e35-six-channel-degradation-recovery/v1"
|
||||
),
|
||||
"acceptance": {
|
||||
"maximum_recovery_seconds": 0.25,
|
||||
"expected_scenario_count": 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
report={
|
||||
"metrics": {
|
||||
"accounting": {
|
||||
"source_frames": 4489,
|
||||
"variant_count": 6,
|
||||
"variant_frame_outcomes": 26934,
|
||||
"injection_records": 360,
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "camera-loss",
|
||||
"recovery_seconds": 0.086,
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"build_elapsed_ms": 18618.0,
|
||||
"variant_frame_processing_ms": {"p95": 1.08},
|
||||
},
|
||||
},
|
||||
"acceptance": {"accepted": True, "requirements": {}},
|
||||
"decision": {"next_gate": "A9/E36"},
|
||||
"authority": authority,
|
||||
},
|
||||
review={
|
||||
"schema_version": "missioncore.e35-recovery-review/v1",
|
||||
"result_id": result_id,
|
||||
"scenarios": [],
|
||||
},
|
||||
)
|
||||
|
||||
def fake_read(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> SimpleNamespace:
|
||||
assert root_text == str(candidate.resolve())
|
||||
assert signature
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(advanced_api, "_read_e35_cached", fake_read)
|
||||
router = build_advanced_laboratory_router(
|
||||
e35_root_provider=lambda: root,
|
||||
)
|
||||
route = _endpoint(router, "/api/v1/laboratory/e35/results")
|
||||
catalog = route(limit=1) # type: ignore[operator]
|
||||
|
||||
assert catalog["candidate_total"] == 1
|
||||
assert catalog["invalid_total"] == 0
|
||||
item = catalog["items"][0]
|
||||
assert item["status"] == (
|
||||
"accepted-deterministic-degradation-recovery"
|
||||
)
|
||||
assert item["metrics"]["variant_frame_outcomes"] == 26934
|
||||
assert item["configuration"]["maximum_recovery_seconds"] == 0.25
|
||||
assert item["review"]["scenarios"] == []
|
||||
assert item["authority"] == authority
|
||||
assert item["access"] == "read-only"
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from k1link.compute.degradation_recovery import (
|
||||
DegradationKind,
|
||||
DegradationRecoveryError,
|
||||
DegradationScenario,
|
||||
transform_track_geometry_frame,
|
||||
)
|
||||
from k1link.compute.e35_degradation_replay import (
|
||||
E35DegradationReplayError,
|
||||
read_e35_degradation_profile,
|
||||
)
|
||||
from k1link.compute.track_geometry import (
|
||||
PointSlab,
|
||||
TrackGeometry,
|
||||
TrackGeometryCurrentness,
|
||||
TrackGeometryEvidenceState,
|
||||
TrackGeometryFrame,
|
||||
TrackGeometryMetricBasis,
|
||||
TrackGeometryOwnerKind,
|
||||
TrackGeometrySourceBinding,
|
||||
)
|
||||
|
||||
|
||||
def _binding() -> TrackGeometrySourceBinding:
|
||||
return TrackGeometrySourceBinding(
|
||||
source_pack_id="e10-lidar-pack-" + "a" * 64,
|
||||
source_session_id="source-session",
|
||||
representation_profile_id="representation-profile/v1",
|
||||
e31_qualification_id="e31-source-qualification-" + "b" * 64,
|
||||
calibration_sha256="c" * 64,
|
||||
coordinate_frame="map",
|
||||
time_basis="source-time",
|
||||
selected_offset_ms=0,
|
||||
)
|
||||
|
||||
|
||||
def _frame(frame_index: int = 10) -> TrackGeometryFrame:
|
||||
geometries = (
|
||||
TrackGeometry(
|
||||
owner_key="track:7",
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.AGREE,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=("accepted-hit-backed-support",),
|
||||
semantic_track_id=7,
|
||||
semantic_label="car",
|
||||
bbox_xyxy=(1.0, 1.0, 2.0, 2.0),
|
||||
range_m=3.0,
|
||||
),
|
||||
TrackGeometry(
|
||||
owner_key="geometry:8",
|
||||
owner_kind=TrackGeometryOwnerKind.GEOMETRY_CLUSTER,
|
||||
evidence_state=TrackGeometryEvidenceState.GEOMETRY_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.CURRENT_POINTS,
|
||||
reason_codes=("accepted-hit-backed-support",),
|
||||
range_m=4.0,
|
||||
),
|
||||
TrackGeometry(
|
||||
owner_key="track:9",
|
||||
owner_kind=TrackGeometryOwnerKind.CAMERA_TRACK,
|
||||
evidence_state=TrackGeometryEvidenceState.CAMERA_ONLY,
|
||||
currentness=TrackGeometryCurrentness.CURRENT,
|
||||
metric_basis=TrackGeometryMetricBasis.UNAVAILABLE,
|
||||
reason_codes=("camera-without-current-points",),
|
||||
semantic_track_id=9,
|
||||
semantic_label="person",
|
||||
bbox_xyxy=(2.0, 2.0, 3.0, 4.0),
|
||||
),
|
||||
)
|
||||
return TrackGeometryFrame(
|
||||
binding=_binding(),
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
session_seconds=frame_index / 10.0,
|
||||
source_available=True,
|
||||
point_slab=PointSlab(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=frame_index,
|
||||
source_point_count=4,
|
||||
coordinate_frame="map",
|
||||
owner_keys=("track:7", "geometry:8"),
|
||||
source_indices=np.asarray([0, 1, 2, 3], dtype="<i8"),
|
||||
points_xyz_m=np.asarray(
|
||||
[
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.1, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[2.1, 0.0, 0.0],
|
||||
],
|
||||
dtype="<f4",
|
||||
),
|
||||
owner_indices=np.asarray([0, 0, 1, 1], dtype="<u4"),
|
||||
),
|
||||
geometries=geometries,
|
||||
)
|
||||
|
||||
|
||||
def _scenario(
|
||||
kind: DegradationKind,
|
||||
parameters: dict[str, object],
|
||||
*,
|
||||
frame_start: int = 10,
|
||||
frame_end: int = 20,
|
||||
) -> DegradationScenario:
|
||||
return DegradationScenario(
|
||||
scenario_id=kind.value,
|
||||
kind=kind,
|
||||
frame_start=frame_start,
|
||||
frame_end=frame_end,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
def test_camera_loss_removes_semantics_but_retains_geometry_points() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert transformed.frame.point_slab.row_count == 4
|
||||
assert all(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.semantic_label is None
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert transformed.transformation["channels"]["camera"] == "unavailable"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "parameters", "expected_source_available"),
|
||||
[
|
||||
(
|
||||
DegradationKind.LIDAR_LOSS,
|
||||
{"drop_lidar_points": True},
|
||||
False,
|
||||
),
|
||||
(
|
||||
DegradationKind.POSE_STALENESS,
|
||||
{"pose_age_seconds": 0.5},
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_metric_loss_keeps_camera_nonmetric_and_withholds_map_rows(
|
||||
kind: DegradationKind,
|
||||
parameters: dict[str, object],
|
||||
expected_source_available: bool,
|
||||
) -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(kind, parameters),
|
||||
)
|
||||
|
||||
assert transformed.frame.point_slab.row_count == 0
|
||||
assert transformed.frame.source_available is expected_source_available
|
||||
assert all(
|
||||
geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.metric_basis is not TrackGeometryMetricBasis.CURRENT_POINTS
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
assert all(
|
||||
geometry.evidence_state is not TrackGeometryEvidenceState.AGREE
|
||||
for geometry in transformed.frame.geometries
|
||||
)
|
||||
|
||||
|
||||
def test_delayed_frame_is_discarded_and_cannot_retain_claims() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.DELAYED_FRAMES,
|
||||
{"delay_seconds": 1.0, "late_result_policy": "discard"},
|
||||
),
|
||||
)
|
||||
|
||||
assert not transformed.frame.source_available
|
||||
assert transformed.frame.point_slab.row_count == 0
|
||||
assert transformed.frame.geometries == ()
|
||||
assert transformed.transformation["action"] == "late-frame-discarded"
|
||||
|
||||
|
||||
def test_bounded_drop_is_deterministic_and_journals_pass_frames() -> None:
|
||||
scenario = _scenario(
|
||||
DegradationKind.BOUNDED_DROP,
|
||||
{"drop_every_nth_frame": 3},
|
||||
)
|
||||
dropped = transform_track_geometry_frame(_frame(10), scenario)
|
||||
passed_input = _frame(11)
|
||||
passed = transform_track_geometry_frame(passed_input, scenario)
|
||||
|
||||
assert dropped.frame.geometries == ()
|
||||
assert dropped.transformation["action"] == "input-frame-dropped"
|
||||
assert passed.frame is passed_input
|
||||
assert passed.frame.geometries
|
||||
assert passed.transformation["action"] == "bounded-drop-pass"
|
||||
|
||||
|
||||
def test_timing_offset_splits_camera_and_geometry_without_agree() -> None:
|
||||
transformed = transform_track_geometry_frame(
|
||||
_frame(),
|
||||
_scenario(
|
||||
DegradationKind.TIMING_OFFSET,
|
||||
{"camera_lidar_offset_ms": 250},
|
||||
),
|
||||
)
|
||||
|
||||
camera = [
|
||||
geometry
|
||||
for geometry in transformed.frame.geometries
|
||||
if geometry.owner_kind is TrackGeometryOwnerKind.CAMERA_TRACK
|
||||
]
|
||||
geometry = [
|
||||
item
|
||||
for item in transformed.frame.geometries
|
||||
if item.owner_kind is TrackGeometryOwnerKind.GEOMETRY_CLUSTER
|
||||
]
|
||||
assert transformed.frame.point_slab.row_count == 4
|
||||
assert len(camera) == 2
|
||||
assert len(geometry) == 2
|
||||
assert all(
|
||||
item.evidence_state is not TrackGeometryEvidenceState.AGREE
|
||||
for item in transformed.frame.geometries
|
||||
)
|
||||
assert transformed.frame.point_slab.owner_keys == (
|
||||
"e35-timing-offset:track:7",
|
||||
"geometry:8",
|
||||
)
|
||||
|
||||
|
||||
def test_outside_window_returns_exact_frame_with_inactive_journal() -> None:
|
||||
frame = _frame(9)
|
||||
transformed = transform_track_geometry_frame(
|
||||
frame,
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert transformed.frame is frame
|
||||
assert transformed.transformation["active"] is False
|
||||
assert transformed.transformation["original_frame_sha256"] == (
|
||||
transformed.transformation["transformed_frame_sha256"]
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_requires_exact_parameters_and_unsafe_offset_fails() -> None:
|
||||
with pytest.raises(DegradationRecoveryError):
|
||||
_scenario(DegradationKind.CAMERA_LOSS, {})
|
||||
with pytest.raises(DegradationRecoveryError):
|
||||
_scenario(
|
||||
DegradationKind.TIMING_OFFSET,
|
||||
{"camera_lidar_offset_ms": 100},
|
||||
)
|
||||
|
||||
|
||||
def test_source_frame_and_input_arrays_are_not_mutated() -> None:
|
||||
frame = _frame()
|
||||
before = copy.deepcopy(frame.to_dict())
|
||||
before_points = frame.point_slab.points_xyz_m.copy()
|
||||
|
||||
transform_track_geometry_frame(
|
||||
frame,
|
||||
_scenario(
|
||||
DegradationKind.CAMERA_LOSS,
|
||||
{"drop_camera_observations": True},
|
||||
),
|
||||
)
|
||||
|
||||
assert frame.to_dict() == before
|
||||
np.testing.assert_array_equal(frame.point_slab.points_xyz_m, before_points)
|
||||
|
||||
|
||||
def test_frozen_profile_contains_every_degradation_once() -> None:
|
||||
profile, scenarios = read_e35_degradation_profile(
|
||||
Path("experiments/perception/e35_deterministic_degradation_profile.json")
|
||||
)
|
||||
|
||||
assert profile["profile_id"] == "e35-six-channel-degradation-recovery/v1"
|
||||
assert {scenario.kind for scenario in scenarios} == set(DegradationKind)
|
||||
assert sum(scenario.frame_count for scenario in scenarios) == 360
|
||||
|
||||
|
||||
def test_profile_rejects_authority_and_policy_relaxation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = Path(
|
||||
"experiments/perception/e35_deterministic_degradation_profile.json"
|
||||
)
|
||||
profile = json.loads(source.read_text(encoding="utf-8"))
|
||||
profile["policy"]["late_results_may_reenter"] = True
|
||||
path = tmp_path / "relaxed.json"
|
||||
path.write_text(json.dumps(profile), encoding="utf-8")
|
||||
|
||||
with pytest.raises(E35DegradationReplayError):
|
||||
read_e35_degradation_profile(path)
|
||||
Reference in New Issue
Block a user