feat(perception): qualify E35 degradation recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-07-27 17:53:59 +03:00
parent 621084fcd6
commit b894945344
21 changed files with 3985 additions and 17 deletions
@@ -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;
}
+1
View File
@@ -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} · цепочка E32E34` },
{
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,0860,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" ? "E31E34" : null,
advanced.status === "rejected" ? "E31E35" : 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 E31E34 from separate read-only catalogs", async () => {
test("decodes E31E35 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 E31E34 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");
+13 -5
View File
@@ -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; E30E35 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; E32E34 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. A0A8 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 E32E35 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 A0A8.
The next critical-path gate is A9/E36: run the frozen E32E35 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())
+623
View File
@@ -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
+106
View File
@@ -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
+7
View File
@@ -527,6 +527,13 @@ app.include_router(
/ "e34"
/ "results"
),
e35_root_provider=lambda: (
REPOSITORY_ROOT
/ ".runtime"
/ "compute-experiments"
/ "e35"
/ "results"
),
)
)
app.include_router(
+106 -3
View File
@@ -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"
+315
View File
@@ -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)