feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
---
|
||||
name: mission-core-product-ui
|
||||
description: Design and enforce Mission Core product surfaces, information architecture, product UI, and laboratory presentation. Use for every new or relocated workspace, product root, navigation category, panel, viewer, Control Station surface, LAB catalog/summary, visualization control, fullscreen behavior, product-facing status, or UI refactor in NODEDC_MISSION_CORE, including work that could introduce a new component, page pattern, or task-specific composition.
|
||||
---
|
||||
|
||||
# Mission Core product UI
|
||||
|
||||
## Required context
|
||||
|
||||
Before changing UI:
|
||||
|
||||
1. Read the repository `AGENTS.md`.
|
||||
2. Read `docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md`.
|
||||
3. Read `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
4. For a new, relocated, or materially changed non-LAB surface, read
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||
5. For LAB work, also read `docs/15_LABORATORY_RUN_CANON.md`.
|
||||
6. Read the sibling Design Guideline:
|
||||
- `registry/registry.json`
|
||||
- `registry/components.json`
|
||||
- `registry/icons.json`
|
||||
- `docs/COMPONENTS.md`
|
||||
- `docs/GOVERNANCE.md`
|
||||
- the documentation for the affected page pattern or component.
|
||||
|
||||
Do not start implementation before resolving the required controls and icons in
|
||||
the registries.
|
||||
|
||||
## Component gate
|
||||
|
||||
- Reuse package exports from `@nodedc/ui-react`, `@nodedc/ui-core`,
|
||||
`@nodedc/tokens`, and `@nodedc/page-patterns`.
|
||||
- Do not create a local substitute for a button, icon button, segmented switch,
|
||||
select, dropdown, glass surface, status, window, focus state, hover state, or
|
||||
page pattern.
|
||||
- Treat an absent generic visual entity as a product decision. Stop, describe
|
||||
the user need and proposed generic API, and obtain explicit approval.
|
||||
- After approval, implement the generic entity in the Design Guideline first,
|
||||
including registry, documentation, interaction states, catalog example, and
|
||||
validation. Consume it from Mission Core only afterward.
|
||||
- Allow Mission Core domain renderers, data adapters, and domain layouts. Build
|
||||
their controls from canonical exports.
|
||||
|
||||
Explicit approval for one entity does not authorize unrelated visual entities.
|
||||
|
||||
## Product surface design
|
||||
|
||||
Keep product grammar stable without forcing one layout:
|
||||
|
||||
- Reuse the canonical shell, controls, states, icons, focus, motion, and
|
||||
responsive behavior.
|
||||
- Change composition when the operator job, primary entity, lifecycle, or
|
||||
action model changes materially.
|
||||
- Do not treat the LAB template as the default for non-LAB work.
|
||||
- Choose the smallest complete surface: view mode, inspector/panel, floating
|
||||
window, existing section, dedicated workspace, product root, plugin slot, or
|
||||
LAB result.
|
||||
|
||||
Before adding or relocating a workspace or changing primary navigation:
|
||||
|
||||
1. State the operator job, trigger/frequency, primary entity/lifecycle,
|
||||
evidence, authority, actions, and complete state grammar.
|
||||
2. Compare at least two credible placements or compositions.
|
||||
3. Resolve the Design Guideline primitives and page patterns.
|
||||
4. Classify the novelty:
|
||||
- domain content in an admitted composition;
|
||||
- new Mission Core-specific composition;
|
||||
- new shared Design Guideline entity/pattern;
|
||||
- new product root or platform meaning.
|
||||
5. Present the selected placement and rejected alternative with UX reasoning.
|
||||
6. Obtain product-owner agreement for a new/relocated workspace, primary
|
||||
navigation change, new root, or shared visual pattern.
|
||||
7. Implement only after the decision; do not add placeholder product routes or
|
||||
temporary navigation.
|
||||
|
||||
Valid task-specific compositions include scene-first, map-first,
|
||||
timeline-first, list/detail queue, table/catalog, form/editor, graph/topology,
|
||||
dashboard, and immutable LAB review.
|
||||
|
||||
## Application architecture
|
||||
|
||||
- Preserve `core → components/renderers → workspaces → composition/App`.
|
||||
- Core owns contracts, adapters, state machines, and domain hooks. It must not
|
||||
import visual adapters, workspaces, or App.
|
||||
- Reusable components may consume Core and canonical UI exports. They must not
|
||||
import workspaces or own product navigation.
|
||||
- Add every new domain or LAB run as a bounded feature module. Do not append its
|
||||
API adapter, renderer, selectors, or styles to `App.tsx`,
|
||||
`Workspaces.tsx`, or `styles/workspaces.css`.
|
||||
- Keep LAB composition under `workspaces/laboratory` and pass generic host
|
||||
viewers through typed contracts.
|
||||
- Treat `productModel.ts` as the application registry. Do not duplicate root,
|
||||
workspace, icon, or capability lists in the shell.
|
||||
- Do not introduce a runtime ontology merely to organize code. Use the typed
|
||||
registry, executable contracts, and experimental vocabulary until the
|
||||
multi-consumer admission gate in the architecture canon is met.
|
||||
- Never raise an architecture line-count ratchet to fit a new feature. Split
|
||||
the feature and retain the boundary test.
|
||||
|
||||
## Laboratory product contract
|
||||
|
||||
Use one template:
|
||||
|
||||
`selectors → compact summary → evidence → result → optional reusable details`
|
||||
|
||||
The compact summary must cover:
|
||||
|
||||
- decision question and purpose;
|
||||
- immutable source and tested bounds;
|
||||
- pipeline, execution class, models, algorithms, tools, and worker/runtime;
|
||||
- experimental feature or configuration;
|
||||
- principal result and limitation;
|
||||
- retained authority.
|
||||
|
||||
Keep manifests, long hashes, implementation files, validation logs, regressions,
|
||||
rejected approaches, and next-stage planning in the Mission Core Ops report.
|
||||
|
||||
Do not expose roadmap steps, next-gate checklists, internal reason taxonomies,
|
||||
debug controls, placeholder blocks, or implementation scaffolding in product
|
||||
UI.
|
||||
|
||||
## Evidence viewers
|
||||
|
||||
- Use one reusable viewer frame for related representations.
|
||||
- Provide canonical expand/restore through `IconButton` and `Icon`.
|
||||
- Provide mode switching through `SegmentedControl`.
|
||||
- Preserve selected evidence and mode across resize.
|
||||
- Use 3D for spatial shape, range, height, topology, support, and occupied
|
||||
volume.
|
||||
- Use 2D for camera-plane reprojection, bbox/mask agreement, calibration,
|
||||
field-of-view, occlusion, and pixel correspondence.
|
||||
- When both are useful, keep them as modes of the same viewer and share source
|
||||
indices and selected case.
|
||||
- Keep renderer colors semantic and token-derived. Never use them to invent
|
||||
control states or persistent colored outlines.
|
||||
|
||||
## Ops report boundary
|
||||
|
||||
When the task includes reporting a completed LAB or architecture milestone, use
|
||||
the direct `nodedc-ops-agent` tools and write titled structured blocks in this
|
||||
order:
|
||||
|
||||
1. Objective and architecture stage.
|
||||
2. Decision question and hypothesis.
|
||||
3. Immutable source evidence and bounds.
|
||||
4. Method, preprocessing, models, algorithms, tools, and identities.
|
||||
5. Worker/runtime topology and resource policy.
|
||||
6. Experimental implementation.
|
||||
7. Validation and reproduced evidence.
|
||||
8. Results.
|
||||
9. Regressions, rejected approaches, and limitations.
|
||||
10. Decision.
|
||||
11. Next stage and forbidden authority.
|
||||
12. Acceptance checker.
|
||||
|
||||
Do not turn the product UI into a duplicate Ops report.
|
||||
|
||||
## Validation
|
||||
|
||||
Before handoff:
|
||||
|
||||
1. Search the changed product UI for raw local controls, hard-coded product
|
||||
colors, temporary copy, gate/checklist UI, and per-LAB page branches.
|
||||
2. For a new surface, verify the product-surface brief names the user job,
|
||||
selected placement, rejected alternative, state grammar, and real acceptance
|
||||
evidence.
|
||||
3. Run `test/applicationArchitecture.test.mjs`, then the full frontend
|
||||
typecheck, unit tests, and production build.
|
||||
4. Use the in-app browser to verify normal and expanded modes, every new view
|
||||
mode, keyboard Escape, and the canonical control states.
|
||||
5. Verify the default composition answers the primary operator question.
|
||||
6. Leave a real evidence case open for product-owner review.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Mission Core Product UI"
|
||||
short_description: "Mission Core surfaces and LAB product architecture"
|
||||
default_prompt: "Use $mission-core-product-ui to design or review a Mission Core product surface and its placement."
|
||||
@@ -39,3 +39,65 @@ and the boundary between Mission Core and vendor-specific integration code.
|
||||
|
||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||
decoders before BLE/Wi-Fi/data-session evidence exists.
|
||||
|
||||
## Product UI governance
|
||||
|
||||
- For every Control Station, LAB, viewer, or product-presentation change, use
|
||||
`.codex/skills/mission-core-product-ui/SKILL.md` and follow
|
||||
`docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md` and
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
- `NODEDC_DESIGN_GUIDELINE` is the only visual-design source of truth. Before
|
||||
editing UI, read its `registry/registry.json`, `registry/components.json`,
|
||||
`registry/icons.json`, and the relevant component documentation.
|
||||
- Reuse `@nodedc/ui-react`, `@nodedc/ui-core`, tokens, icons, and page patterns.
|
||||
Do not create an application-local visual control, interaction state,
|
||||
geometry, color language, or copy of a design-system component.
|
||||
- If the required visual entity is absent from the Design Guideline, stop and
|
||||
obtain explicit product-owner approval. After approval, add it to the Design
|
||||
Guideline first with registry, documentation, states, and validation; only
|
||||
then consume it here.
|
||||
- Domain renderers may remain Mission Core code when they visualize Mission
|
||||
Core data. Their controls and containing surfaces must still be composed from
|
||||
canonical Design Guideline exports.
|
||||
- Product UI must not expose implementation steps, roadmap gates, internal
|
||||
reason taxonomies, debug controls, placeholder status blocks, or temporary
|
||||
experiment scaffolding. Keep these in Ops, engineering reports, or developer
|
||||
tooling.
|
||||
- Extend one reusable application pattern instead of adding per-LAB layouts.
|
||||
A new LAB supplies data and renderer configuration; it does not invent a new
|
||||
page hierarchy or visual language.
|
||||
- Do not generalize the LAB hierarchy into a universal application template.
|
||||
For a new non-LAB interface, follow
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`: identify the operator job,
|
||||
compare placement/composition alternatives, classify the novelty, and obtain
|
||||
product-owner agreement before adding a workspace, changing primary
|
||||
navigation, or introducing a new product root.
|
||||
- Preserve one product grammar while allowing task-specific composition. A
|
||||
scene, map, timeline, queue, editor, topology, dashboard, and LAB review may
|
||||
have different layouts when their entity, lifecycle, or action model differs.
|
||||
- Preserve the internal dependency direction:
|
||||
`core → components/renderers → workspaces → composition/App`. Core never
|
||||
imports workspaces or visual adapters; reusable components never import
|
||||
workspaces. New domains and LAB runs get their own feature module instead of
|
||||
growing `App.tsx`, `Workspaces.tsx`, or generic CSS buckets.
|
||||
- Treat `productModel.ts`, executable versioned contracts, and the experimental
|
||||
vocabulary as the current local semantic sources. Do not add a parallel
|
||||
runtime ontology unless the admission conditions in
|
||||
`docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md` are met.
|
||||
|
||||
## Laboratory presentation and reporting
|
||||
|
||||
- Use one fixed laboratory presentation contract: selectors, one compact
|
||||
canonical summary, admitted evidence viewer(s), result, and optional reusable
|
||||
technical details.
|
||||
- The compact summary must explain the decision question, immutable source,
|
||||
tested method/models/algorithms, experimental mode, principal result,
|
||||
limitations, and retained authority. It is not the engineering report.
|
||||
- The complete engineering report belongs in the Mission Core Ops card as
|
||||
titled structured blocks: objective and architecture stage, source evidence,
|
||||
method/models/algorithms, worker/runtime, implementation, validation,
|
||||
results, regressions and limitations, decision, next stage, and acceptance
|
||||
checker.
|
||||
- Spatial evidence uses 3D by default. Image-space reprojection may be offered
|
||||
as a 2D diagnostic mode inside the same reusable viewer. Primary evidence
|
||||
viewers must provide the canonical expand/restore action.
|
||||
|
||||
@@ -557,6 +557,11 @@ present.
|
||||
|
||||
- [Technical audit](docs/00_TECHNICAL_AUDIT.md)
|
||||
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
|
||||
- [Architecture audit execution roadmap](docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md)
|
||||
- [Laboratory run canon](docs/15_LABORATORY_RUN_CANON.md)
|
||||
- [Product UI and laboratory presentation canon](docs/17_PRODUCT_UI_AND_LAB_PRESENTATION_CANON.md)
|
||||
- [Application component architecture](docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md)
|
||||
- [Product surface extension protocol](docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md)
|
||||
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
|
||||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||
@@ -579,6 +584,7 @@ present.
|
||||
- [Device-bound K1 command authority](docs/adr/0012-device-bound-k1-command-authority.md)
|
||||
- [Simulation Polygon qualification boundary](docs/adr/0015-simulation-polygon-qualification-boundary.md)
|
||||
- [Distributed product, edge and worker topology](docs/adr/0016-distributed-product-edge-and-worker-topology.md)
|
||||
- [Sensor representation capabilities and free-space admission](docs/adr/0023-sensor-representation-capabilities-and-free-space-admission.md)
|
||||
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||
- [Canonical control and durable archive milestone](docs/lab/004_K1_CANONICAL_CONTROL_ARCHIVE_20260719.redacted.md)
|
||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { E30EngineeringGeneration } from "../../core/laboratory/e30Engineering";
|
||||
|
||||
export function E30EngineeringGenerationSummary({
|
||||
generation,
|
||||
}: {
|
||||
generation: E30EngineeringGeneration;
|
||||
}) {
|
||||
const summary = generation.summary;
|
||||
const confirmed = summary.verdictDistribution.confirmed ?? 0;
|
||||
const corrected = summary.verdictDistribution.corrected ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="e30-engineering-generation"
|
||||
aria-label="Результат A3 engineering generation"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">A3 · IMMUTABLE ENGINEERING GENERATION</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
tone={summary.humanExceptionCount ? "warning" : "accent"}
|
||||
>
|
||||
{summary.reviewedItemCount} / {summary.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Подтверждено</dt>
|
||||
<dd>{confirmed.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исправлено</dt>
|
||||
<dd>{corrected.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исключения</dt>
|
||||
<dd>{summary.humanExceptionCount.toLocaleString("ru-RU")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Средняя уверенность</dt>
|
||||
<dd>
|
||||
{(summary.meanConfidence * 100).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
%
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { E30ReviewItemDetail } from "../../core/laboratory/e30Review";
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItemDetail): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null ? "недоступна" : `${formatNumber(value)} м`;
|
||||
}
|
||||
|
||||
export function E30EvidenceTelemetry({
|
||||
detail,
|
||||
mode,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
mode: "camera" | "3d";
|
||||
}) {
|
||||
return (
|
||||
<aside
|
||||
className="e30-evidence-telemetry"
|
||||
aria-label="Параметры доказательства"
|
||||
>
|
||||
<span>
|
||||
{mode === "camera"
|
||||
? "Точный camera frame · LiDAR projection"
|
||||
: "Map frame · Z вверх"}
|
||||
</span>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Кадр / время</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.sourceFrameIndex)}
|
||||
{" · "}
|
||||
{formatNumber(detail.sessionSeconds)} с
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>LiDAR</dt>
|
||||
<dd>{formatNumber(detail.materialization.projectedPointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Кандидаты</dt>
|
||||
<dd>{formatNumber(detail.materialization.candidatePointCount)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Выбрано / отклонено</dt>
|
||||
<dd>
|
||||
{formatNumber(detail.materialization.selectedPointCount)}
|
||||
{" / "}
|
||||
{formatNumber(detail.materialization.rejectedCandidatePointCount)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Класс E29</dt>
|
||||
<dd>{detail.snapshot.geometryStatus}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Дальность</dt>
|
||||
<dd>{evidenceRange(detail)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<span>Точные индексы LiDAR</span>
|
||||
<code>
|
||||
{detail.selected.sourceIndices.length
|
||||
? detail.selected.sourceIndices.slice(0, 18).join(", ")
|
||||
: "нет выбранных точек"}
|
||||
{detail.selected.sourceIndices.length > 18 ? " …" : ""}
|
||||
</code>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryEvidenceViewerMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function LaboratoryEvidenceViewer<T extends string>({
|
||||
label,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
onModeChange,
|
||||
onExpandedChange,
|
||||
actions,
|
||||
overlay,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
onModeChange: (mode: T) => void;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
actions?: ReactNode;
|
||||
overlay?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const expandButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
onExpandedChange(false);
|
||||
window.requestAnimationFrame(() => expandButtonRef.current?.focus());
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [expanded, onExpandedChange]);
|
||||
|
||||
const viewer = (
|
||||
<section
|
||||
className="laboratory-evidence-viewer"
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="laboratory-evidence-viewer__stage">
|
||||
{children}
|
||||
</div>
|
||||
{overlay}
|
||||
<div className="laboratory-evidence-viewer__controls">
|
||||
{actions}
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
items={[...modes]}
|
||||
label={`${label}: режим представления`}
|
||||
onChange={onModeChange}
|
||||
/>
|
||||
<IconButton
|
||||
ref={expandButtonRef}
|
||||
label={expanded ? `Свернуть ${label}` : `Развернуть ${label}`}
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
>
|
||||
<Icon name={expanded ? "minimize" : "expand"} size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
return expanded ? createPortal(viewer, document.body) : viewer;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Select,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
export interface LaboratoryOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type LaboratoryExecutionClass =
|
||||
| "deterministic"
|
||||
| "ai-inference"
|
||||
| "hybrid";
|
||||
export type LaboratoryMethodCompleteness = "complete" | "legacy-partial";
|
||||
export type LaboratoryEvidenceKind = "recorded-replay" | "diagnostic-model";
|
||||
|
||||
export interface LaboratoryMethodComponent {
|
||||
kind: "source" | "tool" | "model" | "algorithm" | "runtime";
|
||||
name: string;
|
||||
version: string;
|
||||
role: string;
|
||||
identitySha256: string | null;
|
||||
}
|
||||
|
||||
export interface LaboratoryMethod {
|
||||
completeness: LaboratoryMethodCompleteness;
|
||||
executionClass: LaboratoryExecutionClass;
|
||||
pipelineId: string;
|
||||
components: readonly LaboratoryMethodComponent[];
|
||||
}
|
||||
|
||||
const EXECUTION_LABELS: Record<LaboratoryExecutionClass, string> = {
|
||||
deterministic: "Детерминированный",
|
||||
"ai-inference": "AI inference",
|
||||
hybrid: "Гибридный",
|
||||
};
|
||||
|
||||
const COMPONENT_LABELS: Record<LaboratoryMethodComponent["kind"], string> = {
|
||||
source: "Источник",
|
||||
tool: "Инструмент",
|
||||
model: "Модель",
|
||||
algorithm: "Алгоритм",
|
||||
runtime: "Runtime",
|
||||
};
|
||||
|
||||
export function LaboratorySelector<T extends string>({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: readonly LaboratoryOption<T>[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="laboratory-selector">
|
||||
<div>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="laboratory-selector__control">
|
||||
<span>{label}</span>
|
||||
<Select
|
||||
label={`Выбрать: ${label}`}
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
}))}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={disabled}
|
||||
onChange={(next) => onChange(next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratorySummary({
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
statusTone = "neutral",
|
||||
facts,
|
||||
method,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
statusTone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
facts: readonly { label: string; value: string }[];
|
||||
method?: LaboratoryMethod | null;
|
||||
}) {
|
||||
const methodComplete = method?.completeness === "complete";
|
||||
return (
|
||||
<section className="laboratory-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЛАБОРАТОРНАЯ РАБОТА</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone}>{status}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<dl className="laboratory-summary__facts">
|
||||
{facts.map((fact) => (
|
||||
<div key={fact.label}>
|
||||
<dt>{fact.label}</dt>
|
||||
<dd>{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{method ? (
|
||||
<div className="laboratory-summary__method">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">МЕТОД</span>
|
||||
<strong>{method.pipelineId}</strong>
|
||||
</div>
|
||||
<small>
|
||||
{EXECUTION_LABELS[method.executionClass]}
|
||||
{" · "}
|
||||
{methodComplete ? "полная идентичность" : "legacy · частично"}
|
||||
</small>
|
||||
</header>
|
||||
<dl className="laboratory-summary__components">
|
||||
{method.components.map((component, index) => (
|
||||
<div key={`${component.kind}:${component.name}:${index}`}>
|
||||
<dt>{COMPONENT_LABELS[component.kind]}</dt>
|
||||
<dd>
|
||||
<strong>{component.name}</strong>
|
||||
<small>
|
||||
{component.role}
|
||||
{" · "}
|
||||
{component.version}
|
||||
{component.identitySha256
|
||||
? ` · ${component.identitySha256.slice(0, 12)}`
|
||||
: ""}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryEvidence({
|
||||
eyebrow,
|
||||
title,
|
||||
kind,
|
||||
resizable = false,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
kind: LaboratoryEvidenceKind;
|
||||
resizable?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="lab-result-surface"
|
||||
data-evidence-kind={kind}
|
||||
data-resizable={resizable ? "true" : undefined}
|
||||
>
|
||||
<header>
|
||||
<span className="section-eyebrow">{eyebrow}</span>
|
||||
<strong>{title}</strong>
|
||||
</header>
|
||||
<div className="laboratory-evidence-frame">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryWorkTemplate({
|
||||
summary,
|
||||
evidence,
|
||||
result = null,
|
||||
details = null,
|
||||
}: {
|
||||
summary: ReactNode;
|
||||
evidence: ReactNode;
|
||||
result?: ReactNode;
|
||||
details?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="laboratory-work-template">
|
||||
{summary}
|
||||
{evidence}
|
||||
{result}
|
||||
{details}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import {
|
||||
parseE30ReviewItem,
|
||||
type E30ReviewItem,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export type E30EngineeringVerdict =
|
||||
| "confirmed"
|
||||
| "corrected"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30DetectorAssessment =
|
||||
| "valid"
|
||||
| "class-mismatch"
|
||||
| "false-positive"
|
||||
| "missed-object"
|
||||
| "not-applicable"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export type E30ProjectionAssessment =
|
||||
| "aligned"
|
||||
| "misaligned"
|
||||
| "not-assessable";
|
||||
|
||||
export type E30PointOwnership =
|
||||
| "object"
|
||||
| "surface-or-background"
|
||||
| "static-environment"
|
||||
| "self"
|
||||
| "insufficient-support"
|
||||
| "not-applicable"
|
||||
| "mixed"
|
||||
| "insufficient-evidence";
|
||||
|
||||
export interface E30ExceptionReviewPrompt {
|
||||
question: string;
|
||||
focus: string;
|
||||
effects: Readonly<{
|
||||
"object-present": string;
|
||||
"background-or-noise": string;
|
||||
"insufficient-evidence": string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface E30EngineeringGeneration {
|
||||
generationId: string;
|
||||
createdAtUtc: string;
|
||||
materializationId: string;
|
||||
producer: {
|
||||
producerId: string;
|
||||
methodId: string;
|
||||
reviewSheetIdentitySha256: string;
|
||||
claimsHumanGroundTruth: false;
|
||||
};
|
||||
summary: {
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
verdictDistribution: Readonly<Record<string, number>>;
|
||||
detectorDistribution: Readonly<Record<string, number>>;
|
||||
projectionDistribution: Readonly<Record<string, number>>;
|
||||
pointOwnershipDistribution: Readonly<Record<string, number>>;
|
||||
humanExceptionCount: number;
|
||||
meanConfidence: number;
|
||||
};
|
||||
causeDistribution: readonly {
|
||||
reasonCode: string;
|
||||
count: number;
|
||||
}[];
|
||||
humanExceptions: readonly {
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
confidence: number;
|
||||
reviewPrompt: E30ExceptionReviewPrompt | null;
|
||||
}[];
|
||||
aiReviewComplete: true;
|
||||
humanExceptionComplete: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30EngineeringGeneration[];
|
||||
}
|
||||
|
||||
export interface E30EngineeringDecision {
|
||||
sequence: number;
|
||||
itemId: string;
|
||||
reviewKey: string;
|
||||
sourceStratum: E30Stratum;
|
||||
verdict: E30EngineeringVerdict;
|
||||
effectiveStratum: E30Stratum | null;
|
||||
detectorAssessment: E30DetectorAssessment;
|
||||
projectionAssessment: E30ProjectionAssessment;
|
||||
pointOwnership: E30PointOwnership;
|
||||
causeCode: string | null;
|
||||
confidence: number;
|
||||
humanExceptionRequired: boolean;
|
||||
exceptionReason: "ambiguity" | "high-impact" | null;
|
||||
evidenceNote: string;
|
||||
reviewSheet: {
|
||||
path: string;
|
||||
sha256: string;
|
||||
ordinal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface E30EngineeringExceptions {
|
||||
resultId: string;
|
||||
generationId: string;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
}
|
||||
|
||||
export class E30EngineeringContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30EngineeringContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 E30EngineeringContractError(`${label}: неверный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30EngineeringContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function authority(value: unknown, label: string): void {
|
||||
const parsed = record(value, label);
|
||||
falseValue(parsed.commands_enabled, `${label}.commands_enabled`);
|
||||
falseValue(
|
||||
parsed.navigation_or_safety_accepted,
|
||||
`${label}.navigation_or_safety_accepted`,
|
||||
);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const parsed = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableStratum(value: unknown, label: string): E30Stratum | null {
|
||||
return value === null ? null : stratum(value, label);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
values: readonly T[],
|
||||
label: string,
|
||||
): T {
|
||||
if (typeof value !== "string" || !values.includes(value as T)) {
|
||||
throw new E30EngineeringContractError(`${label}: неизвестное значение.`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function exceptionReviewPrompt(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionReviewPrompt | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
const source = record(value, label);
|
||||
const effects = record(source.effects, `${label}.effects`);
|
||||
return {
|
||||
question: stringValue(source.question, `${label}.question`),
|
||||
focus: stringValue(source.focus, `${label}.focus`),
|
||||
effects: {
|
||||
"object-present": stringValue(
|
||||
effects["object-present"],
|
||||
`${label}.effects.object-present`,
|
||||
),
|
||||
"background-or-noise": stringValue(
|
||||
effects["background-or-noise"],
|
||||
`${label}.effects.background-or-noise`,
|
||||
),
|
||||
"insufficient-evidence": stringValue(
|
||||
effects["insufficient-evidence"],
|
||||
`${label}.effects.insufficient-evidence`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30EngineeringGeneration {
|
||||
const source = record(value, "A3 generation");
|
||||
const producer = record(source.producer, "A3 producer");
|
||||
const summary = record(source.summary, "A3 summary");
|
||||
const causes = record(source.cause_distribution, "A3 causes");
|
||||
const humanExceptions = source.human_exceptions;
|
||||
if (
|
||||
producer.kind !== "ai-assisted-engineering-review"
|
||||
|| !Array.isArray(causes.reasons)
|
||||
|| !Array.isArray(humanExceptions)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 provenance не поддерживается.");
|
||||
}
|
||||
falseValue(
|
||||
producer.claims_human_ground_truth,
|
||||
"A3 producer.claims_human_ground_truth",
|
||||
);
|
||||
authority(source.authority, "A3 authority");
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 generation_id",
|
||||
),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "A3 created_at_utc"),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"A3 materialization_id",
|
||||
),
|
||||
producer: {
|
||||
producerId: stringValue(producer.producer_id, "A3 producer_id"),
|
||||
methodId: stringValue(producer.method_id, "A3 method_id"),
|
||||
reviewSheetIdentitySha256: stringValue(
|
||||
producer.review_sheet_identity_sha256,
|
||||
"A3 review_sheet_identity_sha256",
|
||||
),
|
||||
claimsHumanGroundTruth: false,
|
||||
},
|
||||
summary: {
|
||||
itemCount: integerValue(summary.item_count, "A3 item_count"),
|
||||
reviewedItemCount: integerValue(
|
||||
summary.reviewed_item_count,
|
||||
"A3 reviewed_item_count",
|
||||
),
|
||||
verdictDistribution: distribution(
|
||||
summary.verdict_distribution,
|
||||
"A3 verdict_distribution",
|
||||
),
|
||||
detectorDistribution: distribution(
|
||||
summary.detector_distribution,
|
||||
"A3 detector_distribution",
|
||||
),
|
||||
projectionDistribution: distribution(
|
||||
summary.projection_distribution,
|
||||
"A3 projection_distribution",
|
||||
),
|
||||
pointOwnershipDistribution: distribution(
|
||||
summary.point_ownership_distribution,
|
||||
"A3 point_ownership_distribution",
|
||||
),
|
||||
humanExceptionCount: integerValue(
|
||||
summary.human_exception_count,
|
||||
"A3 human_exception_count",
|
||||
),
|
||||
meanConfidence: numberValue(
|
||||
summary.mean_confidence,
|
||||
"A3 mean_confidence",
|
||||
),
|
||||
},
|
||||
causeDistribution: causes.reasons.map((value, index) => {
|
||||
const reason = record(value, `A3 causes[${index}]`);
|
||||
return {
|
||||
reasonCode: stringValue(
|
||||
reason.reason_code,
|
||||
`A3 causes[${index}].reason_code`,
|
||||
),
|
||||
count: integerValue(reason.count, `A3 causes[${index}].count`),
|
||||
};
|
||||
}),
|
||||
humanExceptions: humanExceptions.map((value, index) => {
|
||||
const exception = record(value, `A3 exceptions[${index}]`);
|
||||
const confidence = numberValue(
|
||||
exception.confidence,
|
||||
`A3 exceptions[${index}].confidence`,
|
||||
);
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError(
|
||||
`A3 exceptions[${index}].confidence вне диапазона.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
exception.item_id,
|
||||
"e30-review-item",
|
||||
`A3 exceptions[${index}].item_id`,
|
||||
),
|
||||
reviewKey: stringValue(
|
||||
exception.review_key,
|
||||
`A3 exceptions[${index}].review_key`,
|
||||
),
|
||||
sourceStratum: stratum(
|
||||
exception.source_stratum,
|
||||
`A3 exceptions[${index}].source_stratum`,
|
||||
),
|
||||
confidence,
|
||||
reviewPrompt: exceptionReviewPrompt(
|
||||
exception.review_prompt,
|
||||
`A3 exceptions[${index}].review_prompt`,
|
||||
),
|
||||
};
|
||||
}),
|
||||
aiReviewComplete: trueValue(
|
||||
source.ai_review_complete,
|
||||
"A3 ai_review_complete",
|
||||
),
|
||||
humanExceptionComplete: booleanValue(
|
||||
source.human_exception_complete,
|
||||
"A3 human_exception_complete",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"A3 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "A3 lab_published"),
|
||||
access: readOnly(source.access, "A3 access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringCatalog(
|
||||
value: unknown,
|
||||
): E30EngineeringCatalog {
|
||||
const source = record(value, "A3 catalog");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-generations/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 catalog schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "A3 catalog access");
|
||||
return {
|
||||
configured: Boolean(source.configured),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"A3 candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "A3 invalid_total"),
|
||||
items: source.items.map(parseGeneration),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringDecision(
|
||||
value: unknown,
|
||||
): E30EngineeringDecision {
|
||||
const response = record(value, "A3 decision response");
|
||||
if (
|
||||
response.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-decision/v1"
|
||||
) {
|
||||
throw new E30EngineeringContractError("A3 decision schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "A3 decision access");
|
||||
contentId(
|
||||
response.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 decision generation_id",
|
||||
);
|
||||
const source = record(response.decision, "A3 decision");
|
||||
const sheet = record(source.review_sheet, "A3 review_sheet");
|
||||
const confidence = numberValue(source.confidence, "A3 confidence");
|
||||
if (confidence < 0 || confidence > 1) {
|
||||
throw new E30EngineeringContractError("A3 confidence вне диапазона.");
|
||||
}
|
||||
const exceptionReason = source.exception_reason === null
|
||||
? null
|
||||
: enumValue(
|
||||
source.exception_reason,
|
||||
["ambiguity", "high-impact"] as const,
|
||||
"A3 exception_reason",
|
||||
);
|
||||
return {
|
||||
sequence: integerValue(source.sequence, "A3 sequence"),
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"A3 decision item_id",
|
||||
),
|
||||
reviewKey: stringValue(source.review_key, "A3 review_key"),
|
||||
sourceStratum: stratum(source.source_stratum, "A3 source_stratum"),
|
||||
verdict: enumValue(
|
||||
source.verdict,
|
||||
["confirmed", "corrected", "insufficient-evidence"] as const,
|
||||
"A3 verdict",
|
||||
),
|
||||
effectiveStratum: nullableStratum(
|
||||
source.effective_stratum,
|
||||
"A3 effective_stratum",
|
||||
),
|
||||
detectorAssessment: enumValue(
|
||||
source.detector_assessment,
|
||||
[
|
||||
"valid",
|
||||
"class-mismatch",
|
||||
"false-positive",
|
||||
"missed-object",
|
||||
"not-applicable",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 detector_assessment",
|
||||
),
|
||||
projectionAssessment: enumValue(
|
||||
source.projection_assessment,
|
||||
["aligned", "misaligned", "not-assessable"] as const,
|
||||
"A3 projection_assessment",
|
||||
),
|
||||
pointOwnership: enumValue(
|
||||
source.point_ownership,
|
||||
[
|
||||
"object",
|
||||
"surface-or-background",
|
||||
"static-environment",
|
||||
"self",
|
||||
"insufficient-support",
|
||||
"not-applicable",
|
||||
"mixed",
|
||||
"insufficient-evidence",
|
||||
] as const,
|
||||
"A3 point_ownership",
|
||||
),
|
||||
causeCode: source.cause_code === null
|
||||
? null
|
||||
: stringValue(source.cause_code, "A3 cause_code"),
|
||||
confidence,
|
||||
humanExceptionRequired: booleanValue(
|
||||
source.human_exception_required,
|
||||
"A3 human_exception_required",
|
||||
),
|
||||
exceptionReason,
|
||||
evidenceNote: stringValue(source.evidence_note, "A3 evidence_note"),
|
||||
reviewSheet: {
|
||||
path: stringValue(sheet.path, "A3 review_sheet.path"),
|
||||
sha256: stringValue(sheet.sha256, "A3 review_sheet.sha256"),
|
||||
ordinal: integerValue(sheet.ordinal, "A3 review_sheet.ordinal"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30EngineeringExceptions(
|
||||
value: unknown,
|
||||
): E30EngineeringExceptions {
|
||||
const source = record(value, "A3 exception queue");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30EngineeringContractError(
|
||||
"A3 exception queue schema не поддерживается.",
|
||||
);
|
||||
}
|
||||
readOnly(source.access, "A3 exception queue access");
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"A3 exception queue result_id",
|
||||
),
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-engineering-generation",
|
||||
"A3 exception queue generation_id",
|
||||
),
|
||||
items: source.items.map(parseE30ReviewItem),
|
||||
total: integerValue(source.total, "A3 exception queue total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(
|
||||
source.next_cursor,
|
||||
"A3 exception queue next_cursor",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30EngineeringContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validMaterializationId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validGenerationId(value: string): boolean {
|
||||
return /^e30-engineering-generation-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringCatalog(
|
||||
resultId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringCatalog> {
|
||||
if (!validMaterializationId(resultId)) {
|
||||
throw new E30EngineeringContractError("Некорректный E30 materialization id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations?limit=1`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringCatalog(
|
||||
await responseJson(response, "Не удалось получить A3 generation."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringDecision(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringDecision> {
|
||||
if (
|
||||
!validMaterializationId(resultId)
|
||||
|| !validGenerationId(generationId)
|
||||
|| !validItemId(itemId)
|
||||
) {
|
||||
throw new E30EngineeringContractError("Некорректный A3 decision id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringDecision(
|
||||
await responseJson(response, "Не удалось получить A3 decision."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30EngineeringExceptions(
|
||||
resultId: string,
|
||||
generationId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30EngineeringExceptions> {
|
||||
if (!validMaterializationId(resultId) || !validGenerationId(generationId)) {
|
||||
throw new E30EngineeringContractError("Некорректная A3 exception queue.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/exceptions?limit=128`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30EngineeringExceptions(
|
||||
await responseJson(response, "Не удалось получить A3 exception queue."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import {
|
||||
E30ReviewContractError,
|
||||
type E30Stratum,
|
||||
} from "./e30Review";
|
||||
|
||||
export const E30_EXCEPTION_DISPOSITIONS = [
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
] as const;
|
||||
|
||||
export type E30ExceptionDisposition =
|
||||
(typeof E30_EXCEPTION_DISPOSITIONS)[number];
|
||||
export type E30HumanReviewState = "active" | "finalized";
|
||||
|
||||
export interface E30HumanReviewDecision {
|
||||
itemId: string;
|
||||
sourceStratum: E30Stratum;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
eventId: string;
|
||||
decidedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface E30HumanReviewDraft {
|
||||
draftId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
state: E30HumanReviewState;
|
||||
revision: number;
|
||||
itemCount: number;
|
||||
reviewedItemCount: number;
|
||||
remainingItemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
generationId: string | null;
|
||||
decisions: readonly E30HumanReviewDecision[];
|
||||
labPublished: false;
|
||||
access: "review-write" | "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewGeneration {
|
||||
generationId: string;
|
||||
materializationId: string;
|
||||
engineeringGenerationId: string;
|
||||
reviewerId: string;
|
||||
createdAtUtc: string;
|
||||
itemCount: number;
|
||||
dispositionDistribution: Readonly<Record<string, number>>;
|
||||
coverage: {
|
||||
expectedItemCount: number;
|
||||
reviewedItemCount: number;
|
||||
complete: true;
|
||||
};
|
||||
humanReviewComplete: true;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30HumanReviewFinalized {
|
||||
draft: E30HumanReviewDraft;
|
||||
generation: E30HumanReviewGeneration;
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (
|
||||
typeof value !== "number"
|
||||
|| !Number.isSafeInteger(value)
|
||||
|| value < 0
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function contentId(value: unknown, prefix: string, label: string): string {
|
||||
const parsed = stringValue(value, label);
|
||||
if (!new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(parsed)) {
|
||||
throw new E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function distribution(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): Readonly<Record<string, number>> {
|
||||
const source = record(value, label);
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).map(([key, count]) => [
|
||||
key,
|
||||
integerValue(count, `${label}.${key}`),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function disposition(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): E30ExceptionDisposition {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_EXCEPTION_DISPOSITIONS.includes(
|
||||
value as E30ExceptionDisposition,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестное решение.`);
|
||||
}
|
||||
return value as E30ExceptionDisposition;
|
||||
}
|
||||
|
||||
function stratum(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
value !== "conflict"
|
||||
&& value !== "agree"
|
||||
&& value !== "camera-only"
|
||||
&& value !== "unknown"
|
||||
&& value !== "geometry-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDecision(value: unknown): E30HumanReviewDecision {
|
||||
const source = record(value, "Решение по исключению");
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"Решение.item_id",
|
||||
),
|
||||
sourceStratum: stratum(source.source_stratum, "Решение.source_stratum"),
|
||||
disposition: disposition(source.disposition, "Решение.disposition"),
|
||||
notes: nullableString(source.notes, "Решение.notes"),
|
||||
eventId: contentId(
|
||||
source.event_id,
|
||||
"e30-review-event",
|
||||
"Решение.event_id",
|
||||
),
|
||||
decidedAtUtc: stringValue(
|
||||
source.decided_at_utc,
|
||||
"Решение.decided_at_utc",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewDraft(
|
||||
value: unknown,
|
||||
): E30HumanReviewDraft {
|
||||
const source = record(value, "Проверка исключений");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-draft/v2"
|
||||
|| !Array.isArray(source.decisions)
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема проверки исключений не поддерживается.",
|
||||
);
|
||||
}
|
||||
const state = stringValue(source.state, "Проверка.state");
|
||||
const access = stringValue(source.access, "Проверка.access");
|
||||
if (
|
||||
(state !== "active" && state !== "finalized")
|
||||
|| (state === "active" && access !== "review-write")
|
||||
|| (state === "finalized" && access !== "read-only")
|
||||
) {
|
||||
throw new E30ReviewContractError("Состояние проверки некорректно.");
|
||||
}
|
||||
const itemCount = integerValue(source.item_count, "Проверка.item_count");
|
||||
const reviewedItemCount = integerValue(
|
||||
source.reviewed_item_count,
|
||||
"Проверка.reviewed_item_count",
|
||||
);
|
||||
const remainingItemCount = integerValue(
|
||||
source.remaining_item_count,
|
||||
"Проверка.remaining_item_count",
|
||||
);
|
||||
const decisions = source.decisions.map(parseDecision);
|
||||
if (
|
||||
reviewedItemCount + remainingItemCount !== itemCount
|
||||
|| decisions.length !== reviewedItemCount
|
||||
) {
|
||||
throw new E30ReviewContractError("Покрытие проверки расходится.");
|
||||
}
|
||||
return {
|
||||
draftId: contentId(
|
||||
source.draft_id,
|
||||
"e30-human-draft",
|
||||
"Проверка.draft_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
state,
|
||||
revision: integerValue(source.revision, "Проверка.revision"),
|
||||
itemCount,
|
||||
reviewedItemCount,
|
||||
remainingItemCount,
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
generationId: source.generation_id === null
|
||||
? null
|
||||
: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
decisions,
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: access as "review-write" | "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneration(value: unknown): E30HumanReviewGeneration {
|
||||
const source = record(value, "Зафиксированная проверка");
|
||||
const coverage = record(source.coverage, "Проверка.coverage");
|
||||
if (
|
||||
source.schema_version !== "missioncore.e30-human-review-generation/v2"
|
||||
|| source.access !== "read-only"
|
||||
) {
|
||||
throw new E30ReviewContractError(
|
||||
"Схема зафиксированной проверки не поддерживается.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
generationId: contentId(
|
||||
source.generation_id,
|
||||
"e30-review-generation",
|
||||
"Проверка.generation_id",
|
||||
),
|
||||
materializationId: contentId(
|
||||
source.materialization_id,
|
||||
"e30-materialization",
|
||||
"Проверка.materialization_id",
|
||||
),
|
||||
engineeringGenerationId: contentId(
|
||||
source.engineering_generation_id,
|
||||
"e30-engineering-generation",
|
||||
"Проверка.engineering_generation_id",
|
||||
),
|
||||
reviewerId: stringValue(source.reviewer_id, "Проверка.reviewer_id"),
|
||||
createdAtUtc: stringValue(source.created_at_utc, "Проверка.created_at_utc"),
|
||||
itemCount: integerValue(source.item_count, "Проверка.item_count"),
|
||||
dispositionDistribution: distribution(
|
||||
source.disposition_distribution,
|
||||
"Проверка.disposition_distribution",
|
||||
),
|
||||
coverage: {
|
||||
expectedItemCount: integerValue(
|
||||
coverage.expected_item_count,
|
||||
"Проверка.coverage.expected",
|
||||
),
|
||||
reviewedItemCount: integerValue(
|
||||
coverage.reviewed_item_count,
|
||||
"Проверка.coverage.reviewed",
|
||||
),
|
||||
complete: trueValue(coverage.complete, "Проверка.coverage.complete"),
|
||||
},
|
||||
humanReviewComplete: trueValue(
|
||||
source.human_review_complete,
|
||||
"Проверка.human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "Проверка.lab_published"),
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30HumanReviewFinalized(
|
||||
value: unknown,
|
||||
): E30HumanReviewFinalized {
|
||||
const source = record(value, "Фиксация проверки");
|
||||
if (
|
||||
source.schema_version
|
||||
!== "missioncore.laboratory-e30-human-review-finalized/v2"
|
||||
) {
|
||||
throw new E30ReviewContractError("Схема фиксации не поддерживается.");
|
||||
}
|
||||
const draft = parseE30HumanReviewDraft(source.draft);
|
||||
const generation = parseGeneration(source.generation);
|
||||
if (
|
||||
draft.state !== "finalized"
|
||||
|| draft.generationId !== generation.generationId
|
||||
|| draft.engineeringGenerationId !== generation.engineeringGenerationId
|
||||
) {
|
||||
throw new E30ReviewContractError("Связь фиксации расходится.");
|
||||
}
|
||||
return { draft, generation };
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validId(value: string, prefix: string): boolean {
|
||||
return new RegExp(`^${prefix}-[a-f0-9]{64}$`).test(value);
|
||||
}
|
||||
|
||||
function generationQuery(engineeringGenerationId: string): string {
|
||||
return `?engineering_generation_id=${encodeURIComponent(
|
||||
engineeringGenerationId,
|
||||
)}`;
|
||||
}
|
||||
|
||||
export async function createOrResumeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
reviewerId = "DC",
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
if (
|
||||
!validId(resultId, "e30-materialization")
|
||||
|| !validId(engineeringGenerationId, "e30-engineering-generation")
|
||||
) {
|
||||
throw new E30ReviewContractError("Некорректная очередь проверки.");
|
||||
}
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reviewer_id: reviewerId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось открыть проверку."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveE30HumanReviewDecision(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
itemId: string,
|
||||
request: {
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
disposition: E30ExceptionDisposition;
|
||||
notes: string | null;
|
||||
},
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewDraft> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/decisions/${itemId}${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: request.expectedRevision,
|
||||
idempotency_key: request.idempotencyKey,
|
||||
disposition: request.disposition,
|
||||
notes: request.notes,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewDraft(
|
||||
await responseJson(response, "Не удалось сохранить решение."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function finalizeE30HumanReview(
|
||||
resultId: string,
|
||||
engineeringGenerationId: string,
|
||||
draftId: string,
|
||||
expectedRevision: number,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30HumanReviewFinalized> {
|
||||
const response = await (options.fetcher ?? fetch)(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/human-review/${draftId}`
|
||||
+ `/finalize${generationQuery(engineeringGenerationId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
expected_revision: expectedRevision,
|
||||
confirm_generation: true,
|
||||
}),
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30HumanReviewFinalized(
|
||||
await responseJson(response, "Не удалось зафиксировать проверку."),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
export const E30_STRATA = [
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
] as const;
|
||||
|
||||
export type E30Stratum = (typeof E30_STRATA)[number];
|
||||
|
||||
export interface E30ReviewResult {
|
||||
resultId: string;
|
||||
createdAtUtc: string | null;
|
||||
reviewPackId: string;
|
||||
e29ResultId: string;
|
||||
sourceSessionId: string;
|
||||
itemCount: number;
|
||||
stratumCounts: Record<E30Stratum, number>;
|
||||
reasonTaxonomy: readonly string[];
|
||||
projection: {
|
||||
width: number;
|
||||
height: number;
|
||||
sourceId: string;
|
||||
calibrationSlot: string;
|
||||
};
|
||||
cameraEvidenceAvailable: boolean;
|
||||
humanReviewComplete: false;
|
||||
labPublished: false;
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30ReviewCatalog {
|
||||
configured: boolean;
|
||||
candidateTotal: number;
|
||||
invalidTotal: number;
|
||||
items: readonly E30ReviewResult[];
|
||||
}
|
||||
|
||||
export interface E30ReviewSnapshot {
|
||||
label: string | null;
|
||||
geometryStatus: string;
|
||||
geometryReason: string | null;
|
||||
rangeM: number | null;
|
||||
nearestRangeM: number | null;
|
||||
bboxXyxy: readonly [number, number, number, number] | null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItem {
|
||||
itemId: string;
|
||||
sequence: number;
|
||||
reviewKey: string;
|
||||
stratum: E30Stratum;
|
||||
rangeBucket: string;
|
||||
frameIndex: number;
|
||||
sourceFrameIndex: number;
|
||||
sessionSeconds: number;
|
||||
locatorKind: "semantic-observation" | "geometry-only-cluster";
|
||||
snapshot: E30ReviewSnapshot;
|
||||
materialization: {
|
||||
framePointCount: number;
|
||||
projectedPointCount: number;
|
||||
candidatePointCount: number;
|
||||
selectedPointCount: number;
|
||||
rejectedCandidatePointCount: number;
|
||||
selectedAndCandidateLossless: true;
|
||||
freeSpaceValid: false;
|
||||
humanReviewComplete: false;
|
||||
detectorScore: number | null;
|
||||
};
|
||||
cameraFrameAvailable: boolean;
|
||||
engineeringTriage: E30EngineeringTriage;
|
||||
review: {
|
||||
state: "unreviewed";
|
||||
reasonCode: null;
|
||||
notes: null;
|
||||
};
|
||||
access: "read-only";
|
||||
}
|
||||
|
||||
export interface E30EngineeringTriage {
|
||||
provenance: "deterministic-evidence-readiness/v1";
|
||||
state: "ready-for-ai-review" | "blocked-camera-frame-unavailable";
|
||||
attention: "standard" | "elevated";
|
||||
signals: readonly string[];
|
||||
semanticVerdict: null;
|
||||
humanExceptionRequired: null;
|
||||
}
|
||||
|
||||
export interface E30ReviewItems {
|
||||
resultId: string;
|
||||
stratum: E30Stratum;
|
||||
items: readonly E30ReviewItem[];
|
||||
total: number;
|
||||
nextCursor: number | null;
|
||||
reasonTaxonomy: readonly string[];
|
||||
}
|
||||
|
||||
export interface E30ReviewItemDetail extends E30ReviewItem {
|
||||
cameraFrame: {
|
||||
available: true;
|
||||
url: string;
|
||||
sha256: string;
|
||||
width: number;
|
||||
height: number;
|
||||
sourceFrameIndex: number;
|
||||
exactSourceFrame: true;
|
||||
} | null;
|
||||
selected: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
candidate: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
};
|
||||
projection: {
|
||||
sourceIndices: readonly number[];
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[];
|
||||
pixelsXy: readonly (readonly [number, number])[];
|
||||
depthM: readonly number[];
|
||||
pointClass: readonly number[];
|
||||
pointHeightM: readonly number[];
|
||||
candidateMask: readonly number[];
|
||||
selectedMask: readonly number[];
|
||||
};
|
||||
pose: {
|
||||
positionMapXyzM: readonly [number, number, number];
|
||||
orientationMapFromLidarXyzw: readonly [number, number, number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export class E30ReviewContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "E30ReviewContractError";
|
||||
}
|
||||
}
|
||||
|
||||
type E30Fetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалась строка.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось конечное число.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
const parsed = numberValue(value, label);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new E30ReviewContractError(`${label}: ожидался boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function falseValue(value: unknown, label: string): false {
|
||||
if (value !== false) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось false.`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trueValue(value: unknown, label: string): true {
|
||||
if (value !== true) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось true.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function nullValue(value: unknown, label: string): null {
|
||||
if (value !== null) {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось null.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readOnly(value: unknown, label: string): "read-only" {
|
||||
if (value !== "read-only") {
|
||||
throw new E30ReviewContractError(`${label}: ожидалось read-only.`);
|
||||
}
|
||||
return "read-only";
|
||||
}
|
||||
|
||||
function stratumValue(value: unknown, label: string): E30Stratum {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| !E30_STRATA.includes(value as E30Stratum)
|
||||
) {
|
||||
throw new E30ReviewContractError(`${label}: неизвестная страта.`);
|
||||
}
|
||||
return value as E30Stratum;
|
||||
}
|
||||
|
||||
function nullableNumber(value: unknown, label: string): number | null {
|
||||
return value === null || value === undefined ? null : numberValue(value, label);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown, label: string): string | null {
|
||||
return value === null || value === undefined ? null : stringValue(value, label);
|
||||
}
|
||||
|
||||
function numberTuple<const N extends number>(
|
||||
value: unknown,
|
||||
length: N,
|
||||
label: string,
|
||||
): readonly number[] {
|
||||
if (!Array.isArray(value) || value.length !== length) {
|
||||
throw new E30ReviewContractError(`${label}: неверная размерность.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function numberArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => numberValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function integerArray(value: unknown, label: string): readonly number[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив.`);
|
||||
}
|
||||
return value.map((item, index) => integerValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
function points(
|
||||
value: unknown,
|
||||
dimensions: 2 | 3,
|
||||
label: string,
|
||||
): readonly (readonly number[])[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив точек.`);
|
||||
}
|
||||
return value.map((item, index) => (
|
||||
numberTuple(item, dimensions, `${label}[${index}]`)
|
||||
));
|
||||
}
|
||||
|
||||
function strings(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new E30ReviewContractError(`${label}: ожидался массив строк.`);
|
||||
}
|
||||
return value.map((item, index) => stringValue(item, `${label}[${index}]`));
|
||||
}
|
||||
|
||||
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 E30ReviewContractError(`${label}: некорректный content id.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResult(value: unknown): E30ReviewResult {
|
||||
const source = record(value, "E30 result");
|
||||
const counts = record(source.stratum_counts, "E30 stratum_counts");
|
||||
const projection = record(source.projection, "E30 projection");
|
||||
const authority = record(source.authority, "E30 authority");
|
||||
falseValue(authority.commands_enabled, "E30 authority.commands_enabled");
|
||||
falseValue(
|
||||
authority.navigation_or_safety_accepted,
|
||||
"E30 authority.navigation_or_safety_accepted",
|
||||
);
|
||||
return {
|
||||
resultId: contentId(
|
||||
source.result_id,
|
||||
"e30-materialization",
|
||||
"E30 result_id",
|
||||
),
|
||||
createdAtUtc: source.created_at_utc === null
|
||||
? null
|
||||
: stringValue(source.created_at_utc, "E30 created_at_utc"),
|
||||
reviewPackId: contentId(
|
||||
source.review_pack_id,
|
||||
"e30-review-pack",
|
||||
"E30 review_pack_id",
|
||||
),
|
||||
e29ResultId: contentId(
|
||||
source.e29_result_id,
|
||||
"e29-camera-geometry",
|
||||
"E30 e29_result_id",
|
||||
),
|
||||
sourceSessionId: stringValue(
|
||||
source.source_session_id,
|
||||
"E30 source_session_id",
|
||||
),
|
||||
itemCount: integerValue(source.item_count, "E30 item_count"),
|
||||
stratumCounts: Object.fromEntries(E30_STRATA.map((stratum) => [
|
||||
stratum,
|
||||
integerValue(counts[stratum], `E30 stratum_counts.${stratum}`),
|
||||
])) as Record<E30Stratum, number>,
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 reason_taxonomy"),
|
||||
projection: {
|
||||
width: integerValue(projection.width, "E30 projection.width"),
|
||||
height: integerValue(projection.height, "E30 projection.height"),
|
||||
sourceId: stringValue(projection.source_id, "E30 projection.source_id"),
|
||||
calibrationSlot: stringValue(
|
||||
projection.calibration_slot,
|
||||
"E30 projection.calibration_slot",
|
||||
),
|
||||
},
|
||||
cameraEvidenceAvailable: booleanValue(
|
||||
source.camera_evidence_available,
|
||||
"E30 camera_evidence_available",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
source.human_review_complete,
|
||||
"E30 human_review_complete",
|
||||
),
|
||||
labPublished: falseValue(source.lab_published, "E30 lab_published"),
|
||||
access: readOnly(source.access, "E30 access"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown): E30ReviewSnapshot {
|
||||
const source = record(value, "E30 snapshot");
|
||||
const rawBbox = source.bbox_xyxy;
|
||||
return {
|
||||
label: nullableString(source.label ?? source.semantic_class, "E30 snapshot.label"),
|
||||
geometryStatus: stringValue(
|
||||
source.geometry_status,
|
||||
"E30 snapshot.geometry_status",
|
||||
),
|
||||
geometryReason: nullableString(
|
||||
source.geometry_reason,
|
||||
"E30 snapshot.geometry_reason",
|
||||
),
|
||||
rangeM: nullableNumber(source.range_m, "E30 snapshot.range_m"),
|
||||
nearestRangeM: nullableNumber(
|
||||
source.nearest_range_m,
|
||||
"E30 snapshot.nearest_range_m",
|
||||
),
|
||||
bboxXyxy: rawBbox === null || rawBbox === undefined
|
||||
? null
|
||||
: numberTuple(rawBbox, 4, "E30 snapshot.bbox_xyxy") as
|
||||
readonly [number, number, number, number],
|
||||
};
|
||||
}
|
||||
|
||||
function parseItem(value: unknown): E30ReviewItem {
|
||||
const source = record(value, "E30 item");
|
||||
const locator = record(source.locator, "E30 item.locator");
|
||||
const materialized = record(
|
||||
source.materialization,
|
||||
"E30 item.materialization",
|
||||
);
|
||||
const review = record(source.review, "E30 item.review");
|
||||
const triage = record(
|
||||
source.engineering_triage,
|
||||
"E30 item.engineering_triage",
|
||||
);
|
||||
const locatorKind = stringValue(locator.kind, "E30 item.locator.kind");
|
||||
if (
|
||||
locatorKind !== "semantic-observation"
|
||||
&& locatorKind !== "geometry-only-cluster"
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 item.locator.kind не поддерживается.");
|
||||
}
|
||||
if (review.state !== "unreviewed") {
|
||||
throw new E30ReviewContractError("E30 item уже изменён вне review gate.");
|
||||
}
|
||||
if (
|
||||
triage.provenance !== "deterministic-evidence-readiness/v1"
|
||||
|| (
|
||||
triage.state !== "ready-for-ai-review"
|
||||
&& triage.state !== "blocked-camera-frame-unavailable"
|
||||
)
|
||||
|| (triage.attention !== "standard" && triage.attention !== "elevated")
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 engineering triage не поддерживается.");
|
||||
}
|
||||
return {
|
||||
itemId: contentId(
|
||||
source.item_id,
|
||||
"e30-review-item",
|
||||
"E30 item.item_id",
|
||||
),
|
||||
sequence: integerValue(source.sequence, "E30 item.sequence"),
|
||||
reviewKey: stringValue(source.review_key, "E30 item.review_key"),
|
||||
stratum: stratumValue(source.stratum, "E30 item.stratum"),
|
||||
rangeBucket: stringValue(source.range_bucket, "E30 item.range_bucket"),
|
||||
frameIndex: integerValue(source.frame_index, "E30 item.frame_index"),
|
||||
sourceFrameIndex: integerValue(
|
||||
source.source_frame_index,
|
||||
"E30 item.source_frame_index",
|
||||
),
|
||||
sessionSeconds: numberValue(
|
||||
source.session_seconds,
|
||||
"E30 item.session_seconds",
|
||||
),
|
||||
locatorKind,
|
||||
snapshot: parseSnapshot(source.snapshot),
|
||||
materialization: {
|
||||
framePointCount: integerValue(
|
||||
materialized.frame_point_count,
|
||||
"E30 materialization.frame_point_count",
|
||||
),
|
||||
projectedPointCount: integerValue(
|
||||
materialized.projected_point_count,
|
||||
"E30 materialization.projected_point_count",
|
||||
),
|
||||
candidatePointCount: integerValue(
|
||||
materialized.candidate_point_count,
|
||||
"E30 materialization.candidate_point_count",
|
||||
),
|
||||
selectedPointCount: integerValue(
|
||||
materialized.selected_point_count,
|
||||
"E30 materialization.selected_point_count",
|
||||
),
|
||||
rejectedCandidatePointCount: integerValue(
|
||||
materialized.rejected_candidate_point_count,
|
||||
"E30 materialization.rejected_candidate_point_count",
|
||||
),
|
||||
selectedAndCandidateLossless: trueValue(
|
||||
materialized.selected_and_candidate_lossless,
|
||||
"E30 materialization.selected_and_candidate_lossless",
|
||||
),
|
||||
freeSpaceValid: falseValue(
|
||||
materialized.free_space_valid,
|
||||
"E30 materialization.free_space_valid",
|
||||
),
|
||||
humanReviewComplete: falseValue(
|
||||
materialized.human_review_complete,
|
||||
"E30 materialization.human_review_complete",
|
||||
),
|
||||
detectorScore: nullableNumber(
|
||||
materialized.detector_score,
|
||||
"E30 materialization.detector_score",
|
||||
),
|
||||
},
|
||||
cameraFrameAvailable: booleanValue(
|
||||
source.camera_frame_available,
|
||||
"E30 item.camera_frame_available",
|
||||
),
|
||||
engineeringTriage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: triage.state,
|
||||
attention: triage.attention,
|
||||
signals: strings(triage.signals, "E30 engineering_triage.signals"),
|
||||
semanticVerdict: nullValue(
|
||||
triage.semantic_verdict,
|
||||
"E30 engineering_triage.semantic_verdict",
|
||||
),
|
||||
humanExceptionRequired: nullValue(
|
||||
triage.human_exception_required,
|
||||
"E30 engineering_triage.human_exception_required",
|
||||
),
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reasonCode: nullValue(review.reason_code, "E30 review.reason_code"),
|
||||
notes: nullValue(review.notes, "E30 review.notes"),
|
||||
},
|
||||
access: readOnly(source.access, "E30 item.access"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItem(value: unknown): E30ReviewItem {
|
||||
return parseItem(value);
|
||||
}
|
||||
|
||||
export function parseE30ReviewCatalog(value: unknown): E30ReviewCatalog {
|
||||
const source = record(value, "E30 catalog");
|
||||
if (source.schema_version !== "missioncore.laboratory-e30-catalog/v1") {
|
||||
throw new E30ReviewContractError("E30 catalog schema не поддерживается.");
|
||||
}
|
||||
if (!Array.isArray(source.items)) {
|
||||
throw new E30ReviewContractError("E30 catalog.items имеет неверный формат.");
|
||||
}
|
||||
readOnly(source.access, "E30 catalog.access");
|
||||
return {
|
||||
configured: booleanValue(source.configured, "E30 catalog.configured"),
|
||||
candidateTotal: integerValue(
|
||||
source.candidate_total,
|
||||
"E30 catalog.candidate_total",
|
||||
),
|
||||
invalidTotal: integerValue(source.invalid_total, "E30 catalog.invalid_total"),
|
||||
items: source.items.map(parseResult),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItems(value: unknown): E30ReviewItems {
|
||||
const source = record(value, "E30 items");
|
||||
if (
|
||||
source.schema_version !== "missioncore.laboratory-e30-items/v1"
|
||||
|| !Array.isArray(source.items)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 items schema не поддерживается.");
|
||||
}
|
||||
readOnly(source.access, "E30 items.access");
|
||||
return {
|
||||
resultId: stringValue(source.result_id, "E30 items.result_id"),
|
||||
stratum: stratumValue(source.stratum, "E30 items.stratum"),
|
||||
items: source.items.map(parseItem),
|
||||
total: integerValue(source.total, "E30 items.total"),
|
||||
nextCursor: source.next_cursor === null
|
||||
? null
|
||||
: integerValue(source.next_cursor, "E30 items.next_cursor"),
|
||||
reasonTaxonomy: strings(source.reason_taxonomy, "E30 items.reason_taxonomy"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseE30ReviewItemDetail(value: unknown): E30ReviewItemDetail {
|
||||
const response = record(value, "E30 item detail response");
|
||||
if (response.schema_version !== "missioncore.laboratory-e30-item-detail/v1") {
|
||||
throw new E30ReviewContractError("E30 detail schema не поддерживается.");
|
||||
}
|
||||
readOnly(response.access, "E30 detail access");
|
||||
const source = record(response.item, "E30 item detail");
|
||||
const item = parseItem(source);
|
||||
const selected = record(source.selected, "E30 selected");
|
||||
const candidate = record(source.candidate, "E30 candidate");
|
||||
const projection = record(source.projection, "E30 projection detail");
|
||||
const pose = record(source.pose, "E30 pose");
|
||||
const cameraFrame = source.camera_frame === null
|
||||
? null
|
||||
: record(source.camera_frame, "E30 camera_frame");
|
||||
const projectedLength = integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
).length;
|
||||
const projectionCollections = [
|
||||
projection.points_map_xyz_m,
|
||||
projection.pixels_xy,
|
||||
projection.depth_m,
|
||||
projection.point_class,
|
||||
projection.point_height_m,
|
||||
projection.candidate_mask,
|
||||
projection.selected_mask,
|
||||
];
|
||||
if (
|
||||
projectionCollections.some(
|
||||
(collection) => !Array.isArray(collection) || collection.length !== projectedLength,
|
||||
)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection arrays имеют разную длину.");
|
||||
}
|
||||
const candidateMask = integerArray(
|
||||
projection.candidate_mask,
|
||||
"E30 projection.candidate_mask",
|
||||
);
|
||||
const selectedMask = integerArray(
|
||||
projection.selected_mask,
|
||||
"E30 projection.selected_mask",
|
||||
);
|
||||
if (
|
||||
candidateMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
|| selectedMask.some((itemValue) => itemValue !== 0 && itemValue !== 1)
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 projection mask должна быть бинарной.");
|
||||
}
|
||||
const selectedSourceIndices = integerArray(
|
||||
selected.source_indices,
|
||||
"E30 selected.source_indices",
|
||||
);
|
||||
const selectedPoints = points(
|
||||
selected.points_map_xyz_m,
|
||||
3,
|
||||
"E30 selected.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
const candidateSourceIndices = integerArray(
|
||||
candidate.source_indices,
|
||||
"E30 candidate.source_indices",
|
||||
);
|
||||
const candidatePoints = points(
|
||||
candidate.points_map_xyz_m,
|
||||
3,
|
||||
"E30 candidate.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[];
|
||||
if (
|
||||
selectedSourceIndices.length !== selectedPoints.length
|
||||
|| candidateSourceIndices.length !== candidatePoints.length
|
||||
|| selectedSourceIndices.length !== item.materialization.selectedPointCount
|
||||
|| candidateSourceIndices.length !== item.materialization.candidatePointCount
|
||||
) {
|
||||
throw new E30ReviewContractError("E30 selected/candidate arrays расходятся.");
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
cameraFrame: cameraFrame === null
|
||||
? null
|
||||
: {
|
||||
available: trueValue(
|
||||
cameraFrame.available,
|
||||
"E30 camera_frame.available",
|
||||
),
|
||||
url: stringValue(cameraFrame.url, "E30 camera_frame.url"),
|
||||
sha256: stringValue(cameraFrame.sha256, "E30 camera_frame.sha256"),
|
||||
width: integerValue(cameraFrame.width, "E30 camera_frame.width"),
|
||||
height: integerValue(cameraFrame.height, "E30 camera_frame.height"),
|
||||
sourceFrameIndex: integerValue(
|
||||
cameraFrame.source_frame_index,
|
||||
"E30 camera_frame.source_frame_index",
|
||||
),
|
||||
exactSourceFrame: trueValue(
|
||||
cameraFrame.exact_source_frame,
|
||||
"E30 camera_frame.exact_source_frame",
|
||||
),
|
||||
},
|
||||
selected: {
|
||||
sourceIndices: selectedSourceIndices,
|
||||
pointsMapXyzM: selectedPoints,
|
||||
},
|
||||
candidate: {
|
||||
sourceIndices: candidateSourceIndices,
|
||||
pointsMapXyzM: candidatePoints,
|
||||
},
|
||||
projection: {
|
||||
sourceIndices: integerArray(
|
||||
projection.source_indices,
|
||||
"E30 projection.source_indices",
|
||||
),
|
||||
pointsMapXyzM: points(
|
||||
projection.points_map_xyz_m,
|
||||
3,
|
||||
"E30 projection.points_map_xyz_m",
|
||||
) as readonly (readonly [number, number, number])[],
|
||||
pixelsXy: points(
|
||||
projection.pixels_xy,
|
||||
2,
|
||||
"E30 projection.pixels_xy",
|
||||
) as readonly (readonly [number, number])[],
|
||||
depthM: numberArray(projection.depth_m, "E30 projection.depth_m"),
|
||||
pointClass: integerArray(
|
||||
projection.point_class,
|
||||
"E30 projection.point_class",
|
||||
),
|
||||
pointHeightM: numberArray(
|
||||
projection.point_height_m,
|
||||
"E30 projection.point_height_m",
|
||||
),
|
||||
candidateMask,
|
||||
selectedMask,
|
||||
},
|
||||
pose: {
|
||||
positionMapXyzM: numberTuple(
|
||||
pose.position_map_xyz_m,
|
||||
3,
|
||||
"E30 pose.position_map_xyz_m",
|
||||
) as readonly [number, number, number],
|
||||
orientationMapFromLidarXyzw: numberTuple(
|
||||
pose.orientation_map_from_lidar_xyzw,
|
||||
4,
|
||||
"E30 pose.orientation_map_from_lidar_xyzw",
|
||||
) as readonly [number, number, number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, fallback: string): Promise<unknown> {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
// Preserve the status-aware fallback below.
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail?: unknown }).detail)
|
||||
: fallback;
|
||||
throw new E30ReviewContractError(detail);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validResultId(value: string): boolean {
|
||||
return /^e30-materialization-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function validItemId(value: string): boolean {
|
||||
return /^e30-review-item-[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewCatalog(
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewCatalog> {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher("/api/v1/laboratory/e30/reviews?limit=1", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
});
|
||||
return parseE30ReviewCatalog(
|
||||
await responseJson(response, "Не удалось получить LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItems(
|
||||
resultId: string,
|
||||
stratum: E30Stratum,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItems> {
|
||||
if (!validResultId(resultId) || !E30_STRATA.includes(stratum)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review query.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=${stratum}&limit=128&cursor=0`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItems(
|
||||
await responseJson(response, "Не удалось получить выборку LAB E30."),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchE30ReviewItemDetail(
|
||||
resultId: string,
|
||||
itemId: string,
|
||||
options: { signal?: AbortSignal; fetcher?: E30Fetch } = {},
|
||||
): Promise<E30ReviewItemDetail> {
|
||||
if (!validResultId(resultId) || !validItemId(itemId)) {
|
||||
throw new E30ReviewContractError("Некорректный E30 review item id.");
|
||||
}
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const response = await fetcher(
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
return parseE30ReviewItemDetail(
|
||||
await responseJson(response, "Не удалось получить доказательство LAB E30."),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/shell.css";
|
||||
@import "./styles/workspaces.css";
|
||||
@import "./styles/laboratory.css";
|
||||
@import "./styles/e30-human-review.css";
|
||||
@import "./styles/spatial.css";
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
.e30-human-review {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-human-review__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-human-review h3,
|
||||
.e30-human-review p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-human-review h3 {
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.e30-human-review p {
|
||||
max-width: 58rem;
|
||||
margin-top: 0.28rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.e30-human-review__field > span,
|
||||
.e30-human-review__actions > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-human-review__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 0.55fr) minmax(18rem, 1fr);
|
||||
align-items: end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.e30-human-review__field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.e30-human-review__field .nodedc-select-anchor,
|
||||
.e30-human-review__field .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.e30-human-review__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-human-review__impact strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.e30-human-review__actions > span {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.e30-human-review__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.e30-human-review__form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.e30-engineering-generation {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl,
|
||||
.e30-engineering-generation dt,
|
||||
.e30-engineering-generation dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dl > div {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
padding: 0.5rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-engineering-generation dd {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.e30-engineering-generation dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
.lab-archive-workspace,
|
||||
.lab-result-surface,
|
||||
.laboratory-work-template {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.recordings-workspace {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector h2,
|
||||
.laboratory-selector p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-selector h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector p {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-summary,
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-summary > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-summary p,
|
||||
.laboratory-summary dl,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary h2,
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-summary > header p,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts > div,
|
||||
.laboratory-result-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dt,
|
||||
.laboratory-summary__facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dt,
|
||||
.laboratory-result-metrics span,
|
||||
.laboratory-result-metrics small,
|
||||
.laboratory-summary__method small,
|
||||
.laboratory-summary__components dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__facts dd,
|
||||
.laboratory-result-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 660;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__method {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 0.1rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__method > header strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.66rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__components {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components > div {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.58rem 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components dt {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-summary__components dd {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.laboratory-summary__components strong,
|
||||
.laboratory-summary__components small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-summary__components strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.lab-result-surface[data-resizable="true"] .laboratory-evidence-frame {
|
||||
height: clamp(42rem, 68vh, 58rem);
|
||||
min-height: 42rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .spatial-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .lidar-quality-workspace {
|
||||
min-height: 42rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-selector,
|
||||
.lab-archive-workspace[data-viewer-focused="true"]
|
||||
.laboratory-work-template > :not(.lab-result-surface),
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface > header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-work-output,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-work-template,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.laboratory-result-pending strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review h2 {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button:hover,
|
||||
.laboratory-frame-review__picker button.is-active {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker small,
|
||||
.laboratory-frame-review__detail small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__state {
|
||||
display: flex;
|
||||
min-height: 6rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__conflicts p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__stage > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer__controls {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-expanded="true"] {
|
||||
position: fixed;
|
||||
z-index: var(--nodedc-layer-overlay);
|
||||
inset: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-viewer[data-expanded="true"] .e30-evidence-scene,
|
||||
.laboratory-evidence-viewer[data-expanded="true"] .e30-projection-scene {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace__header,
|
||||
.e30-review-workspace__case {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace h2,
|
||||
.e30-review-workspace h3,
|
||||
.e30-review-workspace p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace h2,
|
||||
.e30-review-workspace h3 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace p {
|
||||
max-width: 62rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata .nodedc-segmented__item {
|
||||
min-width: max-content;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__body {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(10.5rem, 13vw, 12rem) minmax(0, 1fr);
|
||||
gap: 0.6rem;
|
||||
height: clamp(40rem, 68vh, 54rem);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items,
|
||||
.e30-review-workspace__detail {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
}
|
||||
|
||||
.e30-review-workspace__items {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.3rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.2rem 0.5rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items > header small,
|
||||
.e30-review-workspace__item small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.53rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.08rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button {
|
||||
display: grid;
|
||||
min-height: auto;
|
||||
justify-content: flex-start;
|
||||
gap: 0.18rem;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-panel-item-bg);
|
||||
padding: 0.62rem 0.7rem;
|
||||
box-shadow: none;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button:hover {
|
||||
background: var(--nodedc-panel-item-hover-bg);
|
||||
}
|
||||
|
||||
.e30-review-workspace__item.nodedc-button[data-active="true"] {
|
||||
background: var(--nodedc-panel-item-active-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.e30-review-workspace__item span,
|
||||
.e30-review-workspace__item strong,
|
||||
.e30-review-workspace__item small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 0.61rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-review-workspace__item strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.e30-review-workspace__detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__case {
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__case h3 {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 10rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.e30-review-evidence {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.e30-evidence-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e30-projection-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--nodedc-radius-option);
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.e30-projection-scene canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.e30-projection-scene__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__viewport canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__toolbar {
|
||||
position: absolute;
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__toolbar .nodedc-button {
|
||||
background: var(--nodedc-floating-surface);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures,
|
||||
.e30-evidence-scene__legend {
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
color: var(--nodedc-text-secondary);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
padding: 0.43rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__gestures span,
|
||||
.e30-evidence-scene__legend span {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend {
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.55rem;
|
||||
padding: 0.42rem 0.55rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span::before {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-text-muted);
|
||||
content: "";
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="rejected"]::before {
|
||||
background: rgb(var(--nodedc-warning-rgb));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="selected"]::before {
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.e30-evidence-scene__legend span[data-point="depth"]::before {
|
||||
background: linear-gradient(90deg, #4f72ee, #b95ee8, #ee654f);
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 0.6rem;
|
||||
bottom: 0.6rem;
|
||||
display: grid;
|
||||
width: min(11.5rem, calc(100% - 1.2rem));
|
||||
gap: 0.4rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.5rem 0.6rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
backdrop-filter: blur(var(--nodedc-blur-control));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry > span,
|
||||
.e30-evidence-telemetry dt,
|
||||
.e30-evidence-telemetry > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.48rem;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dl {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dl > div,
|
||||
.e30-evidence-telemetry > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dt,
|
||||
.e30-evidence-telemetry dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry dd {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.56rem;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-evidence-telemetry code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.48rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.e30-evidence-scene__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-summary__facts,
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__strata {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.e30-review-workspace__body {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.e30-review-workspace__items {
|
||||
height: 18rem;
|
||||
}
|
||||
|
||||
.e30-review-workspace__detail {
|
||||
min-height: 40rem;
|
||||
}
|
||||
|
||||
.e30-review-evidence {
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2384,455 +2384,14 @@
|
||||
}
|
||||
|
||||
.recordings-workspace,
|
||||
.lab-archive-workspace,
|
||||
.lab-result-surface,
|
||||
.laboratory-work-template {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.recordings-workspace {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.lab-result-surface > header strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.38fr);
|
||||
align-items: center;
|
||||
gap: 1.4rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector h2,
|
||||
.laboratory-selector p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-selector h2 {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-selector p {
|
||||
max-width: 60rem;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.laboratory-selector__control {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.laboratory-selector__control > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-selector__control .nodedc-select-anchor,
|
||||
.laboratory-selector__control .nodedc-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.laboratory-work-output {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-task,
|
||||
.laboratory-method,
|
||||
.laboratory-result-summary {
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-task > header,
|
||||
.laboratory-method > header,
|
||||
.laboratory-result-summary > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-task p,
|
||||
.laboratory-task dl,
|
||||
.laboratory-method h2,
|
||||
.laboratory-method p,
|
||||
.laboratory-method ul,
|
||||
.laboratory-result-summary h2,
|
||||
.laboratory-result-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task h2,
|
||||
.laboratory-method h2,
|
||||
.laboratory-result-summary h2 {
|
||||
margin-top: 0.3rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.laboratory-task p,
|
||||
.laboratory-method p,
|
||||
.laboratory-result-summary > p {
|
||||
max-width: 66rem;
|
||||
margin-top: 0.38rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.laboratory-method__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary span,
|
||||
.laboratory-method li > span,
|
||||
.laboratory-method small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-method__summary strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method ul {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.55rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.laboratory-method li {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 0.62rem 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-method li > span {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.laboratory-method li > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.laboratory-method li strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.66rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-method code {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-task dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-task dl > div,
|
||||
.laboratory-result-metrics > div {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-task dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.laboratory-task dt,
|
||||
.laboratory-result-metrics span,
|
||||
.laboratory-result-metrics small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-task dd,
|
||||
.laboratory-result-metrics strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 660;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.lab-result-surface[data-resizable="true"] .laboratory-evidence-frame {
|
||||
height: clamp(42rem, 68vh, 58rem);
|
||||
min-height: 42rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .spatial-workspace {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.laboratory-evidence-frame > .lidar-quality-workspace {
|
||||
min-height: 42rem;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-selector,
|
||||
.lab-archive-workspace[data-viewer-focused="true"]
|
||||
.laboratory-work-template > :not(.lab-result-surface),
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface > header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] > .laboratory-work-output,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-work-template,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .lab-result-surface,
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lab-archive-workspace[data-viewer-focused="true"] .laboratory-evidence-frame {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.laboratory-result-pending {
|
||||
display: grid;
|
||||
min-height: 10rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.laboratory-result-pending strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.laboratory-result-pending p {
|
||||
max-width: 34rem;
|
||||
margin: 0;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.laboratory-result-summary {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.laboratory-result-metrics strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review h2 {
|
||||
margin: 0.3rem 0 0;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--nodedc-text-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker button:hover,
|
||||
.laboratory-frame-review__picker button.is-active {
|
||||
background: rgb(255 255 255 / 0.09);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 660;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__picker small,
|
||||
.laboratory-frame-review__detail small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__state {
|
||||
display: flex;
|
||||
min-height: 6rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail > div > strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__conflicts p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding-top: 0.45rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.laboratory-selector {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.laboratory-task dl,
|
||||
.laboratory-result-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.laboratory-frame-review__detail .laboratory-frame-review__conflicts {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-entry {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 1.2fr) minmax(22rem, 0.8fr);
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon } from "@nodedc/ui-react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
interface E30EvidencePointCloudProps {
|
||||
detail: E30ReviewItemDetail;
|
||||
}
|
||||
|
||||
function tokenColor(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
): THREE.Color {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
if (value.startsWith("#")) {
|
||||
return new THREE.Color(value);
|
||||
}
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3
|
||||
? channels
|
||||
: fallback;
|
||||
return new THREE.Color(red / 255, green / 255, blue / 255);
|
||||
}
|
||||
|
||||
function createPointTexture(): THREE.CanvasTexture {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (context) {
|
||||
const gradient = context.createRadialGradient(32, 32, 2, 32, 32, 30);
|
||||
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
|
||||
gradient.addColorStop(0.72, "rgba(255, 255, 255, 0.94)");
|
||||
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, 64, 64);
|
||||
}
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function toMapScenePositions(
|
||||
pointsMapXyzM: readonly (readonly [number, number, number])[],
|
||||
positionMapXyzM: readonly [number, number, number],
|
||||
): Float32Array {
|
||||
const positions = new Float32Array(pointsMapXyzM.length * 3);
|
||||
pointsMapXyzM.forEach(([mapX, mapY, mapZ], index) => {
|
||||
const offset = index * 3;
|
||||
positions[offset] = mapX - positionMapXyzM[0];
|
||||
positions[offset + 1] = mapZ - positionMapXyzM[2];
|
||||
positions[offset + 2] = -(mapY - positionMapXyzM[1]);
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
function boundsFromPositions(positions: Float32Array): THREE.Box3 {
|
||||
const bounds = new THREE.Box3();
|
||||
const point = new THREE.Vector3();
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
point.set(positions[offset], positions[offset + 1], positions[offset + 2]);
|
||||
bounds.expandByPoint(point);
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function evidenceViewFromPositions(positions: Float32Array): {
|
||||
target: THREE.Vector3;
|
||||
radius: number;
|
||||
} {
|
||||
if (!positions.length) {
|
||||
return { target: new THREE.Vector3(), radius: 0.65 };
|
||||
}
|
||||
const xValues: number[] = [];
|
||||
const yValues: number[] = [];
|
||||
const zValues: number[] = [];
|
||||
for (let offset = 0; offset < positions.length; offset += 3) {
|
||||
xValues.push(positions[offset]);
|
||||
yValues.push(positions[offset + 1]);
|
||||
zValues.push(positions[offset + 2]);
|
||||
}
|
||||
const target = new THREE.Vector3(
|
||||
percentile(xValues, 0.5),
|
||||
percentile(yValues, 0.5),
|
||||
percentile(zValues, 0.5),
|
||||
);
|
||||
const radii = xValues.map((x, index) => Math.hypot(
|
||||
x - target.x,
|
||||
yValues[index] - target.y,
|
||||
zValues[index] - target.z,
|
||||
));
|
||||
return {
|
||||
target,
|
||||
radius: Math.max(percentile(radii, 0.9), 0.65),
|
||||
};
|
||||
}
|
||||
|
||||
function percentile(values: readonly number[], fraction: number): number {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(
|
||||
sorted.length - 1,
|
||||
Math.max(0, Math.floor((sorted.length - 1) * fraction)),
|
||||
);
|
||||
return sorted[index];
|
||||
}
|
||||
|
||||
export function E30EvidencePointCloud({ detail }: E30EvidencePointCloudProps) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const contextGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const rejectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const selectedGeometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||
const contextMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const rejectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const selectedMaterialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const gridRef = useRef<THREE.GridHelper | null>(null);
|
||||
const viewTargetRef = useRef(new THREE.Vector3());
|
||||
const viewDistanceRef = useRef(4);
|
||||
const [renderError, setRenderError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
} catch {
|
||||
setRenderError("Браузер не смог создать WebGL-сцену доказательства E30.");
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.setClearColor(
|
||||
tokenColor(host, "--nodedc-canvas", [5, 5, 6]),
|
||||
1,
|
||||
);
|
||||
renderer.domElement.setAttribute(
|
||||
"aria-label",
|
||||
"Интерактивное 3D-доказательство E30",
|
||||
);
|
||||
renderer.domElement.setAttribute("role", "img");
|
||||
host.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(48, 1, 0.01, 500);
|
||||
camera.position.set(4, 2.5, 4);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
controls.enablePan = true;
|
||||
controls.enableZoom = true;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 0.2;
|
||||
controls.maxDistance = 300;
|
||||
controls.minPolarAngle = 0.04;
|
||||
controls.maxPolarAngle = Math.PI - 0.04;
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
controlsRef.current = controls;
|
||||
|
||||
const pointTexture = createPointTexture();
|
||||
const contextGeometry = new THREE.BufferGeometry();
|
||||
const contextMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-muted", [147, 151, 159]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 2.2,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.34,
|
||||
depthWrite: false,
|
||||
});
|
||||
const contextPoints = new THREE.Points(contextGeometry, contextMaterial);
|
||||
contextPoints.renderOrder = 0;
|
||||
scene.add(contextPoints);
|
||||
contextGeometryRef.current = contextGeometry;
|
||||
contextMaterialRef.current = contextMaterial;
|
||||
|
||||
const rejectedGeometry = new THREE.BufferGeometry();
|
||||
const rejectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 5.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 0.98,
|
||||
depthWrite: true,
|
||||
});
|
||||
const rejectedPoints = new THREE.Points(rejectedGeometry, rejectedMaterial);
|
||||
rejectedPoints.renderOrder = 1;
|
||||
scene.add(rejectedPoints);
|
||||
rejectedGeometryRef.current = rejectedGeometry;
|
||||
rejectedMaterialRef.current = rejectedMaterial;
|
||||
|
||||
const selectedGeometry = new THREE.BufferGeometry();
|
||||
const selectedMaterial = new THREE.PointsMaterial({
|
||||
color: tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
map: pointTexture,
|
||||
alphaTest: 0.04,
|
||||
size: 10.5,
|
||||
sizeAttenuation: false,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
});
|
||||
const selectedPoints = new THREE.Points(selectedGeometry, selectedMaterial);
|
||||
selectedPoints.renderOrder = 3;
|
||||
scene.add(selectedPoints);
|
||||
selectedGeometryRef.current = selectedGeometry;
|
||||
selectedMaterialRef.current = selectedMaterial;
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
10,
|
||||
20,
|
||||
tokenColor(host, "--nodedc-text-muted", [96, 99, 106]),
|
||||
tokenColor(host, "--nodedc-glass-outline", [48, 50, 56]),
|
||||
);
|
||||
const gridMaterials = Array.isArray(grid.material)
|
||||
? grid.material
|
||||
: [grid.material];
|
||||
gridMaterials.forEach((material) => {
|
||||
material.transparent = true;
|
||||
material.opacity = 0.16;
|
||||
material.depthWrite = false;
|
||||
});
|
||||
gridRef.current = grid;
|
||||
scene.add(grid);
|
||||
|
||||
const sensorMarkerGeometry = new THREE.RingGeometry(0.08, 0.12, 32);
|
||||
const sensorMarkerMaterial = new THREE.MeshBasicMaterial({
|
||||
color: tokenColor(host, "--nodedc-text-secondary", [185, 187, 192]),
|
||||
transparent: true,
|
||||
opacity: 0.64,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sensorMarker = new THREE.Mesh(
|
||||
sensorMarkerGeometry,
|
||||
sensorMarkerMaterial,
|
||||
);
|
||||
sensorMarker.rotation.x = -Math.PI / 2;
|
||||
sensorMarker.renderOrder = 3;
|
||||
scene.add(sensorMarker);
|
||||
|
||||
const resize = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height, false);
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(host);
|
||||
resize();
|
||||
|
||||
let animationFrame = 0;
|
||||
const render = () => {
|
||||
animationFrame = window.requestAnimationFrame(render);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
render();
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(animationFrame);
|
||||
resizeObserver.disconnect();
|
||||
controls.dispose();
|
||||
contextGeometry.dispose();
|
||||
contextMaterial.dispose();
|
||||
rejectedGeometry.dispose();
|
||||
rejectedMaterial.dispose();
|
||||
selectedGeometry.dispose();
|
||||
selectedMaterial.dispose();
|
||||
grid.geometry.dispose();
|
||||
gridMaterials.forEach((material) => material.dispose());
|
||||
sensorMarkerGeometry.dispose();
|
||||
sensorMarkerMaterial.dispose();
|
||||
pointTexture.dispose();
|
||||
renderer.dispose();
|
||||
renderer.domElement.remove();
|
||||
contextGeometryRef.current = null;
|
||||
rejectedGeometryRef.current = null;
|
||||
selectedGeometryRef.current = null;
|
||||
contextMaterialRef.current = null;
|
||||
rejectedMaterialRef.current = null;
|
||||
selectedMaterialRef.current = null;
|
||||
cameraRef.current = null;
|
||||
controlsRef.current = null;
|
||||
gridRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const contextGeometry = contextGeometryRef.current;
|
||||
const rejectedGeometry = rejectedGeometryRef.current;
|
||||
const selectedGeometry = selectedGeometryRef.current;
|
||||
const contextMaterial = contextMaterialRef.current;
|
||||
const rejectedMaterial = rejectedMaterialRef.current;
|
||||
const selectedMaterial = selectedMaterialRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
const grid = gridRef.current;
|
||||
if (
|
||||
!host
|
||||
|| !contextGeometry
|
||||
|| !rejectedGeometry
|
||||
|| !selectedGeometry
|
||||
|| !contextMaterial
|
||||
|| !rejectedMaterial
|
||||
|| !selectedMaterial
|
||||
|| !camera
|
||||
|| !controls
|
||||
|| !grid
|
||||
) return;
|
||||
|
||||
const contextPositions = toMapScenePositions(
|
||||
detail.projection.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedPositions = toMapScenePositions(
|
||||
detail.selected.pointsMapXyzM,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedPointsMap = detail.candidate.pointsMapXyzM.filter(
|
||||
(_point, index) => !selectedIndices.has(
|
||||
detail.candidate.sourceIndices[index] ?? -1,
|
||||
),
|
||||
);
|
||||
const rejectedPositions = toMapScenePositions(
|
||||
rejectedPointsMap,
|
||||
detail.pose.positionMapXyzM,
|
||||
);
|
||||
|
||||
contextGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(contextPositions, 3),
|
||||
);
|
||||
rejectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(rejectedPositions, 3),
|
||||
);
|
||||
selectedGeometry.setAttribute(
|
||||
"position",
|
||||
new THREE.BufferAttribute(selectedPositions, 3),
|
||||
);
|
||||
contextGeometry.computeBoundingSphere();
|
||||
rejectedGeometry.computeBoundingSphere();
|
||||
selectedGeometry.computeBoundingSphere();
|
||||
|
||||
rejectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-warning-rgb", [255, 209, 102]),
|
||||
);
|
||||
selectedMaterial.color.copy(
|
||||
tokenColor(host, "--nodedc-accent-rgb", [247, 248, 244]),
|
||||
);
|
||||
|
||||
const evidencePositions = selectedPositions.length || rejectedPositions.length
|
||||
? new Float32Array([...rejectedPositions, ...selectedPositions])
|
||||
: contextPositions;
|
||||
const evidenceBounds = boundsFromPositions(evidencePositions);
|
||||
const contextBounds = boundsFromPositions(contextPositions);
|
||||
const evidenceView = evidenceViewFromPositions(evidencePositions);
|
||||
const target = evidenceView.target;
|
||||
const evidenceRadius = evidenceView.radius;
|
||||
const evidenceSize = evidenceBounds.isEmpty()
|
||||
? new THREE.Vector3(1, 1, 1)
|
||||
: evidenceBounds.getSize(new THREE.Vector3());
|
||||
const contextSize = contextBounds.isEmpty()
|
||||
? evidenceSize
|
||||
: contextBounds.getSize(new THREE.Vector3());
|
||||
const contextRadius = Math.max(contextSize.length() / 2, evidenceRadius);
|
||||
const distance = Math.max(evidenceRadius * 2.45, 2.3);
|
||||
|
||||
viewTargetRef.current.copy(target);
|
||||
viewDistanceRef.current = distance;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
camera.near = Math.max(distance / 2_000, 0.005);
|
||||
camera.far = Math.max(contextRadius * 12, distance * 40, 120);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.maxDistance = Math.max(contextRadius * 5, distance * 5, 40);
|
||||
controls.update();
|
||||
|
||||
const contextHeights: number[] = [];
|
||||
for (let offset = 1; offset < contextPositions.length; offset += 3) {
|
||||
contextHeights.push(contextPositions[offset]);
|
||||
}
|
||||
const groundHeight = percentile(contextHeights, 0.04);
|
||||
const gridSize = THREE.MathUtils.clamp(evidenceRadius * 7, 8, 48);
|
||||
grid.position.set(target.x, groundHeight, target.z);
|
||||
grid.scale.setScalar(gridSize / 10);
|
||||
}, [detail]);
|
||||
|
||||
const resetCamera = () => {
|
||||
const camera = cameraRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!camera || !controls) return;
|
||||
const target = viewTargetRef.current;
|
||||
const distance = viewDistanceRef.current;
|
||||
controls.target.copy(target);
|
||||
camera.position.set(
|
||||
target.x + distance * 0.86,
|
||||
target.y + distance * 0.52,
|
||||
target.z + distance * 0.86,
|
||||
);
|
||||
controls.update();
|
||||
};
|
||||
|
||||
const selectedIndices = new Set(detail.selected.sourceIndices);
|
||||
const rejectedCount = detail.candidate.sourceIndices.filter(
|
||||
(sourceIndex) => !selectedIndices.has(sourceIndex),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="e30-evidence-scene" data-testid="e30-evidence-3d">
|
||||
<div ref={hostRef} className="e30-evidence-scene__viewport">
|
||||
{renderError ? (
|
||||
<p className="e30-evidence-scene__error">{renderError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="e30-evidence-scene__toolbar">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
onClick={resetCamera}
|
||||
>
|
||||
Сбросить ракурс
|
||||
</Button>
|
||||
<div className="e30-evidence-scene__gestures" aria-label="Управление 3D-сценой">
|
||||
<span>ЛКМ · вращение</span>
|
||||
<span>Колесо · масштаб</span>
|
||||
<span>ПКМ · панорама</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="e30-evidence-scene__legend" aria-label="Легенда 3D-доказательства">
|
||||
<span data-point="context">
|
||||
Контекст · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Отклонено · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { E30ReviewItemDetail } from "../core/laboratory/e30Review";
|
||||
|
||||
function tokenColor(host: HTMLElement, token: string, fallback: string): string {
|
||||
return getComputedStyle(host).getPropertyValue(token).trim() || fallback;
|
||||
}
|
||||
|
||||
function tokenRgb(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const value = getComputedStyle(host).getPropertyValue(token).trim();
|
||||
const channels = value.match(/[\d.]+/g)?.slice(0, 3).map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`;
|
||||
}
|
||||
|
||||
function depthColor(
|
||||
depth: number,
|
||||
minimumDepth: number,
|
||||
maximumDepth: number,
|
||||
): string {
|
||||
const span = Math.max(maximumDepth - minimumDepth, 0.001);
|
||||
const position = Math.min(1, Math.max(0, (depth - minimumDepth) / span));
|
||||
const hue = 220 - position * 205;
|
||||
return `hsl(${hue} 88% 62% / 0.78)`;
|
||||
}
|
||||
|
||||
export function E30EvidenceProjection({
|
||||
detail,
|
||||
projectionWidth,
|
||||
projectionHeight,
|
||||
pointLayerVisible,
|
||||
}: {
|
||||
detail: E30ReviewItemDetail;
|
||||
projectionWidth: number;
|
||||
projectionHeight: number;
|
||||
pointLayerVisible: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [frameState, setFrameState] = useState<
|
||||
"loading" | "ready" | "unavailable"
|
||||
>(detail.cameraFrame ? "loading" : "unavailable");
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
const host = canvas?.parentElement;
|
||||
if (!canvas || !context || !host) return;
|
||||
|
||||
let cancelled = false;
|
||||
const canvasWidth = 1_200;
|
||||
const canvasHeight = Math.round(
|
||||
canvasWidth * projectionHeight / projectionWidth,
|
||||
);
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
const scaleX = canvasWidth / projectionWidth;
|
||||
const scaleY = canvasHeight / projectionHeight;
|
||||
const depths = detail.projection.depthM.filter(Number.isFinite);
|
||||
const minimumDepth = depths.length ? Math.min(...depths) : 0;
|
||||
const maximumDepth = depths.length ? Math.max(...depths) : 1;
|
||||
|
||||
const draw = (image: HTMLImageElement | null) => {
|
||||
if (cancelled) return;
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (image) {
|
||||
context.drawImage(image, 0, 0, canvasWidth, canvasHeight);
|
||||
if (pointLayerVisible) {
|
||||
context.fillStyle = "rgb(0 0 0 / 0.08)";
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
} else {
|
||||
context.fillStyle = tokenColor(host, "--nodedc-canvas", "#050506");
|
||||
context.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
if (pointLayerVisible) {
|
||||
detail.projection.pixelsXy.forEach(([sourceX, sourceY], index) => {
|
||||
if (
|
||||
sourceX < 0
|
||||
|| sourceX > projectionWidth
|
||||
|| sourceY < 0
|
||||
|| sourceY > projectionHeight
|
||||
) return;
|
||||
const selected = detail.projection.selectedMask[index] === 1;
|
||||
const candidate = detail.projection.candidateMask[index] === 1;
|
||||
const x = sourceX * scaleX;
|
||||
const y = sourceY * scaleY;
|
||||
context.fillStyle = selected
|
||||
? tokenRgb(host, "--nodedc-accent-rgb", [247, 248, 244])
|
||||
: candidate
|
||||
? tokenRgb(host, "--nodedc-warning-rgb", [255, 209, 102])
|
||||
: depthColor(
|
||||
detail.projection.depthM[index] ?? minimumDepth,
|
||||
minimumDepth,
|
||||
maximumDepth,
|
||||
);
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
x,
|
||||
y,
|
||||
selected ? 6.5 : candidate ? 4.25 : 2.1,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
});
|
||||
}
|
||||
|
||||
const bbox = detail.snapshot.bboxXyxy;
|
||||
if (bbox) {
|
||||
context.strokeStyle = tokenRgb(
|
||||
host,
|
||||
detail.stratum === "conflict"
|
||||
? "--nodedc-danger-rgb"
|
||||
: "--nodedc-accent-rgb",
|
||||
detail.stratum === "conflict"
|
||||
? [255, 98, 92]
|
||||
: [247, 248, 244],
|
||||
);
|
||||
context.lineWidth = 2.5;
|
||||
context.strokeRect(
|
||||
bbox[0] * scaleX,
|
||||
bbox[1] * scaleY,
|
||||
(bbox[2] - bbox[0]) * scaleX,
|
||||
(bbox[3] - bbox[1]) * scaleY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!detail.cameraFrame) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setFrameState("loading");
|
||||
const image = new Image();
|
||||
image.decoding = "async";
|
||||
image.onload = () => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
image.naturalWidth !== detail.cameraFrame?.width
|
||||
|| image.naturalHeight !== detail.cameraFrame?.height
|
||||
) {
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
return;
|
||||
}
|
||||
setFrameState("ready");
|
||||
draw(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (cancelled) return;
|
||||
setFrameState("unavailable");
|
||||
draw(null);
|
||||
};
|
||||
image.src = detail.cameraFrame.url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = "";
|
||||
};
|
||||
}, [detail, pointLayerVisible, projectionHeight, projectionWidth]);
|
||||
|
||||
const rejectedCount = detail.projection.candidateMask.reduce(
|
||||
(count, candidate, index) => (
|
||||
count
|
||||
+ Number(
|
||||
candidate === 1
|
||||
&& detail.projection.selectedMask[index] !== 1,
|
||||
)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="e30-projection-scene" data-testid="e30-evidence-camera">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-label={pointLayerVisible
|
||||
? "Камерный кадр с проекцией LiDAR и выбранным наблюдением E30"
|
||||
: "Исходный камерный кадр с рамкой наблюдения E30"}
|
||||
/>
|
||||
{frameState !== "ready" ? (
|
||||
<div className="e30-projection-scene__state" role="status">
|
||||
{frameState === "loading"
|
||||
? "Проверяем точный кадр камеры…"
|
||||
: "Точный кадр камеры не материализован"}
|
||||
</div>
|
||||
) : null}
|
||||
{pointLayerVisible ? (
|
||||
<div
|
||||
className="e30-evidence-scene__legend"
|
||||
aria-label="Легенда camera-LiDAR доказательства"
|
||||
>
|
||||
<span data-point="depth">
|
||||
LiDAR · глубина · {detail.projection.pointsMapXyzM.length}
|
||||
</span>
|
||||
<span data-point="rejected">Кандидаты · {rejectedCount}</span>
|
||||
<span data-point="selected">
|
||||
Выбрано E29 · {detail.selected.pointsMapXyzM.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createOrResumeE30HumanReview,
|
||||
finalizeE30HumanReview,
|
||||
saveE30HumanReviewDecision,
|
||||
type E30ExceptionDisposition,
|
||||
type E30HumanReviewDraft,
|
||||
} from "../core/laboratory/e30HumanReview";
|
||||
import type { E30EngineeringGeneration } from "../core/laboratory/e30Engineering";
|
||||
import type {
|
||||
E30ReviewItemDetail,
|
||||
E30ReviewResult,
|
||||
} from "../core/laboratory/e30Review";
|
||||
|
||||
const DISPOSITION_OPTIONS: readonly {
|
||||
value: E30ExceptionDisposition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "object-present", label: "Объект есть" },
|
||||
{ value: "background-or-noise", label: "Фон или шум" },
|
||||
{ value: "insufficient-evidence", label: "Недостаточно данных" },
|
||||
];
|
||||
|
||||
const FALLBACK_REVIEW_PROMPT = {
|
||||
question: "Белый кластер — самостоятельное физическое препятствие?",
|
||||
focus: (
|
||||
"Сопоставьте выбранные белые точки с исходным кадром и решите, "
|
||||
+ "принадлежат ли они занятой геометрии реального объекта."
|
||||
),
|
||||
effects: {
|
||||
"object-present": "Сохранить кластер как занятую геометрию.",
|
||||
"background-or-noise": "Исключить кластер как фон или шум.",
|
||||
"insufficient-evidence": "Оставить кейс неизвестным без настройки порогов.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function decisionFor(
|
||||
review: E30HumanReviewDraft | null,
|
||||
itemId: string | undefined,
|
||||
) {
|
||||
return itemId
|
||||
? review?.decisions.find((decision) => decision.itemId === itemId) ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function E30HumanReviewPanel({
|
||||
result,
|
||||
generation,
|
||||
item,
|
||||
review,
|
||||
onReviewChange,
|
||||
onDecisionSaved,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
generation: E30EngineeringGeneration;
|
||||
item: E30ReviewItemDetail | null;
|
||||
review: E30HumanReviewDraft | null;
|
||||
onReviewChange: (review: E30HumanReviewDraft) => void;
|
||||
onDecisionSaved: (review: E30HumanReviewDraft) => void;
|
||||
}) {
|
||||
const [disposition, setDisposition] =
|
||||
useState<E30ExceptionDisposition>("object-present");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [finalizeOpen, setFinalizeOpen] = useState(false);
|
||||
const currentDecision = decisionFor(review, item?.itemId);
|
||||
const reviewPrompt = generation.humanExceptions.find(
|
||||
(exception) => exception.itemId === item?.itemId,
|
||||
)?.reviewPrompt ?? FALLBACK_REVIEW_PROMPT;
|
||||
|
||||
useEffect(() => {
|
||||
setDisposition(currentDecision?.disposition ?? "object-present");
|
||||
setNotes(currentDecision?.notes ?? "");
|
||||
setError(null);
|
||||
}, [currentDecision, item?.itemId]);
|
||||
|
||||
const begin = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
onReviewChange(await createOrResumeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
));
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка недоступна.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!review || !item || review.state !== "active" || pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await saveE30HumanReviewDecision(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
item.itemId,
|
||||
{
|
||||
expectedRevision: review.revision,
|
||||
idempotencyKey: `ui-${crypto.randomUUID()}`,
|
||||
disposition,
|
||||
notes: notes.trim() || null,
|
||||
},
|
||||
);
|
||||
onReviewChange(next);
|
||||
onDecisionSaved(next);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Решение не сохранено.",
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (!review || review.state !== "active") return;
|
||||
setError(null);
|
||||
try {
|
||||
const finalized = await finalizeE30HumanReview(
|
||||
result.resultId,
|
||||
generation.generationId,
|
||||
review.draftId,
|
||||
review.revision,
|
||||
);
|
||||
onReviewChange(finalized.draft);
|
||||
setFinalizeOpen(false);
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error ? caught.message : "Проверка не зафиксирована.",
|
||||
);
|
||||
throw caught;
|
||||
}
|
||||
};
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТРЕБУЕТСЯ РЕШЕНИЕ</span>
|
||||
<h3>{reviewPrompt.question}</h3>
|
||||
<p>{reviewPrompt.focus}</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
0 / {generation.summary.humanExceptionCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
Решение изменит только отдельную A3-коррекцию; A2 останется
|
||||
неизменным.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void begin()}
|
||||
>
|
||||
{pending ? "Открываем…" : "Начать проверку"}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const finalized = review.state === "finalized";
|
||||
return (
|
||||
<section className="e30-human-review" aria-label="Проверка исключений">
|
||||
<header className="e30-human-review__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПРОВЕРКА ИСКЛЮЧЕНИЙ</span>
|
||||
<h3>
|
||||
{finalized ? "Проверка зафиксирована" : reviewPrompt.question}
|
||||
</h3>
|
||||
{!finalized ? <p>{reviewPrompt.focus}</p> : null}
|
||||
</div>
|
||||
<StatusBadge tone={finalized ? "success" : "accent"}>
|
||||
{review.reviewedItemCount} / {review.itemCount}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{finalized ? (
|
||||
<p>
|
||||
Все спорные кадры получили отдельное человеческое решение.
|
||||
Исходные доказательства сохранены без изменений.
|
||||
</p>
|
||||
) : item ? (
|
||||
<>
|
||||
<div className="e30-human-review__form">
|
||||
<div className="e30-human-review__field">
|
||||
<span>Решение</span>
|
||||
<Select
|
||||
label="Что видно на выбранном кадре"
|
||||
value={disposition}
|
||||
options={[...DISPOSITION_OPTIONS]}
|
||||
variant="split"
|
||||
menuWidth="anchor"
|
||||
disabled={pending}
|
||||
onChange={setDisposition}
|
||||
/>
|
||||
</div>
|
||||
<TextAreaField
|
||||
label="Комментарий"
|
||||
hint="необязательно"
|
||||
value={notes}
|
||||
rows={2}
|
||||
maxLength={2_000}
|
||||
disabled={pending}
|
||||
onChange={(event) => setNotes(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="e30-human-review__impact">
|
||||
<span>Что изменится после решения</span>
|
||||
<strong>{reviewPrompt.effects[disposition]}</strong>
|
||||
</div>
|
||||
<div className="e30-human-review__actions">
|
||||
<span>
|
||||
{currentDecision
|
||||
? "Этот кадр уже решён — его можно пересмотреть."
|
||||
: `${review.remainingItemCount} решений осталось.`}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={pending}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{pending
|
||||
? "Сохраняем…"
|
||||
: currentDecision
|
||||
? "Обновить кадр"
|
||||
: "Сохранить кадр"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={review.remainingItemCount !== 0 || pending}
|
||||
onClick={() => setFinalizeOpen(true)}
|
||||
>
|
||||
Зафиксировать проверку
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="e30-human-review__error" role="alert">{error}</p>
|
||||
) : null}
|
||||
|
||||
<ConfirmationModal
|
||||
open={finalizeOpen}
|
||||
title="Зафиксировать проверку?"
|
||||
description={(
|
||||
<p>
|
||||
Будет создан неизменяемый набор из {review.itemCount} решений.
|
||||
После фиксации их нельзя будет изменить.
|
||||
</p>
|
||||
)}
|
||||
confirmLabel="Зафиксировать"
|
||||
pendingLabel="Фиксируем…"
|
||||
onClose={() => setFinalizeOpen(false)}
|
||||
onConfirm={finalize}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { LaboratoryEvidenceViewer } from "../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { E30EvidenceTelemetry } from "../components/laboratory/E30EvidenceTelemetry";
|
||||
import { E30EngineeringGenerationSummary } from "../components/laboratory/E30EngineeringGenerationSummary";
|
||||
import {
|
||||
fetchE30EngineeringCatalog,
|
||||
fetchE30EngineeringExceptions,
|
||||
type E30EngineeringGeneration,
|
||||
} from "../core/laboratory/e30Engineering";
|
||||
import type { E30HumanReviewDraft } from "../core/laboratory/e30HumanReview";
|
||||
import {
|
||||
E30_STRATA,
|
||||
fetchE30ReviewItemDetail,
|
||||
fetchE30ReviewItems,
|
||||
type E30ReviewItem,
|
||||
type E30ReviewItemDetail,
|
||||
type E30ReviewResult,
|
||||
type E30Stratum,
|
||||
} from "../core/laboratory/e30Review";
|
||||
import { formatNumber } from "../presentation";
|
||||
import { E30EvidencePointCloud } from "./E30EvidencePointCloud";
|
||||
import { E30EvidenceProjection } from "./E30EvidenceProjection";
|
||||
import { E30HumanReviewPanel } from "./E30HumanReviewPanel";
|
||||
|
||||
type E30EvidenceMode = "camera" | "3d";
|
||||
type E30Filter = E30Stratum | "review";
|
||||
|
||||
const FILTER_LABELS: Record<E30Filter, string> = {
|
||||
conflict: "Конфликт",
|
||||
agree: "Согласовано",
|
||||
"camera-only": "Только камера",
|
||||
unknown: "Неизвестно",
|
||||
"geometry-only": "Только геометрия",
|
||||
review: "Проверка",
|
||||
};
|
||||
const FILTERS: readonly E30Filter[] = [...E30_STRATA, "review"];
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function itemTitle(item: E30ReviewItem): string {
|
||||
if (item.snapshot.label) return item.snapshot.label;
|
||||
return item.locatorKind === "geometry-only-cluster"
|
||||
? "Геометрический кластер"
|
||||
: "Семантическое наблюдение";
|
||||
}
|
||||
|
||||
function evidenceRange(item: E30ReviewItem): string {
|
||||
const value = item.snapshot.rangeM ?? item.snapshot.nearestRangeM;
|
||||
return value === null
|
||||
? "Дальность недоступна"
|
||||
: `${value.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} м`;
|
||||
}
|
||||
|
||||
export function E30ReviewWorkspace({
|
||||
result,
|
||||
}: {
|
||||
result: E30ReviewResult;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<E30Filter>("conflict");
|
||||
const [items, setItems] = useState<readonly E30ReviewItem[]>([]);
|
||||
const [itemTotal, setItemTotal] = useState(result.stratumCounts.conflict);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<E30ReviewItemDetail | null>(null);
|
||||
const [itemsLoading, setItemsLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [evidenceMode, setEvidenceMode] = useState<E30EvidenceMode>("camera");
|
||||
const [pointLayerVisible, setPointLayerVisible] = useState(true);
|
||||
const [viewerExpanded, setViewerExpanded] = useState(false);
|
||||
const [engineeringGeneration, setEngineeringGeneration] =
|
||||
useState<E30EngineeringGeneration | null>(null);
|
||||
const [engineeringLoading, setEngineeringLoading] = useState(true);
|
||||
const [humanReview, setHumanReview] =
|
||||
useState<E30HumanReviewDraft | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (filter === "review" && !engineeringGeneration) {
|
||||
setItems([]);
|
||||
setItemTotal(0);
|
||||
setSelectedItemId(null);
|
||||
setItemsLoading(engineeringLoading);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setItemsLoading(true);
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
const request = filter === "review"
|
||||
? fetchE30EngineeringExceptions(
|
||||
result.resultId,
|
||||
engineeringGeneration!.generationId,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
: fetchE30ReviewItems(result.resultId, filter, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
void request.then((next) => {
|
||||
setItems(next.items);
|
||||
setItemTotal(next.total);
|
||||
setSelectedItemId((current) => (
|
||||
next.items.some((item) => item.itemId === current)
|
||||
? current
|
||||
: next.items[0]?.itemId ?? null
|
||||
));
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItems([]);
|
||||
setSelectedItemId(null);
|
||||
setError(caught instanceof Error ? caught.message : "Выборка E30 недоступна.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setItemsLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
engineeringGeneration,
|
||||
engineeringLoading,
|
||||
filter,
|
||||
result.resultId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEngineeringLoading(true);
|
||||
setEngineeringGeneration(null);
|
||||
void fetchE30EngineeringCatalog(result.resultId, {
|
||||
signal: controller.signal,
|
||||
}).then((catalog) => {
|
||||
setEngineeringGeneration(catalog.items[0] ?? null);
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setEngineeringGeneration(null);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEngineeringLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItemId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setDetailLoading(true);
|
||||
setError(null);
|
||||
void fetchE30ReviewItemDetail(result.resultId, selectedItemId, {
|
||||
signal: controller.signal,
|
||||
}).then(setDetail).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setError(caught instanceof Error ? caught.message : "Доказательство E30 недоступно.");
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setDetailLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedItemId]);
|
||||
|
||||
const selectItem = (item: E30ReviewItem) => {
|
||||
setSelectedItemId(item.itemId);
|
||||
};
|
||||
|
||||
const advanceAfterDecision = (next: E30HumanReviewDraft) => {
|
||||
const resolved = new Set(next.decisions.map((decision) => decision.itemId));
|
||||
const currentIndex = items.findIndex((item) => item.itemId === selectedItemId);
|
||||
const ordered = [
|
||||
...items.slice(currentIndex + 1),
|
||||
...items.slice(0, currentIndex + 1),
|
||||
];
|
||||
const unresolved = ordered.find((item) => !resolved.has(item.itemId));
|
||||
if (unresolved) setSelectedItemId(unresolved.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface
|
||||
className="e30-review-workspace"
|
||||
tone="soft"
|
||||
padding="md"
|
||||
materialRim={false}
|
||||
role="region"
|
||||
aria-label="Рабочее место ревью E30"
|
||||
>
|
||||
<header className="e30-review-workspace__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">CAMERA-BACKED REVIEW SUBSTRATE</span>
|
||||
<h2>A2 evidence · A3 engineering audit</h2>
|
||||
<p>
|
||||
Точный camera frame, LiDAR-проекция и синхронный 3D сохраняют A2
|
||||
неизменяемым. A3 выпускает отдельные решения с явным provenance.
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={result.cameraEvidenceAvailable ? "accent" : "warning"}>
|
||||
{result.cameraEvidenceAvailable
|
||||
? "Camera evidence привязано"
|
||||
: "Camera evidence отсутствует"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
{engineeringGeneration ? (
|
||||
<E30EngineeringGenerationSummary
|
||||
generation={engineeringGeneration}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SegmentedControl
|
||||
className="e30-review-workspace__strata"
|
||||
value={filter}
|
||||
label="Группа E30"
|
||||
items={FILTERS.map((value) => ({
|
||||
value,
|
||||
label: `${FILTER_LABELS[value]} · ${formatNumber(
|
||||
value === "review"
|
||||
? engineeringGeneration?.summary.humanExceptionCount ?? 0
|
||||
: result.stratumCounts[value],
|
||||
0,
|
||||
)}`,
|
||||
}))}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
|
||||
<div className="e30-review-workspace__body">
|
||||
<aside className="e30-review-workspace__items" aria-label="Кейсы выбранной страты">
|
||||
<header>
|
||||
<span>{FILTER_LABELS[filter]}</span>
|
||||
<small>показано {items.length} из {itemTotal}</small>
|
||||
</header>
|
||||
<div className="e30-review-workspace__item-list">
|
||||
{itemsLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем индекс</span>
|
||||
</div>
|
||||
) : items.length ? items.map((item) => (
|
||||
<Button
|
||||
key={item.itemId}
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
width="full"
|
||||
className="e30-review-workspace__item"
|
||||
data-active={item.itemId === selectedItemId ? "true" : undefined}
|
||||
aria-pressed={item.itemId === selectedItemId}
|
||||
onClick={() => selectItem(item)}
|
||||
>
|
||||
<span>{itemTitle(item)}</span>
|
||||
<strong>Кадр {formatNumber(item.sourceFrameIndex, 0)}</strong>
|
||||
<small>
|
||||
{formatSeconds(item.sessionSeconds)}
|
||||
{" · "}
|
||||
{evidenceRange(item)}
|
||||
{filter === "review"
|
||||
&& humanReview?.decisions.some(
|
||||
(decision) => decision.itemId === item.itemId,
|
||||
)
|
||||
? " · Решено"
|
||||
: ""}
|
||||
</small>
|
||||
</Button>
|
||||
)) : (
|
||||
<div className="e30-review-workspace__state">
|
||||
<Icon name="database" size={18} />
|
||||
<span>В этой страте кейсов нет</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="e30-review-workspace__detail">
|
||||
{detailLoading ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Проверяем camera-LiDAR доказательство</span>
|
||||
</div>
|
||||
) : error || !detail ? (
|
||||
<div className="e30-review-workspace__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{error ?? "Выберите кейс."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="e30-review-workspace__case">
|
||||
<div>
|
||||
<span className="section-eyebrow">{detail.reviewKey}</span>
|
||||
<h3>{itemTitle(detail)}</h3>
|
||||
<p>
|
||||
{detail.snapshot.geometryReason ?? "Независимый geometry-only слой"}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={detail.stratum === "conflict" ? "danger" : "neutral"}>
|
||||
{FILTER_LABELS[detail.stratum]}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="e30-review-evidence">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="Доказательство E30"
|
||||
mode={evidenceMode}
|
||||
modes={[
|
||||
{ value: "camera", label: "Камера" },
|
||||
{ value: "3d", label: "3D" },
|
||||
]}
|
||||
expanded={viewerExpanded}
|
||||
onModeChange={setEvidenceMode}
|
||||
onExpandedChange={setViewerExpanded}
|
||||
actions={evidenceMode === "camera" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={pointLayerVisible ? "primary" : "secondary"}
|
||||
icon={<Icon name="sliders" size={16} />}
|
||||
aria-pressed={pointLayerVisible}
|
||||
onClick={() => setPointLayerVisible((visible) => !visible)}
|
||||
>
|
||||
LiDAR
|
||||
</Button>
|
||||
) : undefined}
|
||||
overlay={(
|
||||
<E30EvidenceTelemetry
|
||||
detail={detail}
|
||||
mode={evidenceMode}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{evidenceMode === "camera" ? (
|
||||
<E30EvidenceProjection
|
||||
detail={detail}
|
||||
projectionWidth={result.projection.width}
|
||||
projectionHeight={result.projection.height}
|
||||
pointLayerVisible={pointLayerVisible}
|
||||
/>
|
||||
) : (
|
||||
<E30EvidencePointCloud detail={detail} />
|
||||
)}
|
||||
</LaboratoryEvidenceViewer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{!detailLoading
|
||||
&& !error
|
||||
&& filter === "review"
|
||||
&& engineeringGeneration
|
||||
&& detail ? (
|
||||
<E30HumanReviewPanel
|
||||
result={result}
|
||||
generation={engineeringGeneration}
|
||||
item={detail}
|
||||
review={humanReview}
|
||||
onReviewChange={setHumanReview}
|
||||
onDecisionSaved={advanceAfterDecision}
|
||||
/>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import type { ObservationSessionReplayCallbacks } from "../components/ObservationSessionSelect";
|
||||
import type {
|
||||
DeviceModelDefinition,
|
||||
DevicePluginConnectionProps,
|
||||
} from "../core/device-plugins/contracts";
|
||||
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
BackendStatus,
|
||||
MissionRuntimeState,
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
requestedPlaybackSeconds?: number | null;
|
||||
recordedReplay: ObservationSessionReplayLaunch | null;
|
||||
recordedSessionAdmission: RecordedSessionAdmissionController | null;
|
||||
sceneSettings: SceneSettings;
|
||||
accumulationSeconds: number;
|
||||
onAccumulationChange: (value: number) => void;
|
||||
onAccumulationCommit: () => void;
|
||||
livePerceptionLayers: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
};
|
||||
onLivePerceptionLayersChange: (next: {
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
deviceLabel: string | null;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
model: DeviceModelDefinition;
|
||||
} | null;
|
||||
sessionArchive: ObservationSessionReplayCallbacks & {
|
||||
disabled: boolean;
|
||||
blockedReason: string | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
LaboratorySelector,
|
||||
LaboratorySummary,
|
||||
LaboratoryWorkTemplate,
|
||||
type LaboratoryMethod,
|
||||
type LaboratoryMethodComponent,
|
||||
type LaboratoryOption,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||
import {
|
||||
fetchE29EvidenceCatalog,
|
||||
fetchE29EvidenceFrame,
|
||||
type E29EvidenceFrame,
|
||||
type E29EvidenceResult,
|
||||
} from "../../core/laboratory/e29Evidence";
|
||||
import {
|
||||
fetchE30ReviewCatalog,
|
||||
type E30ReviewResult,
|
||||
} from "../../core/laboratory/e30Review";
|
||||
import {
|
||||
fetchLidarLocalSurfaces,
|
||||
type LidarLocalSurfaceModel,
|
||||
} from "../../core/lidar/localSurface";
|
||||
import { formatNumber } from "../../presentation";
|
||||
import { E30ReviewWorkspace } from "../E30ReviewWorkspace";
|
||||
import { LidarQualityWorkspace } from "../LidarQualityWorkspace";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
|
||||
type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||
};
|
||||
|
||||
type LaboratoryProfileId = "sensor-fusion" | "published-perception";
|
||||
type LaboratoryWorkId =
|
||||
| "e28-local-surface"
|
||||
| "e29-camera-geometry"
|
||||
| "e30-evidence-review"
|
||||
| `session:${string}`;
|
||||
|
||||
function digestFromContentId(value: string | null | undefined): string | null {
|
||||
const digest = value?.split("-").at(-1) ?? "";
|
||||
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||
}
|
||||
|
||||
function publishedLaboratoryMethod(
|
||||
session: ObservationSessionSummary,
|
||||
): LaboratoryMethod {
|
||||
const method = session.lab?.provenance.method;
|
||||
if (method && typeof method === "object" && !Array.isArray(method)) {
|
||||
const value = method as Record<string, unknown>;
|
||||
const rawComponents = Array.isArray(value.components) ? value.components : [];
|
||||
const components: LaboratoryMethodComponent[] = rawComponents.flatMap((component) => {
|
||||
if (!component || typeof component !== "object" || Array.isArray(component)) return [];
|
||||
const item = component as Record<string, unknown>;
|
||||
const kind = item.kind;
|
||||
if (
|
||||
kind !== "source"
|
||||
&& kind !== "tool"
|
||||
&& kind !== "model"
|
||||
&& kind !== "algorithm"
|
||||
&& kind !== "runtime"
|
||||
) return [];
|
||||
if (
|
||||
typeof item.name !== "string"
|
||||
|| typeof item.version !== "string"
|
||||
|| typeof item.role !== "string"
|
||||
) return [];
|
||||
return [{
|
||||
kind: kind as LaboratoryMethodComponent["kind"],
|
||||
name: item.name,
|
||||
version: item.version,
|
||||
role: item.role,
|
||||
identitySha256: typeof item.identity_sha256 === "string"
|
||||
? item.identity_sha256
|
||||
: null,
|
||||
}];
|
||||
});
|
||||
const executionClass = value.execution_class;
|
||||
const completeness = value.completeness;
|
||||
if (
|
||||
components.length
|
||||
&& typeof value.pipeline_id === "string"
|
||||
&& (
|
||||
executionClass === "deterministic"
|
||||
|| executionClass === "ai-inference"
|
||||
|| executionClass === "hybrid"
|
||||
)
|
||||
&& (completeness === "complete" || completeness === "legacy-partial")
|
||||
) {
|
||||
return {
|
||||
completeness,
|
||||
executionClass,
|
||||
pipelineId: value.pipeline_id,
|
||||
components,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resultKind = session.lab?.resultKind ?? "unknown";
|
||||
const algorithmNames: Record<string, string> = {
|
||||
"e10-integrated-perception": "Camera semantics + LiDAR metric fusion",
|
||||
"e21-realtime-envelope": "Bounded real-time perception replay",
|
||||
"e22-temporal-stability": "Temporal 2D/3D/semantic stabilization",
|
||||
"e23-inline-temporal-stability": "Inline warm-worker stabilization",
|
||||
"e24-world-motion": "World-frame motion tracking",
|
||||
"e25-persistent-support-motion": "Persistent occupied-support tracking",
|
||||
"e26-camera-ego-motion-fusion": "KB4 ego-motion + persistent LiDAR support",
|
||||
};
|
||||
return {
|
||||
completeness: "legacy-partial",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: resultKind,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: session.lab?.sourceResultId ?? session.lab?.sourceSessionId ?? session.id,
|
||||
version: "immutable source evidence",
|
||||
role: "read-only input",
|
||||
identitySha256: digestFromContentId(session.lab?.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: algorithmNames[resultKind] ?? resultKind,
|
||||
version: resultKind,
|
||||
role: "laboratory derivative",
|
||||
identitySha256: session.lab?.configSha256 ?? null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||
}
|
||||
|
||||
function E29LaboratoryResult({
|
||||
props,
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
rigLabel: string;
|
||||
result: E29EvidenceResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [selectedFrameIndex, setSelectedFrameIndex] = useState(
|
||||
result.reviewFrames[0]?.frameIndex ?? 0,
|
||||
);
|
||||
const [frame, setFrame] = useState<E29EvidenceFrame | null>(null);
|
||||
const [frameLoading, setFrameLoading] = useState(false);
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const replayReady = props.recordedReplay?.sessionId === sourceSession.id;
|
||||
const semantic = result.metrics.semanticObservations;
|
||||
const geometryStatus = semantic.geometryStatus;
|
||||
const conflicts = frame?.semanticObservations.filter(
|
||||
(observation) => observation.geometryStatus === "conflict",
|
||||
) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setFrameLoading(true);
|
||||
setFrameError(null);
|
||||
void fetchE29EvidenceFrame(result.resultId, selectedFrameIndex, {
|
||||
signal: controller.signal,
|
||||
}).then((next) => {
|
||||
setFrame(next);
|
||||
}).catch((caught: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFrame(null);
|
||||
setFrameError(
|
||||
caught instanceof Error ? caught.message : "Кадр E29 недоступен.",
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setFrameLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [result.resultId, selectedFrameIndex]);
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E29 · camera-first semantics + независимая геометрия"
|
||||
description="Камера сохраняет класс и идентичность объекта, а LiDAR независимо подтверждает дальность и занятую геометрию по локальной поверхности L2.6. Отсутствие точек не объявляется свободным пространством."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · worker D` },
|
||||
{
|
||||
label: "Источник",
|
||||
value: `${sourceSession.label} · ${formatNumber(result.identity.frameCount, 0)} кадров`,
|
||||
},
|
||||
{
|
||||
label: "Наблюдений",
|
||||
value: formatNumber(semantic.total, 0),
|
||||
},
|
||||
{
|
||||
label: "Контур",
|
||||
value: "Read-only · hash verified",
|
||||
},
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: "hybrid",
|
||||
pipelineId: result.identity.profileId,
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: result.linkedEvidence.sourceResultId,
|
||||
version: "camera-first semantic observations",
|
||||
role: "semantic identity and class",
|
||||
identitySha256: digestFromContentId(result.linkedEvidence.sourceResultId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: "Camera/LiDAR local-surface validation",
|
||||
version: result.identity.profileId,
|
||||
role: "range, occupied support and conflict classification",
|
||||
identitySha256: result.identity.producerSha256,
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
name: result.linkedEvidence.localSurfaceModelId,
|
||||
version: "L2.6 local surface",
|
||||
role: "independent metric geometry",
|
||||
identitySha256: digestFromContentId(
|
||||
result.linkedEvidence.localSurfaceModelId,
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ИСХОДНЫЕ ДАННЫЕ"
|
||||
title="LiDAR, траектория и камера RAVNOVES00"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Проверяем и открываем запись" : "Исходная запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Viewer появится после серверной проверки неизменяемого RRD."
|
||||
: "Выберите LAB E29 повторно, чтобы открыть связанный источник.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">РЕЗУЛЬТАТ И ВЫВОД</span>
|
||||
<h2>Camera-first контракт рассчитан, production gate не пройден</h2>
|
||||
</div>
|
||||
<StatusBadge tone={result.decision.productionPromotion ? "success" : "warning"}>
|
||||
{result.decision.productionPromotion ? "Допущено" : "Только диагностика"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
<div>
|
||||
<span>Поддержка геометрией</span>
|
||||
<strong>{formatNumber(geometryStatus.agree, 0)}</strong>
|
||||
<small>{(semantic.agreementFractionOfCurrent * 100).toLocaleString("ru-RU", { maximumFractionDigits: 2 })}% current</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только камера</span>
|
||||
<strong>{formatNumber(geometryStatus.cameraOnly, 0)}</strong>
|
||||
<small>Семантика без LiDAR-подтверждения</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Postprocess p95</span>
|
||||
<strong>{result.metrics.runtime.frameProcessingP95Ms.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} мс</strong>
|
||||
<small>{formatSeconds(result.metrics.runtime.buildElapsedMs / 1000)} полный build</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Только геометрия</span>
|
||||
<strong>{formatNumber(result.metrics.geometryOnlyOccupied.clusterCount, 0)}</strong>
|
||||
<small>{formatNumber(result.metrics.geometryOnlyOccupied.pointCount, 0)} точек</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>{result.decision.nextGate}</p>
|
||||
</section>
|
||||
)}
|
||||
details={(
|
||||
<section className="laboratory-frame-review">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">КАДРЫ С КОНФЛИКТОМ</span>
|
||||
<h2>Покадровое доказательство из camera-geometry-frames.jsonl</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">
|
||||
{formatNumber(geometryStatus.conflict, 0)} конфликтов
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-frame-review__picker" role="list">
|
||||
{result.reviewFrames.slice(0, 16).map((review) => (
|
||||
<button
|
||||
key={review.frameIndex}
|
||||
type="button"
|
||||
className={review.frameIndex === selectedFrameIndex ? "is-active" : undefined}
|
||||
onClick={() => setSelectedFrameIndex(review.frameIndex)}
|
||||
>
|
||||
<span>Кадр {formatNumber(review.sourceFrameIndex, 0)}</span>
|
||||
<small>{formatSeconds(review.sessionSeconds)} · {review.conflictCount} конфликт</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{frameLoading ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Читаем подтверждённый кадр</span>
|
||||
</div>
|
||||
) : frameError || !frame ? (
|
||||
<div className="laboratory-frame-review__state" role="status">
|
||||
<Icon name="database" size={18} />
|
||||
<span>{frameError ?? "Кадр недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="laboratory-frame-review__detail">
|
||||
<div>
|
||||
<span>Кадр источника</span>
|
||||
<strong>{formatNumber(frame.sourceFrameIndex, 0)}</strong>
|
||||
<small>{formatSeconds(frame.sessionSeconds)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Семантические наблюдения</span>
|
||||
<strong>{formatNumber(frame.semanticObservations.length, 0)}</strong>
|
||||
<small>{formatNumber(conflicts.length, 0)} требуют разбора</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Geometry-only компоненты</span>
|
||||
<strong>{formatNumber(frame.geometryOnlyOccupied.length, 0)}</strong>
|
||||
<small>Класс не назначается</small>
|
||||
</div>
|
||||
<div className="laboratory-frame-review__conflicts">
|
||||
<span>Фактические конфликты</span>
|
||||
{conflicts.length ? conflicts.map((observation) => (
|
||||
<p key={observation.trackId}>
|
||||
<strong>{observation.label} · track {observation.trackId}</strong>
|
||||
<small>
|
||||
{observation.geometryReason} · classified {observation.support.classifiedPoints}
|
||||
{" · "}surface {observation.support.surfacePoints}
|
||||
</small>
|
||||
</p>
|
||||
)) : <small>В этом кадре конфликт не найден.</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function E30LaboratoryResult({
|
||||
rigLabel,
|
||||
result,
|
||||
sourceSession,
|
||||
}: {
|
||||
rigLabel: string;
|
||||
result: E30ReviewResult;
|
||||
sourceSession: ObservationSessionSummary;
|
||||
}) {
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E30 · рабочее место evidence review"
|
||||
description="A2 связывает каждый кейс E29 с точным camera frame, LiDAR-проекцией и frame-local индексами. Камера отвечает на вопрос «что видит детектор», синхронный 3D проверяет принадлежность и форму точек."
|
||||
status="Camera evidence проверено"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · камера + LiDAR · A2` },
|
||||
{ label: "Источник", value: sourceSession.label },
|
||||
{ label: "Кейсов", value: formatNumber(result.itemCount, 0) },
|
||||
{ label: "Контур", value: "Read-only · без LAB publish" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="CAMERA + LIDAR ДОКАЗАТЕЛЬСТВО"
|
||||
title="Точный кадр, проекция и синхронный 3D"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<E30ReviewWorkspace result={result} />
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLaboratoryMetric(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
if (typeof value === "number") {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: 3 });
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function laboratoryMetricLabel(key: string): string {
|
||||
return key
|
||||
.replace(/_p95_ms$/, " · p95 мс")
|
||||
.replaceAll("_", " ")
|
||||
.replace(/^./, (value) => value.toLocaleUpperCase("ru-RU"));
|
||||
}
|
||||
|
||||
function laboratorySessionTitle(session: ObservationSessionSummary): string {
|
||||
const labPrefix = session.lab ? `${session.lab.labId} · ` : "";
|
||||
return labPrefix && session.label.startsWith(labPrefix)
|
||||
? session.label.slice(labPrefix.length)
|
||||
: session.label;
|
||||
}
|
||||
|
||||
function PublishedLaboratoryResult({
|
||||
props,
|
||||
session,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
props: LaboratoryWorkspaceProps;
|
||||
session: ObservationSessionSummary;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const lab = session.lab;
|
||||
const metrics = Object.entries(lab?.provenance ?? {})
|
||||
.filter(([key, value]) => (
|
||||
(typeof value === "number" || typeof value === "boolean")
|
||||
&& !key.includes("sha256")
|
||||
&& !key.includes("authority")
|
||||
))
|
||||
.slice(0, 8);
|
||||
const replayReady = props.recordedReplay?.sessionId === session.id;
|
||||
|
||||
return (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title={`${lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`}
|
||||
description="Опубликованная работа открывается по неизменяемой записи. Viewer показывает исходные синхронные каналы, а продуктовая выжимка — только зафиксированный метод и LAB-provenance."
|
||||
status="Зафиксированный результат"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Тип результата", value: lab?.resultKind ?? "—" },
|
||||
{ label: "Источник", value: lab?.sourceSessionId ?? session.id },
|
||||
{ label: "Конфигурация", value: lab?.configSha256?.slice(0, 16) ?? "Не зафиксирована" },
|
||||
{ label: "Контур", value: "Диагностика · без команд" },
|
||||
]}
|
||||
method={publishedLaboratoryMethod(session)}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Исходная запись выбранной лабораторной работы"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
{replayReady ? (
|
||||
<props.SpatialView {...props} />
|
||||
) : (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
{loading ? <span className="busy-indicator" aria-hidden="true" /> : <Icon name="database" size={20} />}
|
||||
<strong>{loading ? "Подготавливаем лабораторную запись" : "Запись не открыта"}</strong>
|
||||
<p>
|
||||
{error ?? (loading
|
||||
? "Связанное визуальное доказательство откроется после проверки записи."
|
||||
: "Выберите работу ещё раз, чтобы открыть связанное визуальное доказательство.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
result={(
|
||||
<section className="laboratory-result-summary">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">ЗАФИКСИРОВАННЫЕ МЕТРИКИ</span>
|
||||
<h2>Результат из LAB-provenance</h2>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
lab?.provenance.benchmark_passed === true ? "success" : "warning"
|
||||
}>
|
||||
{lab?.provenance.benchmark_passed === true ? "Benchmark passed" : "Требует разбора"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="laboratory-result-metrics">
|
||||
{metrics.length ? metrics.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{laboratoryMetricLabel(key)}</span>
|
||||
<strong>{formatLaboratoryMetric(value)}</strong>
|
||||
<small>Immutable provenance</small>
|
||||
</div>
|
||||
)) : (
|
||||
<div>
|
||||
<span>Метрики</span>
|
||||
<strong>Не опубликованы</strong>
|
||||
<small>Доступна исходная запись</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>("sensor-fusion");
|
||||
const [workId, setWorkId] = useState<LaboratoryWorkId>("e28-local-surface");
|
||||
const [e28Model, setE28Model] = useState<LidarLocalSurfaceModel | null>(null);
|
||||
const [e29Result, setE29Result] = useState<E29EvidenceResult | null>(null);
|
||||
const [e30Result, setE30Result] = useState<E30ReviewResult | null>(null);
|
||||
const [evidenceLoading, setEvidenceLoading] = useState(true);
|
||||
const [evidenceError, setEvidenceError] = useState<string | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit: 100,
|
||||
replayEnabled: props.sessionArchive.blockedReason === null,
|
||||
onReplayBegin: props.sessionArchive.onReplayBegin,
|
||||
onReplayAccepted: props.sessionArchive.onReplayAccepted,
|
||||
onReplaySettled: props.sessionArchive.onReplaySettled,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)),
|
||||
[sessions.items],
|
||||
);
|
||||
const sourceSessions = useMemo(
|
||||
() => new Map(sessions.items.map((session) => [session.id, session])),
|
||||
[sessions.items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setEvidenceLoading(true);
|
||||
setEvidenceError(null);
|
||||
void Promise.allSettled([
|
||||
fetchLidarLocalSurfaces({ signal: controller.signal }),
|
||||
fetchE29EvidenceCatalog({ signal: controller.signal }),
|
||||
fetchE30ReviewCatalog({ signal: controller.signal }),
|
||||
]).then(([e28, e29, e30]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const nextE28 = e28.status === "fulfilled" ? e28.value.items[0] ?? null : null;
|
||||
const nextE29 = e29.status === "fulfilled" ? e29.value.items[0] ?? null : null;
|
||||
const nextE30 = e30.status === "fulfilled" ? e30.value.items[0] ?? null : null;
|
||||
setE28Model(nextE28);
|
||||
setE29Result(nextE29);
|
||||
setE30Result(nextE30);
|
||||
const failures = [
|
||||
e28.status === "rejected" ? "E28" : null,
|
||||
e29.status === "rejected" ? "E29" : null,
|
||||
e30.status === "rejected" ? "E30" : null,
|
||||
].filter(Boolean);
|
||||
setEvidenceError(
|
||||
failures.length
|
||||
? `${failures.join(" и ")} не прошли серверную проверку и скрыты.`
|
||||
: null,
|
||||
);
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setEvidenceLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const rigLabel = useMemo(() => {
|
||||
if (props.deviceLabel) return props.deviceLabel;
|
||||
const coordinateFrame = publishedWorks
|
||||
.map((session) => session.lab?.provenance.coordinate_frame)
|
||||
.find((value) => typeof value === "string" && value.trim());
|
||||
const sensorToken = typeof coordinateFrame === "string"
|
||||
? coordinateFrame.split("-")[0]?.trim()
|
||||
: "";
|
||||
return sensorToken ? sensorToken.toLocaleUpperCase("ru-RU") : "Сенсорный риг";
|
||||
}, [props.deviceLabel, publishedWorks]);
|
||||
const sensorWorks = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryWorkId>[] = [];
|
||||
if (e28Model) {
|
||||
items.push({
|
||||
id: "e28-local-surface",
|
||||
label: "LAB E28 · локальная поверхность L2.6",
|
||||
});
|
||||
}
|
||||
if (
|
||||
e29Result
|
||||
&& sourceSessions.has(e29Result.linkedEvidence.sourceSessionId)
|
||||
) {
|
||||
items.push({
|
||||
id: "e29-camera-geometry",
|
||||
label: "LAB E29 · camera-first + geometry",
|
||||
});
|
||||
}
|
||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||
items.push({
|
||||
id: "e30-evidence-review",
|
||||
label: "LAB E30 · evidence review A2",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [e28Model, e29Result, e30Result, sourceSessions]);
|
||||
const profiles = useMemo(() => {
|
||||
const items: LaboratoryOption<LaboratoryProfileId>[] = [];
|
||||
if (sensorWorks.length) {
|
||||
items.push({
|
||||
id: "sensor-fusion",
|
||||
label: `${rigLabel} · камера + LiDAR · control plane`,
|
||||
});
|
||||
}
|
||||
if (publishedWorks.length) {
|
||||
items.push({
|
||||
id: "published-perception",
|
||||
label: `${rigLabel} · опубликованный perception pipeline`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [publishedWorks.length, rigLabel, sensorWorks.length]);
|
||||
const workOptions: readonly LaboratoryOption<LaboratoryWorkId>[] =
|
||||
profileId === "sensor-fusion"
|
||||
? sensorWorks
|
||||
: publishedWorks.map((session) => ({
|
||||
id: `session:${session.id}` as const,
|
||||
label: `${session.lab?.labId ?? "LAB"} · ${laboratorySessionTitle(session)}`,
|
||||
}));
|
||||
const selectedSessionId = workId.startsWith("session:")
|
||||
? workId.slice("session:".length)
|
||||
: null;
|
||||
const selectedSession = selectedSessionId
|
||||
? publishedWorks.find((session) => session.id === selectedSessionId) ?? null
|
||||
: null;
|
||||
const e29SourceSession = e29Result
|
||||
? sourceSessions.get(e29Result.linkedEvidence.sourceSessionId) ?? null
|
||||
: null;
|
||||
const e30SourceSession = e30Result
|
||||
? sourceSessions.get(e30Result.sourceSessionId) ?? null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) return;
|
||||
if (!profiles.some((profile) => profile.id === profileId)) {
|
||||
const firstProfile = profiles[0];
|
||||
if (!firstProfile) return;
|
||||
setProfileId(firstProfile.id);
|
||||
if (firstProfile.id === "sensor-fusion") {
|
||||
const firstWork = sensorWorks[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
} else {
|
||||
const first = publishedWorks[0];
|
||||
if (first) setWorkId(`session:${first.id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!workOptions.some((work) => work.id === workId)) {
|
||||
const firstWork = workOptions[0];
|
||||
if (firstWork) setWorkId(firstWork.id);
|
||||
}
|
||||
}, [
|
||||
evidenceLoading,
|
||||
profileId,
|
||||
profiles,
|
||||
publishedWorks,
|
||||
sensorWorks,
|
||||
sessions.state,
|
||||
workId,
|
||||
workOptions,
|
||||
]);
|
||||
|
||||
const selectProfile = (next: LaboratoryProfileId) => {
|
||||
setProfileId(next);
|
||||
if (next === "sensor-fusion") {
|
||||
const first = sensorWorks[0];
|
||||
if (first) setWorkId(first.id);
|
||||
return;
|
||||
}
|
||||
const first = publishedWorks[0];
|
||||
if (!first) return;
|
||||
const nextWork = `session:${first.id}` as const;
|
||||
setWorkId(nextWork);
|
||||
void sessions.replay(first.id);
|
||||
};
|
||||
|
||||
const selectWork = (next: LaboratoryWorkId) => {
|
||||
setWorkId(next);
|
||||
if (next.startsWith("session:")) {
|
||||
void sessions.replay(next.slice("session:".length));
|
||||
return;
|
||||
}
|
||||
if (next === "e29-camera-geometry" && e29SourceSession) {
|
||||
void sessions.replay(e29SourceSession.id);
|
||||
return;
|
||||
}
|
||||
if (next === "e30-evidence-review" && e30SourceSession) {
|
||||
void sessions.replay(e30SourceSession.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
evidenceLoading
|
||||
|| sessions.state === "idle"
|
||||
|| sessions.state === "loading"
|
||||
) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<strong>Ревизия лабораторных данных</strong>
|
||||
<p>Проверяем артефакты, связанные исходные записи и доступность replay.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profiles.length) {
|
||||
return (
|
||||
<div className="laboratory-result-pending" role="status">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Подтверждённых лабораторных работ нет</strong>
|
||||
<p>
|
||||
{evidenceError ?? sessions.error ?? "Непроверенные и отсутствующие результаты скрыты."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const viewerFocused = Boolean(
|
||||
props.observationLayout.focusedSourceId
|
||||
|| props.observationLayout.maximizedFloatingSourceId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lab-archive-workspace"
|
||||
data-viewer-focused={viewerFocused ? "true" : undefined}
|
||||
>
|
||||
<LaboratorySelector
|
||||
eyebrow="ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА"
|
||||
title={profiles.find((profile) => profile.id === profileId)?.label ?? rigLabel}
|
||||
description="Профиль фиксирует объект исследования, сенсорные модули и вычислительный контур. Исходные данные остаются read-only; профиль объединяет серию сопоставимых лабораторных работ."
|
||||
label="Профиль"
|
||||
value={profileId}
|
||||
options={profiles}
|
||||
onChange={selectProfile}
|
||||
/>
|
||||
|
||||
<LaboratorySelector
|
||||
eyebrow="ЛАБОРАТОРНАЯ РАБОТА"
|
||||
title={workOptions.find((work) => work.id === workId)?.label ?? "Работа не выбрана"}
|
||||
description="Выберите один зафиксированный эксперимент. Ниже откроются его задача и структурированный результат; viewer появляется только у опубликованного серверного доказательства."
|
||||
label="Работа"
|
||||
value={workId}
|
||||
options={workOptions}
|
||||
disabled={workOptions.length === 0}
|
||||
onChange={selectWork}
|
||||
/>
|
||||
|
||||
<div className="laboratory-work-output">
|
||||
{workId === "e28-local-surface" ? (
|
||||
<LaboratoryWorkTemplate
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB E28 · локальная модель поверхности L2.6"
|
||||
description="Запись RAVNOVES00 воспроизводится через bounded shadow-контур. Модель оценивает поверхность, препятствия, временные скачки и ошибку предсказания без изменения источника и без командного канала."
|
||||
status="Проверенные артефакты"
|
||||
statusTone="success"
|
||||
facts={[
|
||||
{ label: "Конфигурация", value: `${rigLabel} · LiDAR + pose · worker D` },
|
||||
{
|
||||
label: "Покрытие",
|
||||
value: `${formatNumber(e28Model?.metrics.frames.valid ?? 0, 0)} / ${formatNumber(e28Model?.metrics.frames.total ?? 0, 0)} кадров`,
|
||||
},
|
||||
{ label: "Режим", value: "Recorded-source-paced shadow" },
|
||||
{ label: "Контур", value: "Read-only · hash verified" },
|
||||
]}
|
||||
method={{
|
||||
completeness: "complete",
|
||||
executionClass: e28Model?.method.executionClass ?? "deterministic",
|
||||
pipelineId: e28Model?.method.pipelineId ?? "local-surface/unavailable",
|
||||
components: [
|
||||
{
|
||||
kind: "source",
|
||||
name: e28Model?.sourcePackId ?? "Источник не загружен",
|
||||
version: "immutable vendor MAP + pose",
|
||||
role: "read-only LiDAR evidence",
|
||||
identitySha256: digestFromContentId(e28Model?.sourcePackId),
|
||||
},
|
||||
{
|
||||
kind: "algorithm",
|
||||
name: e28Model?.method.algorithm ?? "Rolling local surface",
|
||||
version: e28Model?.method.pipelineId ?? "—",
|
||||
role: "robust local plane, occupancy and temporal residuals",
|
||||
identitySha256: e28Model?.method.producerSha256 ?? null,
|
||||
},
|
||||
{
|
||||
kind: "runtime",
|
||||
name: "Mission Core worker D",
|
||||
version: "recorded-source-paced shadow",
|
||||
role: "bounded passive replay",
|
||||
identitySha256: null,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="ВИЗУАЛЬНОЕ ДОКАЗАТЕЛЬСТВО"
|
||||
title="Диагностическая поверхность и кадры LAB E28"
|
||||
kind="diagnostic-model"
|
||||
>
|
||||
<LidarQualityWorkspace
|
||||
embedded
|
||||
deviceLabel={props.deviceLabel}
|
||||
onOpenObservation={() => props.navigation.openView("spatial-scene")}
|
||||
/>
|
||||
</LaboratoryEvidence>
|
||||
)}
|
||||
/>
|
||||
) : workId === "e29-camera-geometry" && e29Result && e29SourceSession ? (
|
||||
<E29LaboratoryResult
|
||||
props={props}
|
||||
rigLabel={rigLabel}
|
||||
result={e29Result}
|
||||
sourceSession={e29SourceSession}
|
||||
loading={sessions.replayingSessionId === e29SourceSession.id}
|
||||
error={
|
||||
sessions.failedSessionId === e29SourceSession.id
|
||||
? sessions.error
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : workId === "e30-evidence-review" && e30Result && e30SourceSession ? (
|
||||
<E30LaboratoryResult
|
||||
rigLabel={rigLabel}
|
||||
result={e30Result}
|
||||
sourceSession={e30SourceSession}
|
||||
/>
|
||||
) : selectedSession ? (
|
||||
<PublishedLaboratoryResult
|
||||
props={props}
|
||||
session={selectedSession}
|
||||
loading={sessions.replayingSessionId === selectedSession.id}
|
||||
error={sessions.failedSessionId === selectedSession.id ? sessions.error : null}
|
||||
/>
|
||||
) : (
|
||||
<div className="laboratory-result-pending">
|
||||
<Icon name="database" size={20} />
|
||||
<strong>Работа не прошла ревизию</strong>
|
||||
<p>Неподтверждённый результат скрыт из лабораторного каталога.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
|
||||
const sourceRoot = new URL("../src/", import.meta.url);
|
||||
|
||||
async function sourceFiles(relativeDirectory) {
|
||||
const directory = new URL(`${relativeDirectory}/`, sourceRoot);
|
||||
const entries = await readdir(directory, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
});
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && [".ts", ".tsx"].includes(extname(entry.name)))
|
||||
.map((entry) => join(entry.parentPath, entry.name));
|
||||
}
|
||||
|
||||
async function read(relativePath) {
|
||||
return readFile(new URL(relativePath, sourceRoot), "utf8");
|
||||
}
|
||||
|
||||
test("dependency direction keeps core and reusable components below workspaces", async () => {
|
||||
const coreFiles = await sourceFiles("core");
|
||||
const componentFiles = await sourceFiles("components");
|
||||
|
||||
for (const file of coreFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/from\s+["'][^"']*(?:workspaces|\/App)["']/,
|
||||
`${file} imports an application composition layer`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/@nodedc\/ui-(?:react|dom)/,
|
||||
`${file} imports a visual adapter from the domain layer`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of componentFiles) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/from\s+["'][^"']*(?:workspaces|\/App)["']/,
|
||||
`${file} imports a workspace composition layer`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("shared visual primitives come only from the Design Guideline packages", async () => {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(new URL("../package.json", import.meta.url), "utf8"),
|
||||
);
|
||||
for (const packageName of [
|
||||
"@nodedc/tokens",
|
||||
"@nodedc/ui-core",
|
||||
"@nodedc/ui-react",
|
||||
]) {
|
||||
assert.equal(typeof packageJson.dependencies[packageName], "string");
|
||||
}
|
||||
|
||||
const files = await sourceFiles(".");
|
||||
for (const file of files) {
|
||||
const source = await readFile(file, "utf8");
|
||||
assert.doesNotMatch(source, /lucide-react/);
|
||||
assert.doesNotMatch(source, /NODEDC_DESIGN_GUIDELINE\/(?:apps|src)\//);
|
||||
}
|
||||
});
|
||||
|
||||
test("laboratory UI is a bounded feature slice, not a central workspace branch", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const laboratory = await read(
|
||||
"workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const laboratoryCss = await read("styles/laboratory.css");
|
||||
const e30Review = await read("workspaces/E30ReviewWorkspace.tsx");
|
||||
const e30HumanReview = await read(
|
||||
"workspaces/E30HumanReviewPanel.tsx",
|
||||
);
|
||||
const e30HumanReviewCss = await read("styles/e30-human-review.css");
|
||||
|
||||
assert.doesNotMatch(
|
||||
workspaceHub,
|
||||
/fetchE(?:29|30)|LaboratoryWorkTemplate|function\s+E(?:29|30)LaboratoryResult/,
|
||||
);
|
||||
assert.match(
|
||||
workspaceHub,
|
||||
/<LaboratoryArchiveWorkspace[\s\S]*SpatialView=\{SpatialWorkspace\}/,
|
||||
);
|
||||
assert.match(laboratory, /export function LaboratoryArchiveWorkspace/);
|
||||
assert.match(laboratory, /type LaboratoryWorkspaceProps = WorkspaceRendererProps/);
|
||||
assert.doesNotMatch(workspaceCss, /\.(?:lab-|laboratory-|e30-)/);
|
||||
assert.match(laboratoryCss, /\.laboratory-work-template/);
|
||||
assert.match(laboratoryCss, /\.e30-review-workspace/);
|
||||
assert.doesNotMatch(laboratory, /E30HumanReviewPanel/);
|
||||
assert.match(e30Review, /<E30HumanReviewPanel/);
|
||||
assert.doesNotMatch(e30Review, /E30EngineeringAuditPanel/);
|
||||
assert.match(e30HumanReview, /from "@nodedc\/ui-react"/);
|
||||
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("central composition files cannot silently become monoliths again", async () => {
|
||||
const ratchets = [
|
||||
["App.tsx", 1_250],
|
||||
["workspaces/Workspaces.tsx", 1_200],
|
||||
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
|
||||
["styles/workspaces.css", 4_350],
|
||||
["styles/laboratory.css", 900],
|
||||
];
|
||||
|
||||
for (const [relativePath, maximumLines] of ratchets) {
|
||||
const lineCount = (await read(relativePath)).split("\n").length;
|
||||
assert.ok(
|
||||
lineCount <= maximumLines,
|
||||
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30EngineeringCatalog;
|
||||
let parseE30EngineeringDecision;
|
||||
let fetchE30EngineeringDecision;
|
||||
let E30EngineeringContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const generationId = `e30-engineering-generation-${"b".repeat(64)}`;
|
||||
const itemId = `e30-review-item-${"c".repeat(64)}`;
|
||||
|
||||
function generation(overrides = {}) {
|
||||
return {
|
||||
generation_id: generationId,
|
||||
created_at_utc: "2026-07-26T22:01:18.464Z",
|
||||
materialization_id: resultId,
|
||||
producer: {
|
||||
kind: "ai-assisted-engineering-review",
|
||||
producer_id: "codex:a3-engineering-review",
|
||||
method_id: "camera-lidar-42-sheet-audit/v1",
|
||||
review_sheet_identity_sha256: "d".repeat(64),
|
||||
claims_human_ground_truth: false,
|
||||
},
|
||||
summary: {
|
||||
item_count: 486,
|
||||
reviewed_item_count: 486,
|
||||
verdict_distribution: {
|
||||
confirmed: 401,
|
||||
corrected: 80,
|
||||
"insufficient-evidence": 5,
|
||||
},
|
||||
detector_distribution: { valid: 259, "false-positive": 78 },
|
||||
projection_distribution: { aligned: 371, "not-assessable": 115 },
|
||||
point_ownership_distribution: { object: 108 },
|
||||
human_exception_count: 5,
|
||||
mean_confidence: 0.8705,
|
||||
},
|
||||
cause_distribution: {
|
||||
schema_version: "missioncore.e30-engineering-causes/v1",
|
||||
item_count_with_cause: 312,
|
||||
reasons: [
|
||||
{ reason_code: "detector_error", count: 109 },
|
||||
{ reason_code: "time_mismatch", count: 91 },
|
||||
],
|
||||
},
|
||||
human_exceptions: [{
|
||||
item_id: itemId,
|
||||
review_key: "semantic:162:7",
|
||||
source_stratum: "camera-only",
|
||||
confidence: 0.48,
|
||||
review_prompt: {
|
||||
question: "Белый кластер — самостоятельное препятствие?",
|
||||
focus: "Проверьте выбранные белые точки.",
|
||||
effects: {
|
||||
"object-present": "Сохранить занятую геометрию.",
|
||||
"background-or-noise": "Исключить кластер.",
|
||||
"insufficient-evidence": "Оставить неизвестным.",
|
||||
},
|
||||
},
|
||||
}],
|
||||
ai_review_complete: true,
|
||||
human_exception_complete: false,
|
||||
human_review_complete: false,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-engineering-generations/v1",
|
||||
configured: true,
|
||||
items: [generation()],
|
||||
candidate_total: 1,
|
||||
invalid_total: 0,
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function decision(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-engineering-decision/v1",
|
||||
generation_id: generationId,
|
||||
decision: {
|
||||
sequence: 362,
|
||||
item_id: itemId,
|
||||
review_key: "semantic:343:2",
|
||||
source_stratum: "conflict",
|
||||
verdict: "corrected",
|
||||
effective_stratum: null,
|
||||
detector_assessment: "false-positive",
|
||||
projection_assessment: "aligned",
|
||||
point_ownership: "surface-or-background",
|
||||
cause_code: "detector_error",
|
||||
confidence: 0.95,
|
||||
human_exception_required: false,
|
||||
exception_reason: null,
|
||||
evidence_note: "Кадр 343. Рамка находится на ограждении.",
|
||||
review_sheet: {
|
||||
path: "conflict-01.jpg",
|
||||
sha256: "e".repeat(64),
|
||||
ordinal: 1,
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30EngineeringCatalog,
|
||||
parseE30EngineeringDecision,
|
||||
fetchE30EngineeringDecision,
|
||||
E30EngineeringContractError,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30Engineering.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes immutable A3 summary and per-item engineering decision", () => {
|
||||
const decodedCatalog = parseE30EngineeringCatalog(catalog());
|
||||
const decodedDecision = parseE30EngineeringDecision(decision());
|
||||
|
||||
assert.equal(decodedCatalog.items[0].summary.reviewedItemCount, 486);
|
||||
assert.equal(decodedCatalog.items[0].summary.humanExceptionCount, 5);
|
||||
assert.equal(decodedCatalog.items[0].humanExceptions[0].reviewKey, "semantic:162:7");
|
||||
assert.equal(
|
||||
decodedCatalog.items[0].humanExceptions[0].reviewPrompt.effects["object-present"],
|
||||
"Сохранить занятую геометрию.",
|
||||
);
|
||||
assert.equal(decodedDecision.verdict, "corrected");
|
||||
assert.equal(decodedDecision.effectiveStratum, null);
|
||||
assert.equal(decodedDecision.detectorAssessment, "false-positive");
|
||||
});
|
||||
|
||||
test("rejects a generation that claims human ground truth", () => {
|
||||
assert.throws(
|
||||
() => parseE30EngineeringCatalog(catalog({
|
||||
items: [generation({
|
||||
producer: {
|
||||
...generation().producer,
|
||||
claims_human_ground_truth: true,
|
||||
},
|
||||
})],
|
||||
})),
|
||||
E30EngineeringContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches one content-addressed item decision", async () => {
|
||||
const requests = [];
|
||||
const decoded = await fetchE30EngineeringDecision(
|
||||
resultId,
|
||||
generationId,
|
||||
itemId,
|
||||
{
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify(decision()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(decoded.pointOwnership, "surface-or-background");
|
||||
assert.deepEqual(requests, [{
|
||||
input:
|
||||
`/api/v1/laboratory/e30/reviews/${resultId}/engineering-generations/`
|
||||
+ `${generationId}/items/${itemId}`,
|
||||
method: "GET",
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30HumanReviewDraft;
|
||||
let parseE30HumanReviewFinalized;
|
||||
let createOrResumeE30HumanReview;
|
||||
let saveE30HumanReviewDecision;
|
||||
let finalizeE30HumanReview;
|
||||
let E30ReviewContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const engineeringGenerationId =
|
||||
`e30-engineering-generation-${"b".repeat(64)}`;
|
||||
const draftId = `e30-human-draft-${"c".repeat(64)}`;
|
||||
const reviewGenerationId = `e30-review-generation-${"d".repeat(64)}`;
|
||||
const firstItemId = `e30-review-item-${"e".repeat(64)}`;
|
||||
const secondItemId = `e30-review-item-${"f".repeat(64)}`;
|
||||
const firstEventId = `e30-review-event-${"1".repeat(64)}`;
|
||||
const secondEventId = `e30-review-event-${"2".repeat(64)}`;
|
||||
|
||||
function decision(overrides = {}) {
|
||||
return {
|
||||
item_id: firstItemId,
|
||||
source_stratum: "conflict",
|
||||
disposition: "object-present",
|
||||
notes: null,
|
||||
event_id: firstEventId,
|
||||
decided_at_utc: "2026-07-27T06:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function draft(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.e30-human-review-draft/v2",
|
||||
draft_id: draftId,
|
||||
materialization_id: resultId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
reviewer_id: "DC",
|
||||
created_at_utc: "2026-07-27T06:00:00Z",
|
||||
state: "active",
|
||||
revision: 1,
|
||||
item_count: 2,
|
||||
reviewed_item_count: 1,
|
||||
remaining_item_count: 1,
|
||||
disposition_distribution: { "object-present": 1 },
|
||||
generation_id: null,
|
||||
decisions: [decision()],
|
||||
lab_published: false,
|
||||
access: "review-write",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function finalized() {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-human-review-finalized/v2",
|
||||
draft: draft({
|
||||
state: "finalized",
|
||||
revision: 2,
|
||||
reviewed_item_count: 2,
|
||||
remaining_item_count: 0,
|
||||
disposition_distribution: {
|
||||
"object-present": 1,
|
||||
"background-or-noise": 1,
|
||||
},
|
||||
generation_id: reviewGenerationId,
|
||||
decisions: [
|
||||
decision(),
|
||||
decision({
|
||||
item_id: secondItemId,
|
||||
source_stratum: "unknown",
|
||||
disposition: "background-or-noise",
|
||||
event_id: secondEventId,
|
||||
}),
|
||||
],
|
||||
access: "read-only",
|
||||
}),
|
||||
generation: {
|
||||
schema_version: "missioncore.e30-human-review-generation/v2",
|
||||
generation_id: reviewGenerationId,
|
||||
materialization_id: resultId,
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
reviewer_id: "DC",
|
||||
created_at_utc: "2026-07-27T06:05:00Z",
|
||||
item_count: 2,
|
||||
disposition_distribution: {
|
||||
"object-present": 1,
|
||||
"background-or-noise": 1,
|
||||
},
|
||||
coverage: {
|
||||
expected_item_count: 2,
|
||||
reviewed_item_count: 2,
|
||||
complete: true,
|
||||
},
|
||||
human_review_complete: true,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30HumanReviewDraft,
|
||||
parseE30HumanReviewFinalized,
|
||||
createOrResumeE30HumanReview,
|
||||
saveE30HumanReviewDecision,
|
||||
finalizeE30HumanReview,
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30HumanReview.ts",
|
||||
));
|
||||
({ E30ReviewContractError } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/e30Review.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes an exception-only draft and immutable final generation", () => {
|
||||
const active = parseE30HumanReviewDraft(draft());
|
||||
const complete = parseE30HumanReviewFinalized(finalized());
|
||||
|
||||
assert.equal(active.engineeringGenerationId, engineeringGenerationId);
|
||||
assert.equal(active.reviewedItemCount, 1);
|
||||
assert.equal(active.decisions[0].disposition, "object-present");
|
||||
assert.equal(complete.draft.state, "finalized");
|
||||
assert.equal(complete.generation.humanReviewComplete, true);
|
||||
assert.equal(complete.generation.labPublished, false);
|
||||
});
|
||||
|
||||
test("rejects unknown dispositions and finalized writable access", () => {
|
||||
assert.throws(
|
||||
() => parseE30HumanReviewDraft(draft({
|
||||
decisions: [decision({ disposition: "invented" })],
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseE30HumanReviewDraft(draft({
|
||||
state: "finalized",
|
||||
access: "review-write",
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses generation-bound write endpoints and explicit finalization", async () => {
|
||||
const requests = [];
|
||||
const fetcher = async (input, init) => {
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
requests.push({ input: String(input), method: init?.method, body });
|
||||
const payload = String(input).includes("/finalize?")
|
||||
? finalized()
|
||||
: draft();
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
await createOrResumeE30HumanReview(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
"DC",
|
||||
{ fetcher },
|
||||
);
|
||||
await saveE30HumanReviewDecision(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
draftId,
|
||||
firstItemId,
|
||||
{
|
||||
expectedRevision: 1,
|
||||
idempotencyKey: "ui-decision-001",
|
||||
disposition: "insufficient-evidence",
|
||||
notes: null,
|
||||
},
|
||||
{ fetcher },
|
||||
);
|
||||
await finalizeE30HumanReview(
|
||||
resultId,
|
||||
engineeringGenerationId,
|
||||
draftId,
|
||||
2,
|
||||
{ fetcher },
|
||||
);
|
||||
|
||||
assert.equal(requests[0].method, "POST");
|
||||
assert.deepEqual(requests[0].body, {
|
||||
reviewer_id: "DC",
|
||||
engineering_generation_id: engineeringGenerationId,
|
||||
});
|
||||
assert.match(requests[1].input, /engineering_generation_id=/);
|
||||
assert.equal(requests[1].body.disposition, "insufficient-evidence");
|
||||
assert.equal(requests[2].body.confirm_generation, true);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let parseE30ReviewCatalog;
|
||||
let parseE30ReviewItems;
|
||||
let parseE30ReviewItemDetail;
|
||||
let fetchE30ReviewItems;
|
||||
let E30ReviewContractError;
|
||||
|
||||
const resultId = `e30-materialization-${"a".repeat(64)}`;
|
||||
const reviewPackId = `e30-review-pack-${"b".repeat(64)}`;
|
||||
const e29ResultId = `e29-camera-geometry-${"c".repeat(64)}`;
|
||||
const itemId = `e30-review-item-${"d".repeat(64)}`;
|
||||
|
||||
function result(overrides = {}) {
|
||||
return {
|
||||
result_id: resultId,
|
||||
created_at_utc: "2026-07-26T00:00:00Z",
|
||||
review_pack_id: reviewPackId,
|
||||
e29_result_id: e29ResultId,
|
||||
source_session_id: "20260720T065719Z_viewer_live",
|
||||
item_count: 486,
|
||||
stratum_counts: {
|
||||
conflict: 38,
|
||||
agree: 96,
|
||||
"camera-only": 128,
|
||||
unknown: 96,
|
||||
"geometry-only": 128,
|
||||
},
|
||||
reason_taxonomy: ["time_mismatch", "geometry_mismatch", "other"],
|
||||
projection: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
source_id: "sensor.camera.right",
|
||||
calibration_slot: "camera_1",
|
||||
},
|
||||
camera_evidence_available: true,
|
||||
human_review_complete: false,
|
||||
lab_published: false,
|
||||
access: "read-only",
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function item(overrides = {}) {
|
||||
return {
|
||||
item_id: itemId,
|
||||
sequence: 212,
|
||||
review_key: "semantic:1800:1",
|
||||
stratum: "conflict",
|
||||
range_bucket: "unavailable",
|
||||
frame_index: 1800,
|
||||
source_frame_index: 1800,
|
||||
session_seconds: 215.323857292,
|
||||
locator: {
|
||||
kind: "semantic-observation",
|
||||
observation_index: 1,
|
||||
},
|
||||
snapshot: {
|
||||
label: "car",
|
||||
geometry_status: "conflict",
|
||||
geometry_reason: "camera-object-region-observed-as-local-surface",
|
||||
range_m: null,
|
||||
bbox_xyxy: [223.6, 270.2, 282.4, 305.2],
|
||||
},
|
||||
materialization: {
|
||||
frame_point_count: 1813,
|
||||
projected_point_count: 3,
|
||||
candidate_point_count: 2,
|
||||
selected_point_count: 1,
|
||||
rejected_candidate_point_count: 1,
|
||||
selected_and_candidate_lossless: true,
|
||||
free_space_valid: false,
|
||||
human_review_complete: false,
|
||||
detector_score: 0.83,
|
||||
},
|
||||
camera_frame_available: true,
|
||||
engineering_triage: {
|
||||
provenance: "deterministic-evidence-readiness/v1",
|
||||
state: "ready-for-ai-review",
|
||||
attention: "standard",
|
||||
signals: [],
|
||||
semantic_verdict: null,
|
||||
human_exception_required: null,
|
||||
},
|
||||
review: {
|
||||
state: "unreviewed",
|
||||
reason_code: null,
|
||||
notes: null,
|
||||
},
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-catalog/v1",
|
||||
configured: true,
|
||||
items: [result()],
|
||||
candidate_total: 1,
|
||||
invalid_total: 0,
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function items(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-items/v1",
|
||||
result_id: resultId,
|
||||
stratum: "conflict",
|
||||
items: [item()],
|
||||
total: 38,
|
||||
next_cursor: null,
|
||||
reason_taxonomy: ["time_mismatch", "geometry_mismatch", "other"],
|
||||
access: "read-only",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function detail(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.laboratory-e30-item-detail/v1",
|
||||
result_id: resultId,
|
||||
item: {
|
||||
...item(),
|
||||
camera_frame: {
|
||||
available: true,
|
||||
url: `/api/v1/laboratory/e30/reviews/${resultId}/items/${itemId}/camera-frame?generation=${"e".repeat(64)}`,
|
||||
sha256: "e".repeat(64),
|
||||
width: 800,
|
||||
height: 600,
|
||||
source_frame_index: 1800,
|
||||
exact_source_frame: true,
|
||||
},
|
||||
selected: {
|
||||
source_indices: [7],
|
||||
points_map_xyz_m: [[1, 2, 3]],
|
||||
},
|
||||
candidate: {
|
||||
source_indices: [7, 8],
|
||||
points_map_xyz_m: [[1, 2, 3], [1.1, 2.1, 3.1]],
|
||||
},
|
||||
projection: {
|
||||
source_indices: [7, 8, 9],
|
||||
points_map_xyz_m: [[1, 2, 3], [1.1, 2.1, 3.1], [2, 3, 4]],
|
||||
pixels_xy: [[220, 270], [230, 275], [500, 400]],
|
||||
depth_m: [3, 3.1, 4],
|
||||
point_class: [2, 1, 0],
|
||||
point_height_m: [0.8, 0.1, 0],
|
||||
candidate_mask: [1, 1, 0],
|
||||
selected_mask: [1, 0, 0],
|
||||
},
|
||||
pose: {
|
||||
position_map_xyz_m: [10, 11, 0],
|
||||
orientation_map_from_lidar_xyzw: [0, 0, 0, 1],
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
access: "read-only",
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
parseE30ReviewCatalog,
|
||||
parseE30ReviewItems,
|
||||
parseE30ReviewItemDetail,
|
||||
fetchE30ReviewItems,
|
||||
E30ReviewContractError,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/e30Review.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("decodes immutable E30 catalog, strata and exact point evidence", () => {
|
||||
const decodedCatalog = parseE30ReviewCatalog(catalog());
|
||||
const decodedItems = parseE30ReviewItems(items());
|
||||
const decodedDetail = parseE30ReviewItemDetail(detail());
|
||||
|
||||
assert.equal(decodedCatalog.items[0].stratumCounts.conflict, 38);
|
||||
assert.equal(decodedCatalog.items[0].humanReviewComplete, false);
|
||||
assert.equal(decodedItems.items[0].sessionSeconds, 215.323857292);
|
||||
assert.deepEqual(decodedDetail.selected.sourceIndices, [7]);
|
||||
assert.deepEqual(decodedDetail.projection.selectedMask, [1, 0, 0]);
|
||||
assert.equal(decodedDetail.cameraFrame.exactSourceFrame, true);
|
||||
});
|
||||
|
||||
test("rejects authority escalation and inconsistent materialized arrays", () => {
|
||||
assert.throws(
|
||||
() => parseE30ReviewCatalog(catalog({
|
||||
items: [result({
|
||||
authority: {
|
||||
commands_enabled: true,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
})],
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseE30ReviewItemDetail(detail({
|
||||
selected: {
|
||||
source_indices: [7, 8],
|
||||
points_map_xyz_m: [[1, 2, 3]],
|
||||
},
|
||||
})),
|
||||
E30ReviewContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetches the canonical read-only stratum endpoint", async () => {
|
||||
const requests = [];
|
||||
const decoded = await fetchE30ReviewItems(resultId, "conflict", {
|
||||
fetcher: async (input, init) => {
|
||||
requests.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify(items()), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(decoded.total, 38);
|
||||
assert.deepEqual(requests, [{
|
||||
input: `/api/v1/laboratory/e30/reviews/${resultId}/items?stratum=conflict&limit=128&cursor=0`,
|
||||
method: "GET",
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { test } from "node:test";
|
||||
|
||||
const workspaceSourceUrl = new URL(
|
||||
"../src/workspaces/E30ReviewWorkspace.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const presentationSourceUrl = new URL(
|
||||
"../src/components/laboratory/LaboratoryPresentation.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const viewerSourceUrl = new URL(
|
||||
"../src/components/laboratory/LaboratoryEvidenceViewer.tsx",
|
||||
import.meta.url,
|
||||
);
|
||||
const laboratoryStylesUrl = new URL(
|
||||
"../src/styles/laboratory.css",
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test("E30 uses one reusable evidence viewer with camera, 3D and expand controls", async () => {
|
||||
const [workspaceSource, viewerSource, laboratoryStyles] = await Promise.all([
|
||||
readFile(workspaceSourceUrl, "utf8"),
|
||||
readFile(viewerSourceUrl, "utf8"),
|
||||
readFile(laboratoryStylesUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(workspaceSource, /<LaboratoryEvidenceViewer/);
|
||||
assert.match(workspaceSource, /\{ value: "3d", label: "3D" \}/);
|
||||
assert.match(workspaceSource, /\{ value: "camera", label: "Камера" \}/);
|
||||
assert.match(workspaceSource, /<E30EvidenceProjection/);
|
||||
assert.match(workspaceSource, /<E30EvidenceTelemetry/);
|
||||
assert.match(workspaceSource, /aria-pressed=\{pointLayerVisible\}/);
|
||||
assert.match(workspaceSource, />\s*LiDAR\s*<\/Button>/);
|
||||
assert.match(workspaceSource, /review: "Проверка"/);
|
||||
assert.doesNotMatch(workspaceSource, /e30-review-evidence__facts/);
|
||||
assert.doesNotMatch(workspaceSource, /E30EngineeringAuditPanel/);
|
||||
assert.match(viewerSource, /createPortal/);
|
||||
assert.match(viewerSource, /name=\{expanded \? "minimize" : "expand"\}/);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/\.e30-evidence-telemetry dl\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/s,
|
||||
);
|
||||
assert.match(
|
||||
laboratoryStyles,
|
||||
/\.e30-projection-scene canvas\s*\{[^}]*object-fit:\s*cover/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("E30 exception review is a separate block after the fixed evidence workspace", async () => {
|
||||
const workspaceSource = await readFile(workspaceSourceUrl, "utf8");
|
||||
const bodyStart = workspaceSource.indexOf(
|
||||
'<div className="e30-review-workspace__body">',
|
||||
);
|
||||
const detailClose = workspaceSource.indexOf(
|
||||
"</div>\n\n {!detailLoading",
|
||||
bodyStart,
|
||||
);
|
||||
const reviewPanel = workspaceSource.indexOf(
|
||||
"<E30HumanReviewPanel",
|
||||
bodyStart,
|
||||
);
|
||||
|
||||
assert.ok(bodyStart >= 0);
|
||||
assert.ok(detailClose > bodyStart);
|
||||
assert.ok(reviewPanel > detailClose);
|
||||
});
|
||||
|
||||
test("LAB product surface has a compact canonical summary and no roadmap footer", async () => {
|
||||
const [workspaceSource, presentationSource] = await Promise.all([
|
||||
readFile(workspaceSourceUrl, "utf8"),
|
||||
readFile(presentationSourceUrl, "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(presentationSource, /export function LaboratorySummary/);
|
||||
assert.match(presentationSource, /export function LaboratoryWorkTemplate/);
|
||||
assert.doesNotMatch(workspaceSource, /СЛЕДУЮЩИЙ GATE/);
|
||||
assert.doesNotMatch(workspaceSource, /e30-review-workspace__taxonomy/);
|
||||
});
|
||||
@@ -178,23 +178,30 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const laboratorySource = await readFile(
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const productSource = await readFile(
|
||||
new URL("../src/productModel.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const recordingsSource = workspaceSource.slice(
|
||||
workspaceSource.indexOf("function RecordingsWorkspace"),
|
||||
workspaceSource.indexOf("type LaboratoryProfileId"),
|
||||
workspaceSource.indexOf("export function WorkspaceRenderer"),
|
||||
);
|
||||
|
||||
assert.match(appSource, /activeDefinition\.kind === "recordings"[\s\S]*<ObservationSessionSelect/);
|
||||
assert.match(recordingsSource, /<SpatialWorkspace \{\.\.\.props\} \/>/);
|
||||
assert.doesNotMatch(recordingsSource, /ObservationSessionArchive/);
|
||||
assert.match(productSource, /label: "Лабораторные контуры"/);
|
||||
assert.match(workspaceSource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
|
||||
assert.match(workspaceSource, /ЛАБОРАТОРНАЯ РАБОТА/);
|
||||
assert.match(workspaceSource, /e28-local-surface/);
|
||||
assert.match(workspaceSource, /e29-camera-geometry/);
|
||||
assert.match(laboratorySource, /ПРОФИЛЬ ЛАБОРАТОРНОГО КОНТУРА/);
|
||||
assert.match(laboratorySource, /ЛАБОРАТОРНАЯ РАБОТА/);
|
||||
assert.match(laboratorySource, /e28-local-surface/);
|
||||
assert.match(laboratorySource, /e29-camera-geometry/);
|
||||
});
|
||||
|
||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||
|
||||
@@ -31,11 +31,21 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
|
||||
test("every laboratory result uses the shared evidence template", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL(
|
||||
"../src/workspaces/laboratory/LaboratoryArchiveWorkspace.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const presentation = await readFile(
|
||||
new URL(
|
||||
"../src/components/laboratory/LaboratoryPresentation.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const css = await readFile(
|
||||
new URL("../src/styles/workspaces.css", import.meta.url),
|
||||
new URL("../src/styles/laboratory.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const app = await readFile(
|
||||
@@ -43,11 +53,14 @@ test("every laboratory result uses the shared evidence template", async () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /function LaboratoryWorkTemplate\(/);
|
||||
assert.match(source, /function LaboratoryEvidence\(/);
|
||||
assert.match(source, /function LaboratoryMethodCard\(/);
|
||||
assert.match(source, /method:\s*ReactNode/);
|
||||
assert.match(source, /data-evidence-kind=\{kind\}/);
|
||||
assert.match(source, /LaboratoryWorkTemplate,/);
|
||||
assert.match(source, /LaboratorySummary,/);
|
||||
assert.match(presentation, /export function LaboratoryWorkTemplate\(/);
|
||||
assert.match(presentation, /export function LaboratoryEvidence\(/);
|
||||
assert.match(presentation, /export function LaboratorySummary\(/);
|
||||
assert.match(presentation, /summary:\s*ReactNode/);
|
||||
assert.match(presentation, /data-evidence-kind=\{kind\}/);
|
||||
assert.doesNotMatch(source, /function LaboratoryWorkTemplate\(/);
|
||||
assert.match(source, /data-viewer-focused=/);
|
||||
assert.match(css, /height:\s*clamp\(42rem,\s*68vh,\s*58rem\)/);
|
||||
assert.match(css, /resize:\s*vertical/);
|
||||
|
||||
@@ -634,7 +634,7 @@ with a complete `missioncore.laboratory-method/v1` manifest.
|
||||
|
||||
| LAB | Question and admitted input | Required evidence and measures | Exit decision |
|
||||
| --- | --- | --- | --- |
|
||||
| **E30 — disagreement audit** | Why did E29 produce 38 conflicts, 13,246 camera-only observations and 21,321 unassociated occupied components? Input is the immutable E29 result plus its exact RAVNOVES00 camera/LiDAR/pose evidence. | Review all conflict episodes and a stratified sample of agree, camera-only, unknown and geometry-only cases. Each reviewed item keeps synchronized camera, point cloud, source time and classification into evidenced causes such as FOV, occlusion, time binding, projection/calibration, sparse return, local-surface error, association error or self/rig geometry. | Freeze a cause distribution and the minimum correction set. Do not tune thresholds before this audit. |
|
||||
| **E30 — disagreement audit** | Why did E29 produce 38 conflicts, 13,246 camera-only observations and 21,321 unassociated occupied components? Input is the immutable E29 result plus its exact RAVNOVES00 camera/LiDAR/pose evidence. | Run an AI-assisted engineering audit over the stratified immutable set using exact camera + projected LiDAR as the primary evidence and synchronized map-frame 3D as the secondary check. Each item records detector validity, projection alignment, point ownership, confidence and evidenced cause. Route only ambiguous or high-impact cases to human exception review; never label the AI generation as human ground truth. | Freeze a cause distribution and the minimum correction set after exceptions are resolved. Do not tune thresholds before this audit. |
|
||||
| **E31 — time, transform and self-mask qualification** | Are camera↔LiDAR binding, factory calibration identity, pose age and rig footprint sufficient for E29? | Pin the read-only calibration/profile identities; measure timestamp/pose-age distributions, projection residuals where corresponding evidence exists, offset sensitivity and the effect of a source-defined vehicle/self mask. No hard-coded handheld sensor height and no inferred raw firing time. | Accept one evidenced binding/profile or fail closed and retain diagnostic-only E29. |
|
||||
| **E32 — camera-first geometry v2** | Does the accepted E31 profile improve consistency without erasing occupied evidence? | Re-run the full 4,489-frame source with the exact E31 profile. Compare E29 and E32 by class, distance, scene interval and cause bucket: agree, camera-only, conflict, unknown, geometry-only, occupied support and excluded samples. Show before/after evidence for every changed conflict episode. | Accept the profile only if conflict/staleness improves without converting unknown or unsupported space to free and without hidden source loss. |
|
||||
| **E33 — recorded-source-paced fusion shadow** | Can the complete camera-first + geometry pipeline keep up with the recorded source stream on the worker? | Replay E32 at original host pace through bounded latest-wins queues. Record sensor-to-result p50/p95/max, deadline misses, queue depth, replacements, drops, stale results, recovery, CPU, RSS, GPU/VRAM, runtime/container and exact model-weight identities. Commands remain impossible. | Admit a replay-shadow candidate only when accounting is closed, resource bounds are declared and every late/missing result becomes explicit health state. |
|
||||
@@ -642,6 +642,23 @@ with a complete `missioncore.laboratory-method/v1` manifest.
|
||||
| **E35 — deterministic degradation and recovery** | Does the accepted pipeline fail safely when one source degrades? | Derive immutable replay variants for camera loss, LiDAR loss, pose staleness, delayed frames, bounded drops and timing offset. Preserve the original source and record every injected transformation. Measure status transitions, recovery time, queue accounting and false confidence. | Pass only if missing evidence becomes camera-only, geometry-only, stale or unknown; never guessed class, false free space or hidden success. |
|
||||
| **E36 — second-source transfer gate** | Does the result survive another real capture rather than only RAVNOVES00? | First audit the source catalog for a second recording with the required camera, LiDAR, pose, time and calibration identities. Run the accepted E32–E35 profile unchanged when such evidence exists. Public GOOSE/RELLIS data may qualify an algorithm but cannot substitute for this K1 transfer gate. | Compare the frozen profile without retuning. If no eligible source exists, keep the gate blocked only in the roadmap/Ops plan: do not publish an empty LAB page, fabricate a result or collect new data by default. |
|
||||
|
||||
The current E30 AI-assisted engineering generation is
|
||||
`e30-engineering-generation-62a4fea10dea9b77f69ceac1af5bf0e4928d9c7716083c22258a03670fe5bd4f`.
|
||||
It covers all `486` selected items: `403` confirmed, `81` corrected and `2`
|
||||
retained as `insufficient-evidence` human exceptions. The earlier five-case
|
||||
generation and its human draft remain immutable history. Frames 1213, 162 and
|
||||
1823 are now automatic engineering outcomes; only geometry frames 2622 and
|
||||
4147 require a human decision. It found no systematic camera↔LiDAR
|
||||
registration failure in the review set. The dominant actionable signals are
|
||||
detector errors (`110`, including barrier false positives and 21 missed
|
||||
class-bearing objects), sparse occupied support (`98`), held-track time
|
||||
freshness (`92`) and self points (`10`). This is diagnostic engineering
|
||||
evidence, not human ground truth, detection accuracy or safety acceptance. The
|
||||
exception budget is 1% only for diagnostic residual work and never overrides a
|
||||
repeated-cause or high-impact blocker. The current queue is `2 / 486` (`0.41%`);
|
||||
E31 remains blocked until those two decisions freeze the E30 minimum correction
|
||||
set.
|
||||
|
||||
Execution is strictly sequential through E33: E30 determines what E31 is
|
||||
allowed to change; E31 determines the E32 profile; E32 determines the E33
|
||||
runtime input. E34 and E35 may proceed only after E33 closes exact accounting.
|
||||
|
||||
@@ -125,7 +125,11 @@ The result must explicitly state:
|
||||
There are not two unrelated laboratory-page templates. Mission Core has one
|
||||
`LaboratoryWorkTemplate` with fixed slots:
|
||||
|
||||
`task → method → evidence → result → details`.
|
||||
`compact summary → evidence → result → optional reusable details`.
|
||||
|
||||
Catalog profile/work selectors precede the template. The compact summary
|
||||
projects the task, method, principal result, limitation, and retained authority
|
||||
from typed LAB data; it does not reproduce the complete engineering report.
|
||||
|
||||
The evidence slot has two admitted renderers:
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# Architecture audit execution roadmap
|
||||
|
||||
Date: 2026-07-26
|
||||
Status: active
|
||||
|
||||
This roadmap turns the architecture audit into bounded implementation and LAB
|
||||
gates. It does not supersede immutable source/LAB policy, the accepted E29
|
||||
generation, the Polygon SRS or any authority boundary.
|
||||
|
||||
## Non-negotiable invariants
|
||||
|
||||
- K1 `lio_pcl` is a registered post-LIO map increment, not a native scan.
|
||||
- Missing K1 endpoints are not free-space evidence.
|
||||
- Source, LAB derivative, result and acceptance decision remain separate.
|
||||
- Camera owns semantic class and track identity; geometry may confirm or
|
||||
disagree but does not invent semantics.
|
||||
- Current, held and persistent geometry are separate products.
|
||||
- Worker and simulation compatibility never grant command or safety authority.
|
||||
- ROS 2 remains an execution/provider bus; Mission Core owns identity, policy,
|
||||
evidence and operator history.
|
||||
|
||||
## Workstream A — camera-first geometry qualification
|
||||
|
||||
| Phase | Deliverable | Depends on | Exit |
|
||||
| --- | --- | --- | --- |
|
||||
| A0 — freeze baseline | Preserve E29 and its exact source/method/result identity | Accepted E29 | No mutation, threshold tuning or replacement of the accepted generation |
|
||||
| A1 — representation admission | ADR 0023, `SensorRepresentationCapabilities v1`, strict algorithm requirements and binary admission | ADRs 0018/0020/0021 | K1 admits endpoint marking and rejects projective free-space mapping in tests |
|
||||
| A2 — E30 review substrate | Review-pack schema, stratified selector and fixed reason taxonomy covering conflict, agree, camera-only, unknown and geometry-only strata | A0–A1 | Every selected item binds native camera, projected/current/rejected points, times, pose and profile identities |
|
||||
| A3 — E30 engineering audit | Immutable AI-assisted engineering generation, cause distribution and bounded human-exception queue | A2 | Every item has an evidenced verdict or `insufficient-evidence`; ambiguous/high-impact exceptions are resolved; no algorithm tuning before the decision |
|
||||
| A4 — E31 source qualification | Source-time audit, offset sweep, calibration residual and versioned mount/self-mask profile | A3 | One evidenced binding/profile is accepted or the gate fails closed |
|
||||
| A5 — TrackGeometry contract | `TrackGeometry v1`, PointSlab, source-index ownership, current/held separation and explicit camera-only/conflict/unknown states | A1 and the E31 profile shape | Contract round-trip, no double ownership, no fake range/class, all rejection reasons retained |
|
||||
| A6 — E32 full replay | Camera-first geometry v2 over all 4,489 source frames | A4–A5 | E29/E32 comparison by cause, class, range and scene; no hidden source loss or false free space |
|
||||
| A7 — E33 worker shadow | Persistent recorded-source-paced execution with channel-specific deadlines and resource telemetry | A6 | Closed queue/drop/recovery accounting; diagnostic/shadow only |
|
||||
| A8 — E34/E35 local model | Short-TTL occupied/unknown layers and deterministic degradation suite | A7 | Explicit current/held/unknown aging and safe degradation; planner authority remains false |
|
||||
| A9 — E36 transfer | Frozen-profile replay on a mounted second real source | A8 and eligible source | Generalization decision without retuning; blocked if no eligible source exists |
|
||||
|
||||
The strict critical path is:
|
||||
|
||||
```text
|
||||
A0 → A1 → A2 → A3 → A4 → A5 → A6 → A7 → A8 → A9
|
||||
```
|
||||
|
||||
A5 contract design may start before E31 closes, but the accepted E31
|
||||
time/calibration/mount identities must be bindable before E32 publication.
|
||||
|
||||
## Workstream B — Simulation Polygon
|
||||
|
||||
This workstream may proceed in parallel but cannot bypass Workstream A or grant
|
||||
real authority.
|
||||
|
||||
| Phase | Deliverable | Exit |
|
||||
| --- | --- | --- |
|
||||
| B1 — finish S1 | Full run lifecycle, canonical `VehicleState`, frame golden tests, Ackermann/Differential mapping, sequence/TTL/heartbeat, watchdog/failsafe, provider loss, restart reconciliation and repeatability report | Deterministic virtual-only provider run with clean stop and zero orphan processes |
|
||||
| B2 — S2 trusted navigation | Synthetic trusted map → Nav2 → canonical command → PX4 rover adapter → Gazebo | No unknown traversal, footprint collision checking, command expiry/provider-loss stop and repeatable route completion |
|
||||
| B3 — Virtual K1 | Isaac Sim native scan plus separately degraded K1-like mapped derivative with point/object ground truth | Quantified representation-loss and TrackGeometry quality |
|
||||
| B4 — unified replay UX | RAVNOVES replay represented inside Polygon | Evidence/replay only; never labelled closed loop |
|
||||
| B5 — controller/HIL | Controller-in-loop, HIL or physical shadow | Only after S1, S2, E33, E35, Edge Agent and independent safety review |
|
||||
|
||||
## Workstream C — field transfer
|
||||
|
||||
1. Define a local Edge Agent boundary with local perception minimum, command
|
||||
TTL, watchdog, failsafe and local evidence.
|
||||
2. Bind capability profiles to physical unit, firmware family and mount profile.
|
||||
3. Add authenticated digest-bound source fetch, resumable transfer, job leases,
|
||||
atomic result publication and restart reconciliation.
|
||||
4. Add MCAP only as a normalized P1/P2 sidecar; retain MQTT/fMP4 P0 evidence.
|
||||
5. Require an independent safety gate before any real actuator authority.
|
||||
|
||||
## Current implementation slice
|
||||
|
||||
- [x] Preserve E29 as the accepted immutable diagnostic baseline.
|
||||
- [x] Add ADR 0023.
|
||||
- [x] Add `missioncore.sensor-representation-capabilities/v1`.
|
||||
- [x] Add strict `missioncore.sensor-algorithm-requirements/v1`.
|
||||
- [x] Encode the K1 `lio_pcl` registered-map-increment capability profile.
|
||||
- [x] Prove that endpoint marking is compatible while projective free-space
|
||||
mapping rejects.
|
||||
- [x] Implement the first A2 slice: E30 review-pack schema, deterministic
|
||||
stratified selector, immutable E29 evidence bindings and reason taxonomy.
|
||||
- [x] Materialize source-bound projected/current/rejected point views for all
|
||||
486 selected E30 items, retaining lossless frame-local source indices for
|
||||
selected and candidate points.
|
||||
- [x] Add a fail-closed read-only E30 API that verifies the materialization,
|
||||
linked review pack, index and per-item NPZ hashes before publication.
|
||||
- [x] Add the evidence surface over the immutable selection pack with five
|
||||
strata, exact camera frame, KB4 LiDAR projection, point ownership and
|
||||
synchronized map-frame 3D.
|
||||
- [x] Harden the Control Station application boundary before A3: consume the
|
||||
Design Guideline packages as the only visual platform, isolate the LAB
|
||||
feature and CSS, add typed workspace contracts and enforce one-way imports
|
||||
plus composition-size ratchets.
|
||||
- [x] Validate the real A2 generation:
|
||||
`e30-review-pack-faec915a771022cceaf4ee62bece698afc8018d09b6b0ac7602157216fbb3686`
|
||||
→
|
||||
`e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a`.
|
||||
Counts are 38 conflict, 96 agree, 128 camera-only, 96 unknown and 128
|
||||
geometry-only. It contains 486 item bindings and 465 deduplicated exact
|
||||
camera-frame artifacts; `human_review_complete=false` and
|
||||
`lab_published=false`.
|
||||
- [x] Implement the A3 collection and finalization boundary: a resumable
|
||||
append-only reviewer draft with revision/idempotency checks, explicit
|
||||
reviewer identity and server timestamps, one frozen-taxonomy reason for
|
||||
every changed stratum, full-coverage finalization and a content-addressed
|
||||
immutable reviewer generation with cause distribution.
|
||||
- [x] Replace the owner-facing manual-review form with an isolated A3
|
||||
engineering-audit surface using only admitted Design Guideline controls.
|
||||
Camera + projected LiDAR is primary, map-frame 3D is secondary, and the
|
||||
deterministic readiness triage explicitly carries no semantic verdict.
|
||||
- [x] Issue the first AI-assisted engineering generation over all 486 immutable
|
||||
items with explicit producer/method/confidence, three component questions
|
||||
(detector validity, projection alignment, point ownership) and verdict
|
||||
`confirmed`, `corrected` or `insufficient-evidence`. The immutable generation
|
||||
is
|
||||
`e30-engineering-generation-24f7d73393e1b018596a97813cecac07334d0ce678e9ba73bc5b85e49b2096a3`.
|
||||
It records 401 confirmed, 80 corrected and five `insufficient-evidence`
|
||||
decisions with mean confidence `0.8705`. The evidenced cause distribution is
|
||||
109 detector errors, ten self-point cases, 97 sparse-support cases, 91
|
||||
time-freshness mismatches and five unresolved ambiguities.
|
||||
- [x] Issue the corrected A3 generation
|
||||
`e30-engineering-generation-62a4fea10dea9b77f69ceac1af5bf0e4928d9c7716083c22258a03670fe5bd4f`
|
||||
without rewriting the first generation or its human draft. It records 403
|
||||
confirmed, 81 corrected and two `insufficient-evidence` decisions with mean
|
||||
confidence `0.8730`. Frames 1213, 162 and 1823 are now explicit automatic
|
||||
engineering outcomes; only geometry frames 2622 and 4147 remain in the
|
||||
human queue.
|
||||
- [ ] Resolve only the resulting ambiguous/high-impact human-exception queue
|
||||
and freeze the minimum correction set and cause distribution.
|
||||
|
||||
The current A3 AI-assisted engineering generation covers all 486 items and
|
||||
retains the exact A2 materialization/review-pack and 42-sheet evidence
|
||||
identities. It explicitly claims neither human ground truth nor navigation or
|
||||
safety acceptance and retains `lab_published=false`. The dominant conflict
|
||||
cause is detector placement on striped road/construction barriers rather than
|
||||
camera↔LiDAR registration; the `unknown` stratum is dominated by held
|
||||
world-track time freshness; geometry-only contains both expected static scene
|
||||
geometry and 21 visible missed class-bearing objects.
|
||||
|
||||
A3 is not complete until the two routed human exceptions are resolved and the
|
||||
minimum correction set is frozen. Earlier generations and drafts remain
|
||||
historical and are not rewritten or presented as the current product workflow.
|
||||
A4 remains blocked. No perception threshold changes are allowed before the
|
||||
exception decision is frozen.
|
||||
|
||||
### A3 residual and human-exception policy
|
||||
|
||||
- Every selected item is accounted for by an automatic evidenced decision or
|
||||
an explicit human exception; silent drops are forbidden.
|
||||
- Human review is limited to genuine ambiguity or a high-impact decision. An
|
||||
obvious detector, freshness or support outcome remains an engineering
|
||||
decision even when the result is negative.
|
||||
- For a diagnostic LAB, unresolved `insufficient-evidence` may occupy at most
|
||||
1% of the immutable stratified set. This is a residual-work budget, not an
|
||||
accuracy, navigation or safety acceptance threshold.
|
||||
- A repeated cause cluster or a high-impact case blocks the gate regardless of
|
||||
percentage. The 1% budget cannot hide a systematic defect.
|
||||
- The current queue is 2 / 486 (`0.41%`) and therefore bounded, but A3 still
|
||||
requires those two recorded decisions before its correction set is frozen.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Mission Core product UI and laboratory presentation canon
|
||||
|
||||
Status: accepted by product owner, 2026-07-26
|
||||
|
||||
## Purpose
|
||||
|
||||
Mission Core is an engineering product, not a visual notebook. Experimental
|
||||
work may change algorithms, evidence, and conclusions without creating a new
|
||||
GUI for every LAB run. The product surface remains compact, readable,
|
||||
predictable, and based on the approved NODE.DC Design Guideline.
|
||||
|
||||
This document governs Control Station product UI, laboratory summaries,
|
||||
evidence viewers, and the boundary with complete engineering reports in Ops.
|
||||
It complements `docs/15_LABORATORY_RUN_CANON.md`, which governs publication and
|
||||
provenance. New non-LAB surfaces and their placement are governed by
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||
|
||||
Consistency means one visual and interaction grammar, not one universal page
|
||||
layout. Different operator jobs may use different task-specific compositions
|
||||
while retaining the canonical shell, controls, states, icons, and code
|
||||
boundaries.
|
||||
|
||||
## Design source of truth
|
||||
|
||||
The sibling `NODEDC_DESIGN_GUIDELINE` repository is the only source of visual
|
||||
components, states, geometry, icons, themes, and page patterns.
|
||||
|
||||
Before UI implementation:
|
||||
|
||||
1. read `registry/registry.json`;
|
||||
2. resolve the required component in `registry/components.json`;
|
||||
3. resolve every icon in `registry/icons.json`;
|
||||
4. read the relevant component and governance documentation;
|
||||
5. consume the package export rather than reproducing it locally.
|
||||
|
||||
Application CSS may arrange domain content and size a visualization viewport.
|
||||
It may not redefine canonical button, segmented, select, glass, focus, hover,
|
||||
active, disabled, window, dropdown, or icon behavior.
|
||||
|
||||
If no canonical visual entity satisfies the requirement, implementation stops
|
||||
for product-owner approval. An approved generic entity is added to the Design
|
||||
Guideline with registry entry, API, interaction states, documentation, catalog
|
||||
example, and validation before Mission Core consumes it.
|
||||
|
||||
## Allowed Mission Core UI code
|
||||
|
||||
Mission Core may own domain renderers and domain composition:
|
||||
|
||||
- point-cloud, projection, timeline, camera, map, and diagnostic renderers;
|
||||
- adapters from immutable evidence contracts to renderer inputs;
|
||||
- application state and orchestration;
|
||||
- LAB configuration and concise domain copy;
|
||||
- layout of domain facts inside a canonical surface.
|
||||
|
||||
Mission Core may also own a task-specific workspace composition when a new
|
||||
operator job cannot be expressed honestly by an existing page pattern. It must
|
||||
first pass the product-surface protocol; it does not inherit the LAB template
|
||||
merely because LAB was implemented first.
|
||||
|
||||
Mission Core must not own substitutes for general UI controls or surfaces.
|
||||
Renderer actions use canonical `Button`, `IconButton`, `SegmentedControl`,
|
||||
`Select`, `StatusBadge`, `GlassSurface`, `WorkspaceWindow`, and canonical
|
||||
`Icon` exports.
|
||||
|
||||
## One laboratory product template
|
||||
|
||||
Every LAB uses the same product hierarchy:
|
||||
|
||||
1. **Catalog selectors** — profile and immutable LAB run.
|
||||
2. **Canonical summary** — one compact block, not a full report.
|
||||
3. **Evidence** — one or more admitted reusable evidence viewers.
|
||||
4. **Result** — decision-relevant metrics and conclusion.
|
||||
5. **Technical details** — optional reusable diagnostic component, never a
|
||||
one-off page.
|
||||
|
||||
A LAB provides typed data to these slots. It does not create its own page
|
||||
grammar, toolbar language, status geometry, or decorative card stack.
|
||||
|
||||
### Canonical summary content
|
||||
|
||||
The summary must let an operator understand the evidence before opening the
|
||||
viewer:
|
||||
|
||||
- decision question and reason for the experiment;
|
||||
- immutable physical source and tested bounds;
|
||||
- pipeline and execution class;
|
||||
- models, algorithms, tools, and worker/runtime that materially affected the
|
||||
result;
|
||||
- experimental feature or configuration under test;
|
||||
- principal result and known limitation;
|
||||
- retained command, navigation, safety, and publication authority.
|
||||
|
||||
Show short product names and exact identities only where they aid comparison.
|
||||
Long manifests, hashes, implementation file lists, validation logs, and the
|
||||
history of rejected approaches belong in Ops.
|
||||
|
||||
## Evidence viewer contract
|
||||
|
||||
Primary visual evidence is hosted in one reusable viewer frame.
|
||||
|
||||
The viewer frame must:
|
||||
|
||||
- provide the canonical expand/restore `IconButton`;
|
||||
- preserve the selected evidence case and view mode while resizing;
|
||||
- expose mode switching through canonical `SegmentedControl`;
|
||||
- expose a canonical in-frame layer toggle when an overlay can obscure the
|
||||
immutable source image; hiding a client-rendered overlay must not trigger a
|
||||
second backend materialization;
|
||||
- keep renderer-specific actions inside the frame;
|
||||
- use the same loading, unavailable, and error grammar across LAB runs;
|
||||
- remain keyboard-addressable and restore the previous surface on Escape;
|
||||
- avoid hard-coded product colors and application-local focus/hover states.
|
||||
|
||||
### 3D and 2D policy
|
||||
|
||||
Choose the default representation from the operator question:
|
||||
|
||||
- use **3D** for spatial shape, range, height, topology, surface support,
|
||||
occupied volume, and relationships between sensor-local objects;
|
||||
- use **2D** for camera-plane reprojection, bbox/mask agreement, calibration
|
||||
residuals, field-of-view boundaries, image occlusion, and pixel-space
|
||||
correspondence.
|
||||
|
||||
When both questions matter, expose 2D and 3D as modes of the same viewer. They
|
||||
must use the same selected case and immutable source indices. Do not create a
|
||||
second LAB page or duplicate the evidence state.
|
||||
|
||||
For E30, **camera + projected LiDAR is the default** because the first review
|
||||
question is whether a camera claim, its bbox and the projected points refer to
|
||||
the same visible object. A black pixel-plane scatter without the exact camera
|
||||
frame is not admissible review evidence. Synchronized 3D remains the secondary
|
||||
mode for point ownership, shape, range, surface leakage and occlusion checks.
|
||||
The exact camera frame remains independently inspectable: the projected LiDAR
|
||||
layer may be hidden without hiding the observation bbox or changing the
|
||||
selected evidence item.
|
||||
The 3D scene uses the gravity-aligned map frame; a sensor-local horizontal grid
|
||||
must not be presented as a measured road or ground plane.
|
||||
|
||||
### Automated audit and human exceptions
|
||||
|
||||
Evidence readiness, engineering decisions and human review are separate
|
||||
provenance layers:
|
||||
|
||||
1. deterministic triage may verify hashes, bindings, availability and route an
|
||||
item, but it does not issue a semantic verdict;
|
||||
2. an AI-assisted engineering generation may issue `confirmed`, `corrected` or
|
||||
`insufficient-evidence` only against the immutable camera/2D/3D substrate
|
||||
and must identify its producer, method and confidence;
|
||||
3. only ambiguous or high-impact items are routed into a human exception
|
||||
queue;
|
||||
4. an AI generation is never labelled `human review`, ground truth or
|
||||
navigation/safety acceptance.
|
||||
|
||||
The default product view states the result in one short sentence and keeps
|
||||
expert evidence and provenance available in the same reusable composition.
|
||||
The owner is not asked to approve an uninterpretable point cloud or to repeat
|
||||
the complete engineering audit manually.
|
||||
|
||||
## Forbidden product UI
|
||||
|
||||
Do not render:
|
||||
|
||||
- roadmap steps, phase numbers, next-gate checklists, or internal acceptance
|
||||
queues;
|
||||
- raw internal reason taxonomies as chips or footer content;
|
||||
- debug-only controls, fake progress, mock viewers, or placeholder metrics;
|
||||
- implementation notes, file paths, stack traces, worker logs, or test output;
|
||||
- new local button, tab, dropdown, card, status, icon, or focus styles;
|
||||
- a separate layout because one LAB has a different algorithm.
|
||||
|
||||
If information is necessary only for development or governance, place it in
|
||||
Ops, a report, an ADR, a runbook, or developer tooling.
|
||||
|
||||
## Ops engineering report
|
||||
|
||||
The complete report for a LAB or architecture milestone lives in the Mission
|
||||
Core Ops project. Keep the issue description concise and place the report in
|
||||
titled structured blocks. Use this canonical order:
|
||||
|
||||
1. Objective and architecture stage.
|
||||
2. Decision question and hypothesis.
|
||||
3. Immutable source evidence and tested bounds.
|
||||
4. Method, preprocessing, models, algorithms, tools, and identities.
|
||||
5. Worker/runtime topology and resource policy.
|
||||
6. Experimental changes and implementation.
|
||||
7. Validation and reproduced evidence.
|
||||
8. Quantitative and qualitative results.
|
||||
9. Regressions, rejected approaches, and honest limitations.
|
||||
10. Decision: continue, revise, reject, or promote.
|
||||
11. Next stage and authority that remains forbidden.
|
||||
12. Acceptance checker with short verifiable items.
|
||||
|
||||
The product summary is a projection of this report, never a second independent
|
||||
narrative.
|
||||
|
||||
## Review gate before A3
|
||||
|
||||
Before A3 or any subsequent LAB UI change:
|
||||
|
||||
1. verify the UI uses the fixed template;
|
||||
2. verify controls and icons exist in the Design Guideline registry;
|
||||
3. verify no temporary experiment or roadmap UI is present;
|
||||
4. verify primary visual evidence can expand and restore;
|
||||
5. verify every admitted representation answers a named operator question;
|
||||
6. run typecheck, unit tests, production build, and browser interaction QA;
|
||||
7. visually inspect normal and expanded modes at representative viewport sizes.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Mission Core application component architecture
|
||||
|
||||
Status: accepted, 2026-07-26
|
||||
|
||||
## Decision
|
||||
|
||||
Mission Core adopts the same dependency discipline as the NODE.DC Design
|
||||
Guideline without copying its application code.
|
||||
|
||||
The Design Guideline remains the visual platform:
|
||||
|
||||
```text
|
||||
@nodedc/tokens
|
||||
→ @nodedc/ui-core
|
||||
→ @nodedc/ui-react
|
||||
→ Mission Core application
|
||||
```
|
||||
|
||||
Inside Mission Core, dependencies flow in one direction:
|
||||
|
||||
```text
|
||||
typed contracts and data adapters
|
||||
→ reusable domain components and renderers
|
||||
→ feature workspaces
|
||||
→ product registry and reviewed composition roots
|
||||
→ App shell
|
||||
```
|
||||
|
||||
A lower layer does not import a higher layer. The application owns domain
|
||||
meaning and data flow; the Design Guideline owns general visual controls,
|
||||
surfaces, interaction states, icons, and shell geometry.
|
||||
|
||||
This dependency direction constrains ownership, not product composition. New
|
||||
workspaces may have different information architectures when their operator
|
||||
jobs differ. Their placement and form follow
|
||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||
|
||||
## Why Design Guideline code is not copied
|
||||
|
||||
The reusable architecture in `NODEDC_DESIGN_GUIDELINE` is its published package
|
||||
boundary, registries, validation, and one-way imports. Its living catalog is an
|
||||
application and is not a source template to paste into Mission Core.
|
||||
|
||||
Mission Core consumes `@nodedc/tokens`, `@nodedc/ui-core`, and
|
||||
`@nodedc/ui-react`. A visual primitive missing from those packages is proposed
|
||||
and approved in the Design Guideline first. Mission Core does not fork it.
|
||||
|
||||
The current `file:` package dependencies are suitable for the local standalone
|
||||
development contour, but they are not an immutable distribution contract.
|
||||
Before portable CI or deployment, the packages must be published, vendored with
|
||||
content verification, or pinned to a verified donor revision.
|
||||
|
||||
## Internal layers
|
||||
|
||||
### `src/core`
|
||||
|
||||
Owns typed domain contracts, parsers, API adapters, state machines, and
|
||||
domain-specific hooks. It may depend on React for a hook or context boundary,
|
||||
but it does not import visual adapters, workspaces, or the App shell.
|
||||
|
||||
### `src/components`
|
||||
|
||||
Owns reusable Mission Core domain components such as observation sources,
|
||||
timelines, media adapters, and laboratory presentation/evidence frames.
|
||||
Components may consume core contracts and canonical Design Guideline exports.
|
||||
They do not select routes, own product navigation, or import workspaces.
|
||||
|
||||
### `src/workspaces`
|
||||
|
||||
Owns domain composition. A workspace connects core data to reusable components
|
||||
and renderers. A new domain gets its own module or feature directory; it is not
|
||||
appended to the central workspace hub.
|
||||
|
||||
`workspaces/contracts.ts` is the host contract shared by workspace
|
||||
implementations. The central `Workspaces.tsx` is a composition hub for existing
|
||||
generic workspaces, not a home for LAB-specific adapters or evidence logic.
|
||||
|
||||
### `src/workspaces/laboratory`
|
||||
|
||||
Owns the laboratory catalog feature. It may compose:
|
||||
|
||||
- the reusable laboratory presentation components;
|
||||
- immutable LAB adapters from `core/laboratory`;
|
||||
- domain evidence renderers;
|
||||
- the generic spatial workspace through the explicit `SpatialView` contract.
|
||||
|
||||
Every new LAB adds a bounded result component and typed data adapter here. It
|
||||
does not add LAB imports, selectors, styles, or conditional evidence code to
|
||||
`Workspaces.tsx` or `App.tsx`.
|
||||
|
||||
### `src/composition`
|
||||
|
||||
Owns reviewed integration imports and registries. Vendor/plugin contributions
|
||||
enter Core only through this layer and the public plugin SDK. Composition does
|
||||
not become a second business-logic layer.
|
||||
|
||||
### `src/productModel.ts`
|
||||
|
||||
Is the typed application registry for roots, workspaces, labels, icons, and
|
||||
declared capabilities. Navigation and routing project from it rather than
|
||||
duplicating lists across the App shell.
|
||||
|
||||
It is a product registry, not a runtime ontology and not a substitute for
|
||||
evidence or domain contracts.
|
||||
|
||||
New workspaces and roots enter this registry only after their user job and
|
||||
placement are resolved. The registry must not accumulate placeholder pages for
|
||||
unapproved future ideas.
|
||||
|
||||
### `src/App.tsx`
|
||||
|
||||
Owns shell-level orchestration: selected root/workspace, global panels, runtime
|
||||
providers, and passing typed controllers to a workspace. It must not absorb
|
||||
domain API calls, per-LAB renderers, or new visual primitives.
|
||||
|
||||
## CSS ownership
|
||||
|
||||
CSS follows the same feature boundary:
|
||||
|
||||
- general workspace composition lives in `styles/workspaces.css`;
|
||||
- laboratory presentation and E30 evidence layout live in
|
||||
`styles/laboratory.css`;
|
||||
- spatial, observation, device, and shell rules remain in their named files;
|
||||
- plugin CSS is plugin-local and scoped below its contribution root.
|
||||
|
||||
Application CSS may arrange domain content. Canonical control geometry and
|
||||
states remain in the Design Guideline packages. A feature must not add selectors
|
||||
to a generic CSS bucket merely because that file is already imported.
|
||||
|
||||
## Component admission
|
||||
|
||||
A Mission Core component is admitted only when all of the following are true:
|
||||
|
||||
1. Its responsibility can be stated in one sentence.
|
||||
2. Its inputs are typed domain data or callbacks, not hidden global state.
|
||||
3. It does not duplicate a Design Guideline entity.
|
||||
4. It has explicit loading, empty, unavailable, and error behavior where
|
||||
relevant.
|
||||
5. It has no knowledge of unrelated workspaces or vendor implementations.
|
||||
6. Its styles are feature-scoped.
|
||||
7. Its contract or behavior is covered by a unit or architecture test.
|
||||
|
||||
If the entity is visually generic rather than domain-specific, it belongs in
|
||||
the Design Guideline and requires product-owner approval before implementation.
|
||||
|
||||
## Laboratory feature template
|
||||
|
||||
The code shape of a new laboratory increment is:
|
||||
|
||||
```text
|
||||
core/laboratory/eNNContract.ts
|
||||
immutable API/schema adapter
|
||||
|
||||
workspaces/laboratory/ENNResult.tsx
|
||||
concise summary + admitted evidence + result projection
|
||||
|
||||
domain renderer module
|
||||
only when an existing renderer cannot express the evidence
|
||||
|
||||
LaboratoryArchiveWorkspace.tsx
|
||||
registry/selection composition only
|
||||
|
||||
styles/laboratory.css
|
||||
shared LAB layout; no per-run visual language
|
||||
```
|
||||
|
||||
The complete engineering narrative remains in Ops. The product component
|
||||
receives a concise typed projection.
|
||||
|
||||
## Local ontology decision
|
||||
|
||||
Mission Core already has three semantic mechanisms:
|
||||
|
||||
1. `productModel.ts` for application navigation and capability declarations;
|
||||
2. versioned TypeScript/Pydantic/JSON contracts for executable data;
|
||||
3. `docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md` for
|
||||
meanings that are not yet stable enough for Platform Ontology.
|
||||
|
||||
These mechanisms are sufficient before A3. A new RDF/graph store, ontology
|
||||
service, or parallel entity catalog would introduce duplicated identity and
|
||||
migration work without a current query or integration consumer.
|
||||
|
||||
A formal local runtime ontology is introduced only when:
|
||||
|
||||
- at least two independent runtime consumers require the same relationships;
|
||||
- typed contract traversal is demonstrably insufficient;
|
||||
- ownership, identity, lifecycle, versioning, and migration are agreed;
|
||||
- the graph answers named queries used by the product or automation;
|
||||
- promotion or synchronization with NODE.DC Platform Ontology is defined.
|
||||
|
||||
Until those conditions hold, new stable meanings extend the versioned local
|
||||
vocabulary and executable contracts. They do not create a second runtime model.
|
||||
|
||||
## Automated boundary gate
|
||||
|
||||
`test/applicationArchitecture.test.mjs` enforces:
|
||||
|
||||
- no upward imports from core/components into workspaces or App;
|
||||
- no visual adapter imports from core;
|
||||
- no local vendor icon library or direct Design Guideline source imports;
|
||||
- LAB code and CSS remain outside the central workspace buckets;
|
||||
- central composition files cannot silently return to their previous size.
|
||||
|
||||
The line limits are ratchets, not quality targets. When a file reaches a limit,
|
||||
split a feature; do not raise the limit to accommodate unrelated behavior.
|
||||
|
||||
From `apps/control-station` run:
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Known bounded debt
|
||||
|
||||
- `App.tsx` remains a large shell orchestrator. Its current size is frozen by a
|
||||
ratchet; future shell behavior must extract a controller/hook or panel module.
|
||||
- `Workspaces.tsx` still contains several established generic workspaces. New
|
||||
domains must be separate modules, and existing ones may be extracted when
|
||||
their behavior changes.
|
||||
- `LaboratoryArchiveWorkspace.tsx` is now physically isolated but at its
|
||||
ratchet. A3 receives its own component/module instead of growing that file.
|
||||
- Design Guideline dependencies are mutable local `file:` links until a
|
||||
portable package/distribution decision is implemented.
|
||||
|
||||
None of these debts require a new ontology or a copy of the Design Guideline
|
||||
application.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Mission Core product surface extension protocol
|
||||
|
||||
Status: accepted by product owner, 2026-07-26
|
||||
|
||||
## Purpose
|
||||
|
||||
Mission Core must remain coherent without forcing every future capability into
|
||||
the same page layout. Consistency applies to product grammar; composition
|
||||
follows the operator task.
|
||||
|
||||
The stable grammar is:
|
||||
|
||||
- the NODE.DC application shell and navigation mechanics;
|
||||
- canonical controls, surfaces, icons, states, focus, motion, and responsive
|
||||
behavior from the Design Guideline;
|
||||
- one-way code dependencies and feature ownership;
|
||||
- explicit loading, empty, unavailable, error, disabled, and authority states;
|
||||
- typed product registry and domain contracts;
|
||||
- compact product copy instead of implementation scaffolding.
|
||||
|
||||
The variable composition is:
|
||||
|
||||
- information hierarchy and density;
|
||||
- map-, scene-, timeline-, list-, table-, graph-, form-, or editor-first layout;
|
||||
- permanent workspace, transient window, inspector, or view mode;
|
||||
- renderer arrangement and synchronization;
|
||||
- task-specific actions and domain facts;
|
||||
- placement in an existing product root or an approved new root.
|
||||
|
||||
Reusing product grammar does not mean reusing one layout. A different operator
|
||||
job may require a different application composition.
|
||||
|
||||
## Surface taxonomy
|
||||
|
||||
Choose the smallest surface that owns the complete user job.
|
||||
|
||||
| Surface | Use when | Do not use when |
|
||||
| --- | --- | --- |
|
||||
| View mode | The entity, task, state, and actions stay the same; only representation changes | The user enters a different workflow or lifecycle |
|
||||
| Inspector or panel | The user examines or adjusts the selected entity without leaving the primary task | The panel would contain its own navigation or long-running workflow |
|
||||
| Floating window | A synchronized auxiliary source must remain visible over a primary workspace | The content is the primary task or must own product navigation |
|
||||
| Existing workspace section | The feature is one bounded part of the workspace's existing user job | It introduces a separate object, lifecycle, permission model, or success criterion |
|
||||
| Dedicated workspace | The feature has a distinct recurring job, state, actions, and completion condition inside an existing product domain | It is only a different visualization of the same task |
|
||||
| New product root | A durable product domain owns several related workspaces and cannot be named honestly inside an existing root | There is only one screen, experiment, or implementation module |
|
||||
| Plugin contribution | Vendor- or equipment-specific workflow belongs to a reviewed host slot | The behavior is product-wide or controls global navigation |
|
||||
| LAB result | Immutable experimental evidence is projected through the canonical LAB contract | The user is operating, planning, configuring, or monitoring the production product |
|
||||
|
||||
Do not use a modal for a workspace, a new root for a single screen, or a new
|
||||
workspace for a view-mode switch.
|
||||
|
||||
## Product-surface brief
|
||||
|
||||
Before implementing a new surface or materially relocating an existing one,
|
||||
write a concise brief in the task/Ops card or architecture document:
|
||||
|
||||
1. **Operator and job** — who opens it and what must be completed or decided.
|
||||
2. **Trigger and frequency** — where the workflow begins and how often it is
|
||||
used.
|
||||
3. **Primary entity and lifecycle** — what owns state, identity, history, and
|
||||
completion.
|
||||
4. **Evidence and authority** — what data is shown and which actions remain
|
||||
forbidden.
|
||||
5. **Information hierarchy** — the primary content, secondary context, and
|
||||
details.
|
||||
6. **Actions and states** — primary action, secondary actions, loading, empty,
|
||||
unavailable, error, disabled, and recovery behavior.
|
||||
7. **Placement candidates** — at least two credible placements or
|
||||
compositions, with the rejected alternative and reason.
|
||||
8. **Selected surface** — view mode, panel, window, existing section,
|
||||
workspace, root, plugin slot, or LAB result.
|
||||
9. **Design Guideline reuse** — resolved components, icons, shell/page pattern,
|
||||
and any proven gap.
|
||||
10. **Acceptance evidence** — what browser interaction and real data prove that
|
||||
the surface solves the job.
|
||||
|
||||
The brief is a design decision, not product UI. Do not render this planning
|
||||
material inside Mission Core.
|
||||
|
||||
## Novelty classes and approval
|
||||
|
||||
### A — new domain content in an admitted composition
|
||||
|
||||
Examples: another layer in the spatial workspace, a new evidence renderer
|
||||
inside an existing reusable viewer, or an additional field in a canonical
|
||||
inspector.
|
||||
|
||||
Mission Core may implement this after resolving existing Design Guideline
|
||||
components and confirming that the user job and placement do not change.
|
||||
|
||||
### B — new Mission Core composition
|
||||
|
||||
Examples: a mission editor, operational review queue, fleet topology workspace,
|
||||
or long-running worker monitor.
|
||||
|
||||
The composition may be unique to Mission Core. Prepare the product-surface
|
||||
brief, compare placement alternatives, and obtain product-owner agreement when
|
||||
the change creates or relocates a workspace, changes primary navigation, or
|
||||
changes the main user workflow. Use canonical primitives but do not force the
|
||||
new task into the LAB or catalog template.
|
||||
|
||||
### C — new shared visual entity or page pattern
|
||||
|
||||
Examples: a general split-pane state machine, a reusable graph editor, or a new
|
||||
global navigation form factor.
|
||||
|
||||
Stop for explicit product-owner approval. Implement the generic contract in the
|
||||
Design Guideline first with registry entry, states, documentation, catalog
|
||||
example, validation, and versioned package export. Mission Core then consumes
|
||||
it.
|
||||
|
||||
### D — new product root or platform meaning
|
||||
|
||||
A new top-level root changes the information architecture. It requires:
|
||||
|
||||
- more than one durable related workspace, or a documented reason one root must
|
||||
initially contain one workspace;
|
||||
- a stable domain name, owner, entity/lifecycle boundary, and relationship to
|
||||
existing roots;
|
||||
- product-owner approval;
|
||||
- `productModel.ts` update and navigation/browser acceptance;
|
||||
- an ontology decision only if the separate multi-consumer admission gate is
|
||||
also met.
|
||||
|
||||
A new root does not automatically require a runtime ontology.
|
||||
|
||||
## Composition rule
|
||||
|
||||
Use the same composition when the user job, entity, lifecycle, and action model
|
||||
are the same. Create or approve a different composition when at least one of
|
||||
those changes materially.
|
||||
|
||||
Valid Mission Core compositions may therefore include:
|
||||
|
||||
- scene-first spatial operation;
|
||||
- map-first planning;
|
||||
- timeline-first replay;
|
||||
- list/detail operational triage;
|
||||
- table-first catalog or audit;
|
||||
- form/editor mission construction;
|
||||
- graph/topology monitoring;
|
||||
- compact dashboard;
|
||||
- immutable LAB evidence review.
|
||||
|
||||
The Design Guideline supplies the visual language. Mission Core supplies the
|
||||
task-specific composition.
|
||||
|
||||
## Code placement after the product decision
|
||||
|
||||
After the surface is admitted:
|
||||
|
||||
```text
|
||||
core/<feature>/
|
||||
contracts, API adapters, state machines, domain hooks
|
||||
|
||||
components/<feature>/
|
||||
reusable domain components and renderers
|
||||
|
||||
workspaces/<feature>/
|
||||
task composition and workspace controller
|
||||
|
||||
composition/
|
||||
reviewed registry or plugin wiring only when needed
|
||||
|
||||
productModel.ts
|
||||
root/workspace registration when navigation changes
|
||||
|
||||
styles/<feature>.css
|
||||
feature layout only
|
||||
|
||||
test/
|
||||
contract, interaction, boundary, and registry coverage
|
||||
```
|
||||
|
||||
Do not create an empty production route, placeholder workspace, temporary
|
||||
navigation item, or mock status page while the composition is undecided. Use
|
||||
the brief, Ops, an ADR, or an isolated visual prototype outside product
|
||||
navigation.
|
||||
|
||||
## Review questions
|
||||
|
||||
Before implementation, answer:
|
||||
|
||||
1. Is this a new user job or only a new representation?
|
||||
2. What entity owns the state and lifecycle?
|
||||
3. Can the user complete the job without opening another workspace?
|
||||
4. Does the surface need permanent navigation?
|
||||
5. Which existing root describes it honestly?
|
||||
6. Which Design Guideline primitives and page patterns already solve the
|
||||
generic interaction?
|
||||
7. Is the missing element domain-specific, Mission Core-specific, or truly
|
||||
shared?
|
||||
8. What layout alternative was rejected, and why?
|
||||
9. What real data and interaction will prove the decision?
|
||||
|
||||
If these answers are not available, continue investigation and surface design;
|
||||
do not start production implementation.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR 0023: require sensor capabilities before algorithm admission
|
||||
|
||||
Date: 2026-07-26
|
||||
Status: accepted
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0018 introduced the immutable K1-specific
|
||||
`missioncore.lidar-evidence-profile/v1`, and ADRs 0020–0021 established that the
|
||||
external K1 `lio_pcl` product is a post-LIO, map-registered increment rather
|
||||
than a native sensor scan. Those decisions correctly block known incompatible
|
||||
LiDAR stages, but the compatibility rules are still encoded per stage.
|
||||
|
||||
Mission Core must also admit future native scanners, depth sources and
|
||||
simulation providers without allowing an algorithm to infer missing physical
|
||||
properties from a successful API call. In particular, the absence of endpoints
|
||||
in the current K1 representation is not evidence that a ray traversed free
|
||||
space.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Mission Core owns
|
||||
`missioncore.sensor-representation-capabilities/v1` as a provider-neutral
|
||||
description of the physical properties admitted for one versioned source
|
||||
representation.
|
||||
2. The existing LiDAR evidence profile remains immutable. The capability
|
||||
profile references it by `source_profile_id`; it does not replace or rewrite
|
||||
source evidence.
|
||||
3. K1 `lio_pcl` is represented as `registered-map-increment` with
|
||||
`frame-increment` currentness. It admits metric XYZ, metric intensity,
|
||||
map registration and a separate sensor pose.
|
||||
4. K1 `lio_pcl` does not admit per-point time, ring/channel, a separate IMU,
|
||||
a shared hardware clock, a native ray model, per-point ray origin, motion
|
||||
compensation, ray clearing, free-space evidence or persistent
|
||||
reconstruction.
|
||||
5. Algorithms own a versioned requirements document with accepted
|
||||
representation kinds and required capabilities.
|
||||
6. Admission is binary and fail-closed. A missing capability or incompatible
|
||||
representation rejects the algorithm/input pairing; Mission Core never
|
||||
synthesizes a capability to make an integration run.
|
||||
7. Successful compatibility admission does not grant command, navigation or
|
||||
safety authority.
|
||||
8. `absence_of_endpoints_means_free` is fixed to `false`, and unknown remains
|
||||
unknown. Planner adapters must separately require accepted free-space
|
||||
evidence.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Endpoint-only occupied marking can consume the K1 registered increment.
|
||||
- Projective free-space mappers, ray clearing, TSDF/ESDF integrations and
|
||||
native-scan algorithms reject the K1 profile until a different admitted
|
||||
source supplies their requirements.
|
||||
- A future native MID-360 export or synthetic scan receives a separate source
|
||||
and capability profile; it does not upgrade historical K1 recordings.
|
||||
- Capability compatibility can be used by workers, provider adapters and LAB
|
||||
manifests without moving product ownership into ROS 2.
|
||||
- Track geometry and local occupied/unknown contracts can bind the exact
|
||||
representation profile and preserve the same fail-closed semantics.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `src/k1link/compute/sensor_representation.py`
|
||||
- `src/k1link/compute/e30_review_pack.py`
|
||||
- `src/k1link/compute/e30_materialization.py`
|
||||
- `src/k1link/web/e30_review_api.py`
|
||||
- `apps/control-station/src/core/laboratory/e30Review.ts`
|
||||
- `apps/control-station/src/workspaces/E30ReviewWorkspace.tsx`
|
||||
- `tests/test_sensor_representation.py`
|
||||
- `tests/test_e30_review_pack.py`
|
||||
- `tests/test_e30_materialization.py`
|
||||
- `apps/control-station/test/e30Review.test.mjs`
|
||||
- `docs/16_ARCHITECTURE_AUDIT_EXECUTION_ROADMAP.md`
|
||||
|
||||
## References
|
||||
|
||||
- ADR 0018: LiDAR evidence before models
|
||||
- ADR 0020: Patchwork++ vendor-map boundary
|
||||
- ADR 0021: native scan, normalized scan and rolling-map separation
|
||||
- `docs/13_LIDAR_WORKER_PRODUCT_AND_ROADMAP.md`
|
||||
@@ -0,0 +1,503 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Issue an immutable Codex engineering generation for LAB E30.
|
||||
|
||||
The decisions in this file are the compact, reproducible encoding of a visual
|
||||
audit of all 42 camera-backed engineering sheets (486 immutable E30 items).
|
||||
They are explicitly AI-assisted engineering judgments, not human annotation
|
||||
and not navigation/safety acceptance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.compute.e30_engineering_generation import (
|
||||
E30_ENGINEERING_DECISION_SCHEMA,
|
||||
build_e30_engineering_generation,
|
||||
)
|
||||
|
||||
SheetKey = tuple[str, int]
|
||||
|
||||
# Boxes placed on road/construction barriers, façades, signs, the recording
|
||||
# rig, or other non-object image structure.
|
||||
FALSE_POSITIVES: Final[frozenset[SheetKey]] = frozenset(
|
||||
{
|
||||
("agree-02.jpg", 2),
|
||||
("agree-03.jpg", 5),
|
||||
("agree-05.jpg", 5),
|
||||
("agree-05.jpg", 6),
|
||||
("agree-05.jpg", 7),
|
||||
("agree-05.jpg", 8),
|
||||
("agree-07.jpg", 4),
|
||||
("agree-07.jpg", 6),
|
||||
("agree-07.jpg", 12),
|
||||
("camera-only-01.jpg", 9),
|
||||
("camera-only-01.jpg", 10),
|
||||
# Frame 162: the bus box is on the façade/background; the real truck is
|
||||
# outside the box and therefore does not rescue this observation.
|
||||
("camera-only-01.jpg", 4),
|
||||
("camera-only-03.jpg", 6),
|
||||
("camera-only-03.jpg", 7),
|
||||
("camera-only-03.jpg", 8),
|
||||
("camera-only-04.jpg", 1),
|
||||
("camera-only-04.jpg", 2),
|
||||
("camera-only-04.jpg", 3),
|
||||
("camera-only-05.jpg", 11),
|
||||
("camera-only-05.jpg", 12),
|
||||
("camera-only-06.jpg", 1),
|
||||
("camera-only-06.jpg", 2),
|
||||
("camera-only-06.jpg", 3),
|
||||
("camera-only-06.jpg", 4),
|
||||
("camera-only-08.jpg", 11),
|
||||
("camera-only-09.jpg", 3),
|
||||
("camera-only-09.jpg", 4),
|
||||
("camera-only-10.jpg", 3),
|
||||
("camera-only-10.jpg", 4),
|
||||
("unknown-02.jpg", 1),
|
||||
("unknown-02.jpg", 2),
|
||||
("unknown-02.jpg", 3),
|
||||
("unknown-04.jpg", 9),
|
||||
}
|
||||
)
|
||||
|
||||
SELF_DETECTIONS: Final[frozenset[SheetKey]] = frozenset(
|
||||
{
|
||||
("camera-only-01.jpg", 8),
|
||||
("camera-only-02.jpg", 1),
|
||||
("camera-only-06.jpg", 10),
|
||||
("camera-only-07.jpg", 4),
|
||||
("camera-only-08.jpg", 4),
|
||||
("camera-only-09.jpg", 7),
|
||||
("camera-only-09.jpg", 9),
|
||||
("camera-only-10.jpg", 6),
|
||||
("geometry-only-01.jpg", 1),
|
||||
("geometry-only-01.jpg", 2),
|
||||
}
|
||||
)
|
||||
|
||||
# The broad semantic group remains usable, but the fine detector label does
|
||||
# not match the visible object (typically car/truck/bus or stroller/motorcycle).
|
||||
CLASS_MISMATCHES: Final[frozenset[SheetKey]] = frozenset(
|
||||
{
|
||||
("agree-01.jpg", 4),
|
||||
("agree-01.jpg", 5),
|
||||
("agree-03.jpg", 7),
|
||||
("agree-03.jpg", 9),
|
||||
("agree-03.jpg", 11),
|
||||
("agree-04.jpg", 5),
|
||||
("agree-05.jpg", 2),
|
||||
("agree-05.jpg", 4),
|
||||
("agree-05.jpg", 11),
|
||||
("agree-06.jpg", 1),
|
||||
("agree-06.jpg", 2),
|
||||
("agree-06.jpg", 3),
|
||||
("agree-06.jpg", 12),
|
||||
("agree-08.jpg", 3),
|
||||
("agree-08.jpg", 4),
|
||||
("agree-08.jpg", 6),
|
||||
("camera-only-04.jpg", 8),
|
||||
("camera-only-08.jpg", 12),
|
||||
}
|
||||
)
|
||||
|
||||
# Class-bearing objects visible in the camera projection but represented by
|
||||
# E29 only as geometry-only clusters.
|
||||
MISSED_OBJECTS: Final[frozenset[SheetKey]] = frozenset(
|
||||
{
|
||||
("geometry-only-01.jpg", 6),
|
||||
("geometry-only-01.jpg", 7),
|
||||
("geometry-only-01.jpg", 8),
|
||||
("geometry-only-01.jpg", 9),
|
||||
("geometry-only-01.jpg", 12),
|
||||
("geometry-only-02.jpg", 11),
|
||||
("geometry-only-02.jpg", 12),
|
||||
("geometry-only-03.jpg", 1),
|
||||
("geometry-only-03.jpg", 9),
|
||||
("geometry-only-04.jpg", 5),
|
||||
("geometry-only-05.jpg", 8),
|
||||
("geometry-only-07.jpg", 4),
|
||||
("geometry-only-08.jpg", 8),
|
||||
("geometry-only-08.jpg", 10),
|
||||
("geometry-only-08.jpg", 11),
|
||||
("geometry-only-08.jpg", 12),
|
||||
("geometry-only-10.jpg", 1),
|
||||
("geometry-only-10.jpg", 7),
|
||||
("geometry-only-10.jpg", 9),
|
||||
("geometry-only-10.jpg", 10),
|
||||
("geometry-only-11.jpg", 7),
|
||||
}
|
||||
)
|
||||
|
||||
# These two cases remain genuinely ambiguous after the camera-backed audit.
|
||||
# The three semantic cases from generation v1 are now handled automatically:
|
||||
# one detector false positive, one camera-only object, and one stale track.
|
||||
AMBIGUITIES: Final[frozenset[SheetKey]] = frozenset(
|
||||
{
|
||||
("geometry-only-07.jpg", 6),
|
||||
("geometry-only-10.jpg", 11),
|
||||
}
|
||||
)
|
||||
|
||||
EXCEPTION_REVIEW: Final[dict[SheetKey, dict[str, object]]] = {
|
||||
("geometry-only-07.jpg", 6): {
|
||||
"question": "Белый кластер — самостоятельное физическое препятствие?",
|
||||
"focus": (
|
||||
"Кадр 2622: проверьте четыре выбранные белые точки. Нужно отличить "
|
||||
"занятую геометрию реального объекта от поверхности сцены или шума."
|
||||
),
|
||||
"effects": {
|
||||
"object-present": (
|
||||
"Сохранить кластер как подтверждённую занятую геометрию."
|
||||
),
|
||||
"background-or-noise": (
|
||||
"Исключить кластер из положительной LiDAR-поддержки и учесть "
|
||||
"как leakage или шум."
|
||||
),
|
||||
"insufficient-evidence": (
|
||||
"Оставить кейс неизвестным и не использовать его для настройки "
|
||||
"порогов."
|
||||
),
|
||||
},
|
||||
},
|
||||
("geometry-only-10.jpg", 11): {
|
||||
"question": "Вертикальный белый кластер занимает реальное препятствие?",
|
||||
"focus": (
|
||||
"Кадр 4147: проверьте 31 выбранную белую точку на растительности. "
|
||||
"Нужно подтвердить физически занятую область либо фон/шум."
|
||||
),
|
||||
"effects": {
|
||||
"object-present": (
|
||||
"Сохранить кластер как подтверждённую занятую геометрию."
|
||||
),
|
||||
"background-or-noise": (
|
||||
"Исключить кластер из положительной LiDAR-поддержки и учесть "
|
||||
"как leakage или шум."
|
||||
),
|
||||
"insufficient-evidence": (
|
||||
"Оставить кейс неизвестным и не использовать его для настройки "
|
||||
"порогов."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class IssueGenerationError(RuntimeError):
|
||||
"""The frozen review inputs do not match the audited sheet set."""
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise IssueGenerationError(f"JSON object required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
values: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise IssueGenerationError(f"JSONL object required: {path}")
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _sheet_bindings(
|
||||
root: Path,
|
||||
) -> tuple[dict[str, SheetKey], dict[str, dict[str, object]]]:
|
||||
manifest = _read_json(root / "manifest.json")
|
||||
keys_by_item: dict[str, SheetKey] = {}
|
||||
sheets_by_item: dict[str, dict[str, object]] = {}
|
||||
for sheet in manifest["sheets"]:
|
||||
for entry in sheet["entries"]:
|
||||
item_id = str(entry["item_id"])
|
||||
keys_by_item[item_id] = (str(sheet["path"]), int(entry["ordinal"]))
|
||||
sheets_by_item[item_id] = {
|
||||
"path": sheet["path"],
|
||||
"sha256": sheet["sha256"],
|
||||
"ordinal": entry["ordinal"],
|
||||
}
|
||||
return keys_by_item, sheets_by_item
|
||||
|
||||
|
||||
def _projected_selected_count(materialization_root: Path, item: dict[str, Any]) -> int:
|
||||
with np.load(materialization_root / item["artifact"]["path"]) as arrays:
|
||||
return int(np.count_nonzero(arrays["projected_selected_mask"]))
|
||||
|
||||
|
||||
def _baseline(
|
||||
*,
|
||||
item: dict[str, Any],
|
||||
materialization_root: Path,
|
||||
) -> dict[str, object]:
|
||||
stratum = str(item["stratum"])
|
||||
if stratum == "conflict":
|
||||
return {
|
||||
"verdict": "corrected",
|
||||
"effective_stratum": None,
|
||||
"detector_assessment": "false-positive",
|
||||
"projection_assessment": "aligned",
|
||||
"point_ownership": "surface-or-background",
|
||||
"cause_code": "detector_error",
|
||||
"confidence": 0.97,
|
||||
"note": (
|
||||
"В рамке находится полосатое дорожное/строительное ограждение, "
|
||||
"а не транспорт. Проекция согласована с изображением; LiDAR "
|
||||
"принадлежит поверхности ограждения."
|
||||
),
|
||||
}
|
||||
if stratum == "agree":
|
||||
return {
|
||||
"verdict": "confirmed",
|
||||
"effective_stratum": stratum,
|
||||
"detector_assessment": "valid",
|
||||
"projection_assessment": "aligned",
|
||||
"point_ownership": "object",
|
||||
"cause_code": None,
|
||||
"confidence": 0.94,
|
||||
"note": (
|
||||
"Камерный объект и занятые LiDAR-точки пространственно "
|
||||
"согласованы; поддержка принадлежит наблюдаемому объекту."
|
||||
),
|
||||
}
|
||||
if stratum == "camera-only":
|
||||
return {
|
||||
"verdict": "confirmed",
|
||||
"effective_stratum": stratum,
|
||||
"detector_assessment": "valid",
|
||||
"projection_assessment": "aligned",
|
||||
"point_ownership": "insufficient-support",
|
||||
"cause_code": "sparse_support",
|
||||
"confidence": 0.82,
|
||||
"note": (
|
||||
"Объект читается в камере, но в его рамке недостаточно "
|
||||
"квалифицированной LiDAR-поддержки; глобальная регистрация "
|
||||
"проекции визуально согласована."
|
||||
),
|
||||
}
|
||||
if stratum == "unknown":
|
||||
return {
|
||||
"verdict": "confirmed",
|
||||
"effective_stratum": stratum,
|
||||
"detector_assessment": "valid",
|
||||
"projection_assessment": "not-assessable",
|
||||
"point_ownership": "insufficient-evidence",
|
||||
"cause_code": "time_mismatch",
|
||||
"confidence": 0.9,
|
||||
"note": (
|
||||
"E29 использует удержанный world-track, а не актуальное "
|
||||
"семантическое наблюдение этого кадра. Неизвестность "
|
||||
"обусловлена временной свежестью, не геометрическим порогом."
|
||||
),
|
||||
}
|
||||
if stratum == "geometry-only":
|
||||
projected = _projected_selected_count(materialization_root, item)
|
||||
return {
|
||||
"verdict": "confirmed",
|
||||
"effective_stratum": stratum,
|
||||
"detector_assessment": "not-applicable",
|
||||
"projection_assessment": "aligned" if projected else "not-assessable",
|
||||
"point_ownership": "static-environment",
|
||||
"cause_code": None,
|
||||
"confidence": 0.8,
|
||||
"note": (
|
||||
"Кластер соответствует статической сцене: фасаду, растительности "
|
||||
"или иной геометрии без обязательного семантического объекта."
|
||||
),
|
||||
}
|
||||
raise IssueGenerationError(f"unexpected stratum: {stratum}")
|
||||
|
||||
|
||||
def _decision(
|
||||
*,
|
||||
item: dict[str, Any],
|
||||
key: SheetKey,
|
||||
sheet: dict[str, object],
|
||||
materialization_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
result = _baseline(item=item, materialization_root=materialization_root)
|
||||
source = str(item["stratum"])
|
||||
if key in AMBIGUITIES:
|
||||
result.update(
|
||||
verdict="insufficient-evidence",
|
||||
effective_stratum=None,
|
||||
detector_assessment="insufficient-evidence",
|
||||
projection_assessment="not-assessable",
|
||||
point_ownership="insufficient-evidence",
|
||||
cause_code="unknown",
|
||||
confidence=0.48,
|
||||
note=(
|
||||
"Камерный crop и спроецированная принадлежность не дают "
|
||||
"устойчиво отделить объект от конструкции/края сцены. "
|
||||
"Оставлено единственным типом ручного исключения."
|
||||
),
|
||||
)
|
||||
elif key in SELF_DETECTIONS:
|
||||
result.update(
|
||||
verdict="corrected",
|
||||
effective_stratum=None,
|
||||
detector_assessment=(
|
||||
"false-positive"
|
||||
if source != "geometry-only"
|
||||
else "not-applicable"
|
||||
),
|
||||
projection_assessment="aligned",
|
||||
point_ownership="self",
|
||||
cause_code="self_points",
|
||||
confidence=0.96,
|
||||
note=(
|
||||
"Выделение принадлежит элементам собственной платформы/оператора "
|
||||
"в нижней или краевой части кадра, а не внешнему объекту сцены."
|
||||
),
|
||||
)
|
||||
elif source == "conflict" or key in FALSE_POSITIVES:
|
||||
result.update(
|
||||
verdict="corrected",
|
||||
effective_stratum="geometry-only" if source == "agree" else None,
|
||||
detector_assessment="false-positive",
|
||||
projection_assessment="aligned",
|
||||
point_ownership="surface-or-background",
|
||||
cause_code="detector_error",
|
||||
confidence=0.95,
|
||||
note=(
|
||||
"Детектор поставил объектную рамку на ограждение, знак, фасад "
|
||||
"или иной фон. Проекция камеры и LiDAR согласована; ошибка "
|
||||
"локализована в семантической детекции."
|
||||
),
|
||||
)
|
||||
elif key in MISSED_OBJECTS:
|
||||
result.update(
|
||||
verdict="confirmed",
|
||||
effective_stratum=source,
|
||||
detector_assessment="missed-object",
|
||||
projection_assessment="aligned",
|
||||
point_ownership="object",
|
||||
cause_code="detector_error",
|
||||
confidence=0.88,
|
||||
note=(
|
||||
"Geometry-only кластер пространственно совпадает с различимым "
|
||||
"в камере человеком или транспортом. Геометрия корректна, "
|
||||
"но семантический объект пропущен."
|
||||
),
|
||||
)
|
||||
elif key in CLASS_MISMATCHES:
|
||||
result.update(
|
||||
detector_assessment="class-mismatch",
|
||||
cause_code="detector_error",
|
||||
confidence=0.86,
|
||||
note=(
|
||||
"Объектная поддержка корректна на уровне широкой группы, но "
|
||||
"видимый подтип не совпадает с fine-label детектора."
|
||||
),
|
||||
)
|
||||
|
||||
snapshot = item.get("e29_snapshot")
|
||||
label = (
|
||||
snapshot.get("label") or snapshot.get("semantic_class")
|
||||
if isinstance(snapshot, dict)
|
||||
else None
|
||||
)
|
||||
frame = item["evidence_binding"]["source_frame_index"]
|
||||
exception = key in AMBIGUITIES
|
||||
return {
|
||||
"schema_version": E30_ENGINEERING_DECISION_SCHEMA,
|
||||
"sequence": item["sequence"],
|
||||
"item_id": item["item_id"],
|
||||
"review_key": item["review_key"],
|
||||
"source_stratum": source,
|
||||
"verdict": result["verdict"],
|
||||
"effective_stratum": result["effective_stratum"],
|
||||
"detector_assessment": result["detector_assessment"],
|
||||
"projection_assessment": result["projection_assessment"],
|
||||
"point_ownership": result["point_ownership"],
|
||||
"cause_code": result["cause_code"],
|
||||
"confidence": result["confidence"],
|
||||
"human_exception_required": exception,
|
||||
"exception_reason": "ambiguity" if exception else None,
|
||||
"review_prompt": EXCEPTION_REVIEW.get(key),
|
||||
"evidence_note": (
|
||||
f"Кадр {frame}; label={label or 'geometry-cluster'}. {result['note']}"
|
||||
),
|
||||
"review_sheet": sheet,
|
||||
}
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--materialization-root", type=Path, required=True)
|
||||
parser.add_argument("--review-pack-root", type=Path, required=True)
|
||||
parser.add_argument("--review-sheets-root", type=Path, required=True)
|
||||
parser.add_argument("--decisions-path", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
materialization_root = args.materialization_root.resolve()
|
||||
items = _read_jsonl(materialization_root / "materialized-items.jsonl")
|
||||
keys_by_item, sheets_by_item = _sheet_bindings(
|
||||
args.review_sheets_root.resolve()
|
||||
)
|
||||
all_audited_keys = set(keys_by_item.values())
|
||||
declared_sets = (
|
||||
FALSE_POSITIVES,
|
||||
SELF_DETECTIONS,
|
||||
CLASS_MISMATCHES,
|
||||
MISSED_OBJECTS,
|
||||
AMBIGUITIES,
|
||||
)
|
||||
for index, values in enumerate(declared_sets):
|
||||
if not values <= all_audited_keys:
|
||||
raise IssueGenerationError(
|
||||
f"annotation set {index} references a missing sheet tile"
|
||||
)
|
||||
for other in declared_sets[index + 1 :]:
|
||||
overlap = values & other
|
||||
if overlap:
|
||||
raise IssueGenerationError(
|
||||
f"annotation categories overlap: {sorted(overlap)}"
|
||||
)
|
||||
decisions = [
|
||||
_decision(
|
||||
item=item,
|
||||
key=keys_by_item[str(item["item_id"])],
|
||||
sheet=sheets_by_item[str(item["item_id"])],
|
||||
materialization_root=materialization_root,
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
args.decisions_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.decisions_path.open("w", encoding="utf-8") as stream:
|
||||
for decision in decisions:
|
||||
stream.write(
|
||||
json.dumps(
|
||||
decision,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
manifest = build_e30_engineering_generation(
|
||||
materialization_root=materialization_root,
|
||||
review_pack_root=args.review_pack_root.resolve(),
|
||||
review_sheets_root=args.review_sheets_root.resolve(),
|
||||
decisions_path=args.decisions_path.resolve(),
|
||||
output_root=args.output_root.resolve(),
|
||||
producer_id="codex:a3-engineering-review",
|
||||
method_id="camera-lidar-42-sheet-audit/v2",
|
||||
)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,498 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render reproducible camera-backed A3 engineering review sheets.
|
||||
|
||||
The sheets are presentation derivatives only. Every tile is bound to one
|
||||
immutable E30 materialization item and contains:
|
||||
|
||||
* the exact camera frame;
|
||||
* the complete bounded LiDAR projection;
|
||||
* candidate/selected point ownership;
|
||||
* the semantic bbox or a geometry-derived region of interest;
|
||||
* the source metrics required for engineering adjudication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import colorsys
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
MATERIALIZATION_SCHEMA: Final = "missioncore.e30-evidence-materialization/v2"
|
||||
ITEM_SCHEMA: Final = "missioncore.e30-evidence-materialization-item/v2"
|
||||
SHEET_MANIFEST_SCHEMA: Final = "missioncore.e30-engineering-review-sheets/v1"
|
||||
STRATA: Final = (
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
)
|
||||
|
||||
TILE_WIDTH: Final = 480
|
||||
TILE_HEIGHT: Final = 300
|
||||
FULL_WIDTH: Final = 320
|
||||
FULL_HEIGHT: Final = 240
|
||||
ROI_WIDTH: Final = 160
|
||||
ROI_HEIGHT: Final = 160
|
||||
SHEET_COLUMNS: Final = 4
|
||||
SHEET_ROWS: Final = 3
|
||||
ITEMS_PER_SHEET: Final = SHEET_COLUMNS * SHEET_ROWS
|
||||
|
||||
BACKGROUND: Final = (10, 10, 11)
|
||||
PANEL: Final = (22, 22, 24)
|
||||
TEXT: Final = (242, 242, 239)
|
||||
MUTED: Final = (155, 155, 150)
|
||||
WARNING: Final = (255, 209, 102)
|
||||
CONFLICT: Final = (255, 98, 92)
|
||||
SELECTED: Final = (255, 255, 255)
|
||||
|
||||
|
||||
class ReviewSheetError(RuntimeError):
|
||||
"""The source evidence cannot produce trustworthy review sheets."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReviewSheetError(f"missing regular JSON: {path}")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReviewSheetError(f"invalid JSON: {path}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReviewSheetError(f"JSON must contain an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _read_items(path: Path) -> list[dict[str, Any]]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReviewSheetError("materialized item index is unavailable")
|
||||
values: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReviewSheetError(
|
||||
f"invalid materialized item at line {line_number}"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReviewSheetError(
|
||||
f"materialized item {line_number} is not an object"
|
||||
)
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _font(size: int, *, bold: bool = False) -> ImageFont.ImageFont:
|
||||
candidates = (
|
||||
"/System/Library/Fonts/SFNSMono.ttf",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
if bold
|
||||
else "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
return ImageFont.truetype(candidate, size=size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
FONT_SMALL: Final = _font(11)
|
||||
FONT_META: Final = _font(12)
|
||||
FONT_BOLD: Final = _font(13, bold=True)
|
||||
|
||||
|
||||
def _depth_color(depth: float, minimum: float, maximum: float) -> tuple[int, int, int]:
|
||||
span = max(maximum - minimum, 0.001)
|
||||
position = min(1.0, max(0.0, (depth - minimum) / span))
|
||||
hue = (220.0 - position * 205.0) / 360.0
|
||||
red, green, blue = colorsys.hls_to_rgb(hue, 0.62, 0.88)
|
||||
return round(red * 255), round(green * 255), round(blue * 255)
|
||||
|
||||
|
||||
def _verify_item(
|
||||
*,
|
||||
root: Path,
|
||||
item: dict[str, Any],
|
||||
) -> tuple[Path, Path]:
|
||||
if item.get("schema_version") != ITEM_SCHEMA:
|
||||
raise ReviewSheetError("materialized item schema differs")
|
||||
artifact = item.get("artifact")
|
||||
camera = item.get("camera_frame")
|
||||
if not isinstance(artifact, dict) or not isinstance(camera, dict):
|
||||
raise ReviewSheetError("item artifacts are incomplete")
|
||||
item_path = root / str(artifact.get("path"))
|
||||
frame_path = root / str(camera.get("path"))
|
||||
for path, metadata in ((item_path, artifact), (frame_path, camera)):
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or path.stat().st_size != metadata.get("byte_length")
|
||||
or _sha256(path) != metadata.get("sha256")
|
||||
):
|
||||
raise ReviewSheetError(f"artifact changed: {path}")
|
||||
return item_path, frame_path
|
||||
|
||||
|
||||
def _draw_projection(
|
||||
*,
|
||||
frame: Image.Image,
|
||||
arrays: Any,
|
||||
item: dict[str, Any],
|
||||
) -> Image.Image:
|
||||
image = frame.convert("RGB")
|
||||
draw = ImageDraw.Draw(image)
|
||||
pixels = np.asarray(arrays["projected_pixels_xy"], dtype=np.float64)
|
||||
depths = np.asarray(arrays["projected_depth_m"], dtype=np.float64)
|
||||
candidates = np.asarray(arrays["projected_candidate_mask"], dtype=np.uint8)
|
||||
selected = np.asarray(arrays["projected_selected_mask"], dtype=np.uint8)
|
||||
finite_depths = depths[np.isfinite(depths)]
|
||||
minimum = float(finite_depths.min()) if finite_depths.size else 0.0
|
||||
maximum = float(finite_depths.max()) if finite_depths.size else 1.0
|
||||
for index, (x, y) in enumerate(pixels):
|
||||
if not math.isfinite(float(x)) or not math.isfinite(float(y)):
|
||||
continue
|
||||
if x < 0 or y < 0 or x >= image.width or y >= image.height:
|
||||
continue
|
||||
if selected[index] == 1:
|
||||
color, radius = SELECTED, 4
|
||||
elif candidates[index] == 1:
|
||||
color, radius = WARNING, 3
|
||||
else:
|
||||
color, radius = _depth_color(float(depths[index]), minimum, maximum), 1
|
||||
draw.ellipse(
|
||||
(
|
||||
round(x) - radius,
|
||||
round(y) - radius,
|
||||
round(x) + radius,
|
||||
round(y) + radius,
|
||||
),
|
||||
fill=color,
|
||||
)
|
||||
snapshot = item.get("e29_snapshot")
|
||||
bbox = snapshot.get("bbox_xyxy") if isinstance(snapshot, dict) else None
|
||||
if (
|
||||
isinstance(bbox, list)
|
||||
and len(bbox) == 4
|
||||
and all(isinstance(value, (int, float)) for value in bbox)
|
||||
):
|
||||
draw.rectangle(
|
||||
tuple(round(float(value)) for value in bbox),
|
||||
outline=CONFLICT if item.get("stratum") == "conflict" else SELECTED,
|
||||
width=3,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def _expanded_roi(
|
||||
*,
|
||||
item: dict[str, Any],
|
||||
arrays: Any,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
snapshot = item.get("e29_snapshot")
|
||||
bbox = snapshot.get("bbox_xyxy") if isinstance(snapshot, dict) else None
|
||||
if (
|
||||
isinstance(bbox, list)
|
||||
and len(bbox) == 4
|
||||
and all(isinstance(value, (int, float)) for value in bbox)
|
||||
):
|
||||
x1, y1, x2, y2 = (float(value) for value in bbox)
|
||||
else:
|
||||
pixels = np.asarray(arrays["projected_pixels_xy"], dtype=np.float64)
|
||||
selected = np.asarray(arrays["projected_selected_mask"], dtype=np.uint8) == 1
|
||||
candidate = np.asarray(arrays["projected_candidate_mask"], dtype=np.uint8) == 1
|
||||
owned = pixels[selected | candidate]
|
||||
if owned.size == 0:
|
||||
return 0, 0, width, height
|
||||
x1, y1 = owned.min(axis=0)
|
||||
x2, y2 = owned.max(axis=0)
|
||||
roi_width = max(x2 - x1, 64.0)
|
||||
roi_height = max(y2 - y1, 64.0)
|
||||
padding = max(18.0, 0.35 * max(roi_width, roi_height))
|
||||
center_x = (x1 + x2) / 2.0
|
||||
center_y = (y1 + y2) / 2.0
|
||||
side = min(max(roi_width, roi_height) + 2.0 * padding, float(max(width, height)))
|
||||
left = max(0.0, min(float(width) - side, center_x - side / 2.0))
|
||||
top = max(0.0, min(float(height) - side, center_y - side / 2.0))
|
||||
right = min(float(width), left + side)
|
||||
bottom = min(float(height), top + side)
|
||||
return round(left), round(top), round(right), round(bottom)
|
||||
|
||||
|
||||
def _fit_cover(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
target_width, target_height = size
|
||||
ratio = max(target_width / image.width, target_height / image.height)
|
||||
resized = image.resize(
|
||||
(round(image.width * ratio), round(image.height * ratio)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
left = max(0, (resized.width - target_width) // 2)
|
||||
top = max(0, (resized.height - target_height) // 2)
|
||||
return resized.crop((left, top, left + target_width, top + target_height))
|
||||
|
||||
|
||||
def _tile(
|
||||
*,
|
||||
ordinal: int,
|
||||
item: dict[str, Any],
|
||||
item_path: Path,
|
||||
frame_path: Path,
|
||||
) -> Image.Image:
|
||||
with np.load(item_path, allow_pickle=False) as arrays:
|
||||
with Image.open(frame_path) as source_frame:
|
||||
camera = item["camera_frame"]
|
||||
if source_frame.size != (camera.get("width"), camera.get("height")):
|
||||
raise ReviewSheetError("camera dimensions changed")
|
||||
annotated = _draw_projection(
|
||||
frame=source_frame,
|
||||
arrays=arrays,
|
||||
item=item,
|
||||
)
|
||||
full = annotated.resize(
|
||||
(FULL_WIDTH, FULL_HEIGHT),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
roi_box = _expanded_roi(
|
||||
item=item,
|
||||
arrays=arrays,
|
||||
width=annotated.width,
|
||||
height=annotated.height,
|
||||
)
|
||||
roi = _fit_cover(annotated.crop(roi_box), (ROI_WIDTH, ROI_HEIGHT))
|
||||
|
||||
tile = Image.new("RGB", (TILE_WIDTH, TILE_HEIGHT), PANEL)
|
||||
tile.paste(full, (0, 0))
|
||||
tile.paste(roi, (FULL_WIDTH, 0))
|
||||
draw = ImageDraw.Draw(tile)
|
||||
draw.line((FULL_WIDTH, 0, FULL_WIDTH, ROI_HEIGHT), fill=(65, 65, 68), width=1)
|
||||
draw.rectangle((0, 240, TILE_WIDTH - 1, TILE_HEIGHT - 1), fill=BACKGROUND)
|
||||
|
||||
snapshot = item.get("e29_snapshot")
|
||||
label = (
|
||||
str(snapshot.get("label"))
|
||||
if isinstance(snapshot, dict) and snapshot.get("label")
|
||||
else "geometry"
|
||||
)
|
||||
materialization = item["materialization"]
|
||||
frame_index = item["evidence_binding"]["source_frame_index"]
|
||||
score = materialization.get("detector_score")
|
||||
score_text = "n/a" if score is None else f"{float(score):.2f}"
|
||||
draw.text(
|
||||
(8, 246),
|
||||
f"{ordinal:02d} · {item['stratum']} · {label} · f{frame_index}",
|
||||
font=FONT_BOLD,
|
||||
fill=TEXT,
|
||||
)
|
||||
draw.text(
|
||||
(8, 266),
|
||||
(
|
||||
f"score {score_text} · proj {materialization['projected_point_count']} "
|
||||
f"· cand {materialization['candidate_point_count']} "
|
||||
f"· sel {materialization['selected_point_count']}"
|
||||
),
|
||||
font=FONT_META,
|
||||
fill=MUTED,
|
||||
)
|
||||
draw.text(
|
||||
(FULL_WIDTH + 7, ROI_HEIGHT + 7),
|
||||
item["review_key"][:24],
|
||||
font=FONT_SMALL,
|
||||
fill=MUTED,
|
||||
)
|
||||
draw.text(
|
||||
(FULL_WIDTH + 7, ROI_HEIGHT + 25),
|
||||
f"seq {item['sequence']} · roi {roi_box[0]},{roi_box[1]}",
|
||||
font=FONT_SMALL,
|
||||
fill=MUTED,
|
||||
)
|
||||
return tile
|
||||
|
||||
|
||||
def render_review_sheets(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
output_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
root = materialization_root.resolve()
|
||||
manifest = _read_json(root / "manifest.json")
|
||||
index_path = root / "materialized-items.jsonl"
|
||||
if (
|
||||
manifest.get("schema_version") != MATERIALIZATION_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or manifest.get("item_count") != 486
|
||||
or manifest.get("camera_evidence_available") is not True
|
||||
):
|
||||
raise ReviewSheetError("unsupported E30 materialization")
|
||||
items = _read_items(index_path)
|
||||
if (
|
||||
len(items) != manifest.get("item_count")
|
||||
or len({item.get("item_id") for item in items}) != len(items)
|
||||
):
|
||||
raise ReviewSheetError("materialization index count differs")
|
||||
|
||||
by_stratum: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in items:
|
||||
stratum = item.get("stratum")
|
||||
if stratum not in STRATA:
|
||||
raise ReviewSheetError("unknown E30 stratum")
|
||||
by_stratum[stratum].append(item)
|
||||
for values in by_stratum.values():
|
||||
values.sort(
|
||||
key=lambda item: (
|
||||
item["evidence_binding"]["source_frame_index"],
|
||||
item["sequence"],
|
||||
)
|
||||
)
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
destination = output_root / root.name
|
||||
if destination.exists():
|
||||
if destination.is_symlink() or not destination.is_dir():
|
||||
raise ReviewSheetError("review sheet destination is invalid")
|
||||
shutil.rmtree(destination)
|
||||
staging = Path(tempfile.mkdtemp(prefix=f".{root.name}.", dir=output_root))
|
||||
sheet_documents: list[dict[str, Any]] = []
|
||||
try:
|
||||
for stratum in STRATA:
|
||||
values = by_stratum[stratum]
|
||||
for page_index, offset in enumerate(
|
||||
range(0, len(values), ITEMS_PER_SHEET),
|
||||
start=1,
|
||||
):
|
||||
page = values[offset : offset + ITEMS_PER_SHEET]
|
||||
sheet = Image.new(
|
||||
"RGB",
|
||||
(
|
||||
SHEET_COLUMNS * TILE_WIDTH,
|
||||
SHEET_ROWS * TILE_HEIGHT,
|
||||
),
|
||||
BACKGROUND,
|
||||
)
|
||||
entries: list[dict[str, Any]] = []
|
||||
for ordinal, item in enumerate(page, start=1):
|
||||
item_path, frame_path = _verify_item(root=root, item=item)
|
||||
tile = _tile(
|
||||
ordinal=ordinal,
|
||||
item=item,
|
||||
item_path=item_path,
|
||||
frame_path=frame_path,
|
||||
)
|
||||
column = (ordinal - 1) % SHEET_COLUMNS
|
||||
row = (ordinal - 1) // SHEET_COLUMNS
|
||||
sheet.paste(tile, (column * TILE_WIDTH, row * TILE_HEIGHT))
|
||||
entries.append(
|
||||
{
|
||||
"ordinal": ordinal,
|
||||
"item_id": item["item_id"],
|
||||
"sequence": item["sequence"],
|
||||
"review_key": item["review_key"],
|
||||
"stratum": item["stratum"],
|
||||
"source_frame_index": item["evidence_binding"][
|
||||
"source_frame_index"
|
||||
],
|
||||
"label": item["e29_snapshot"].get("label"),
|
||||
}
|
||||
)
|
||||
file_name = f"{stratum}-{page_index:02d}.jpg"
|
||||
sheet_path = staging / file_name
|
||||
sheet.save(sheet_path, format="JPEG", quality=94, optimize=True)
|
||||
sheet_documents.append(
|
||||
{
|
||||
"path": file_name,
|
||||
"sha256": _sha256(sheet_path),
|
||||
"byte_length": sheet_path.stat().st_size,
|
||||
"stratum": stratum,
|
||||
"page": page_index,
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
identity = {
|
||||
"schema_version": SHEET_MANIFEST_SCHEMA,
|
||||
"materialization_id": root.name,
|
||||
"materialization_manifest_sha256": _sha256(root / "manifest.json"),
|
||||
"materialization_index_sha256": _sha256(index_path),
|
||||
"layout": {
|
||||
"columns": SHEET_COLUMNS,
|
||||
"rows": SHEET_ROWS,
|
||||
"tile_width": TILE_WIDTH,
|
||||
"tile_height": TILE_HEIGHT,
|
||||
},
|
||||
"sheets": sheet_documents,
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
document = {
|
||||
**identity,
|
||||
"identity_sha256": identity_sha256,
|
||||
"sheet_count": len(sheet_documents),
|
||||
"item_count": len(items),
|
||||
"authority": {
|
||||
"presentation_derivative_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
(staging / "manifest.json").write_bytes(_canonical_json(document) + b"\n")
|
||||
staging.rename(destination)
|
||||
return document
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--materialization-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
document = render_review_sheets(
|
||||
materialization_root=args.materialization_root,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"materialization_id": document["materialization_id"],
|
||||
"sheet_count": document["sheet_count"],
|
||||
"item_count": document["item_count"],
|
||||
"identity_sha256": document["identity_sha256"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build camera-backed LAB E30 evidence from immutable A2 sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.compute.e30_materialization import build_e30_materialization
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--review-pack", type=Path, required=True)
|
||||
parser.add_argument("--e29-root", type=Path, required=True)
|
||||
parser.add_argument("--source-result-root", type=Path, required=True)
|
||||
parser.add_argument("--source-pack-root", type=Path, required=True)
|
||||
parser.add_argument("--local-surface-root", type=Path, required=True)
|
||||
parser.add_argument("--camera-job", type=Path, required=True)
|
||||
parser.add_argument("--ffmpeg", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--output-root",
|
||||
type=Path,
|
||||
default=Path(".runtime/compute-experiments/e30/materializations"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = build_e30_materialization(
|
||||
review_pack_root=args.review_pack,
|
||||
e29_root=args.e29_root,
|
||||
source_result_root=args.source_result_root,
|
||||
source_pack_root=args.source_pack_root,
|
||||
local_surface_root=args.local_surface_root,
|
||||
camera_job_root=args.camera_job,
|
||||
ffmpeg_path=args.ffmpeg,
|
||||
output_root=args.output_root,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"result_root": str(result.result_root),
|
||||
"item_count": result.manifest["item_count"],
|
||||
"camera_evidence_available": result.manifest[
|
||||
"camera_evidence_available"
|
||||
],
|
||||
"human_review_complete": result.manifest[
|
||||
"human_review_complete"
|
||||
],
|
||||
"lab_published": result.manifest["lab_published"],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Exact camera-frame evidence for an E30 materialization.
|
||||
|
||||
The E30 point substrate is useful only when a reviewer can see the camera
|
||||
observation that caused the semantic claim. This module binds an immutable
|
||||
camera compute job and decodes only the exact source-frame indices selected by
|
||||
the E30 review pack. The canonical camera archive remains unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from k1link.compute.jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
_READ_CHUNK_BYTES: Final = 1024 * 1024
|
||||
_FFMPEG_TIMEOUT_SECONDS: Final = 600.0
|
||||
|
||||
|
||||
class E30CameraEvidenceError(ValueError):
|
||||
"""The bound camera job or a decoded evidence frame is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class E30CameraEvidenceSource:
|
||||
job: CameraComputeJob
|
||||
epoch_root: Path
|
||||
ffmpeg_path: Path
|
||||
encoder_identity: str
|
||||
|
||||
def identity(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "missioncore.e30-camera-evidence-source/v1",
|
||||
"job_id": self.job.job_id,
|
||||
"input_sha256": self.job.input_sha256,
|
||||
"session_id": self.job.session_id,
|
||||
"source_id": self.job.source_id,
|
||||
"codec_epoch": self.job.codec_epoch,
|
||||
"segment_count": self.job.segment_count,
|
||||
"source_frame_basis": "zero-based-decoded-video-frame",
|
||||
"camera_sequence_basis": "source-frame-index-plus-one",
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"identity": self.encoder_identity,
|
||||
"output": "jpeg-q2-yuvj420p",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def open_e30_camera_evidence_source(
|
||||
*,
|
||||
camera_job_root: Path,
|
||||
ffmpeg_path: Path,
|
||||
expected_session_id: str,
|
||||
expected_source_id: str,
|
||||
) -> E30CameraEvidenceSource:
|
||||
"""Validate the immutable camera job and bind its decoder identity."""
|
||||
|
||||
try:
|
||||
job = validate_camera_compute_job(camera_job_root)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise E30CameraEvidenceError("E30 camera compute job is invalid") from exc
|
||||
if (
|
||||
job.session_id != expected_session_id
|
||||
or job.source_id != expected_source_id
|
||||
):
|
||||
raise E30CameraEvidenceError(
|
||||
"E30 camera job does not match the LiDAR/projection source"
|
||||
)
|
||||
decoder = ffmpeg_path.expanduser().resolve(strict=True)
|
||||
if not decoder.is_file():
|
||||
raise E30CameraEvidenceError("ffmpeg is unavailable")
|
||||
try:
|
||||
version = subprocess.run(
|
||||
[str(decoder), "-version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise E30CameraEvidenceError("ffmpeg identity is unavailable") from exc
|
||||
version_line = version.stdout.splitlines()[0].strip()
|
||||
if not version_line:
|
||||
raise E30CameraEvidenceError("ffmpeg identity is empty")
|
||||
epoch = (
|
||||
job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ job.source_id
|
||||
/ f"epoch-{job.codec_epoch}"
|
||||
).resolve(strict=True)
|
||||
if not epoch.is_dir() or not epoch.is_relative_to(job.job_root):
|
||||
raise E30CameraEvidenceError("E30 camera epoch escaped its compute job")
|
||||
return E30CameraEvidenceSource(
|
||||
job=job,
|
||||
epoch_root=epoch,
|
||||
ffmpeg_path=decoder,
|
||||
encoder_identity=hashlib.sha256(version_line.encode()).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def materialize_e30_camera_frames(
|
||||
*,
|
||||
source: E30CameraEvidenceSource,
|
||||
source_frame_indices: tuple[int, ...],
|
||||
destination_root: Path,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> dict[int, dict[str, object]]:
|
||||
"""Decode exact source frames once and return verified artifact metadata."""
|
||||
|
||||
frames = tuple(sorted(set(source_frame_indices)))
|
||||
if (
|
||||
not frames
|
||||
or any(
|
||||
isinstance(frame, bool)
|
||||
or frame < 0
|
||||
or frame >= source.job.segment_count
|
||||
for frame in frames
|
||||
)
|
||||
or width < 1
|
||||
or height < 1
|
||||
):
|
||||
raise E30CameraEvidenceError("E30 camera frame selection is invalid")
|
||||
destination = destination_root.resolve()
|
||||
destination.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
init_path = source.epoch_root / "init.mp4"
|
||||
segments = tuple(
|
||||
source.epoch_root / "segments" / f"{sequence}.m4s"
|
||||
for sequence in range(1, source.job.segment_count + 1)
|
||||
)
|
||||
select = "+".join(f"eq(n\\,{frame})" for frame in frames)
|
||||
temporary_pattern = destination / ".decoded-%06d.jpg"
|
||||
read_fd, write_fd = os.pipe()
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(source.ffmpeg_path),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
f"select={select}",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-frames:v",
|
||||
str(len(frames)),
|
||||
"-q:v",
|
||||
"2",
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-start_number",
|
||||
"0",
|
||||
str(temporary_pattern),
|
||||
],
|
||||
stdin=read_fd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
)
|
||||
os.close(read_fd)
|
||||
feeder_errors: list[BaseException] = []
|
||||
|
||||
def feed_archive() -> None:
|
||||
try:
|
||||
with os.fdopen(write_fd, "wb", buffering=0) as sink:
|
||||
for path in (init_path, *segments):
|
||||
with path.open("rb") as stream:
|
||||
shutil.copyfileobj(
|
||||
stream,
|
||||
sink,
|
||||
length=_READ_CHUNK_BYTES,
|
||||
)
|
||||
except BrokenPipeError:
|
||||
return
|
||||
except BaseException as exc: # pragma: no cover - child pipe boundary
|
||||
feeder_errors.append(exc)
|
||||
|
||||
feeder = threading.Thread(
|
||||
target=feed_archive,
|
||||
name="e30-camera-evidence-ffmpeg",
|
||||
daemon=True,
|
||||
)
|
||||
feeder.start()
|
||||
try:
|
||||
_, stderr = process.communicate(timeout=_FFMPEG_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
raise E30CameraEvidenceError("E30 camera extraction timed out") from exc
|
||||
feeder.join(timeout=10.0)
|
||||
if feeder.is_alive():
|
||||
raise E30CameraEvidenceError("E30 camera archive feeder did not stop")
|
||||
if feeder_errors:
|
||||
raise E30CameraEvidenceError(
|
||||
"E30 camera archive streaming failed"
|
||||
) from feeder_errors[0]
|
||||
if process.returncode != 0:
|
||||
message = (stderr or b"").decode(errors="replace").strip()[-1000:]
|
||||
raise E30CameraEvidenceError(
|
||||
f"ffmpeg rejected the E30 camera archive: {message}"
|
||||
)
|
||||
|
||||
artifacts: dict[int, dict[str, object]] = {}
|
||||
for ordinal, frame_index in enumerate(frames):
|
||||
decoded = destination / f".decoded-{ordinal:06d}.jpg"
|
||||
final = destination / f"frame-{frame_index:06d}.jpg"
|
||||
if decoded.is_symlink() or not decoded.is_file():
|
||||
raise E30CameraEvidenceError(
|
||||
"ffmpeg returned an incomplete E30 camera frame set"
|
||||
)
|
||||
try:
|
||||
with Image.open(decoded) as image:
|
||||
image.verify()
|
||||
with Image.open(decoded) as image:
|
||||
decoded_size = image.size
|
||||
decoded_format = image.format
|
||||
except (OSError, ValueError) as exc:
|
||||
raise E30CameraEvidenceError(
|
||||
"an E30 camera frame is not a valid image"
|
||||
) from exc
|
||||
if decoded_size != (width, height) or decoded_format != "JPEG":
|
||||
raise E30CameraEvidenceError(
|
||||
"an E30 camera frame has an unexpected format"
|
||||
)
|
||||
os.replace(decoded, final)
|
||||
artifacts[frame_index] = {
|
||||
"role": "camera-frame",
|
||||
"path": f"frames/{final.name}",
|
||||
"media_type": "image/jpeg",
|
||||
"byte_length": final.stat().st_size,
|
||||
"sha256": _sha256_file(final),
|
||||
"width": width,
|
||||
"height": height,
|
||||
"source_frame_index": frame_index,
|
||||
"camera_sequence": frame_index + 1,
|
||||
"exact_source_frame": True,
|
||||
}
|
||||
if any(destination.glob(".decoded-*.jpg")):
|
||||
raise E30CameraEvidenceError("ffmpeg returned undeclared E30 frames")
|
||||
return artifacts
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(_READ_CHUNK_BYTES):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,645 @@
|
||||
"""Immutable AI-assisted engineering generations over E30 evidence.
|
||||
|
||||
This contract is intentionally separate from ``e30_human_review``. It records
|
||||
an explicitly AI-assisted engineering assessment, retains uncertainty, and
|
||||
routes only bounded exceptions to a human. It never claims human ground truth
|
||||
or navigation/safety acceptance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
|
||||
E30_ENGINEERING_DECISION_SCHEMA_V1: Final = (
|
||||
"missioncore.e30-engineering-decision/v1"
|
||||
)
|
||||
E30_ENGINEERING_DECISION_SCHEMA: Final = (
|
||||
"missioncore.e30-engineering-decision/v2"
|
||||
)
|
||||
E30_ENGINEERING_DECISION_SCHEMAS: Final = (
|
||||
E30_ENGINEERING_DECISION_SCHEMA_V1,
|
||||
E30_ENGINEERING_DECISION_SCHEMA,
|
||||
)
|
||||
E30_ENGINEERING_GENERATION_SCHEMA: Final = (
|
||||
"missioncore.e30-engineering-generation/v1"
|
||||
)
|
||||
E30_ENGINEERING_SUMMARY_SCHEMA: Final = (
|
||||
"missioncore.e30-engineering-summary/v1"
|
||||
)
|
||||
E30_ENGINEERING_CAUSES_SCHEMA: Final = (
|
||||
"missioncore.e30-engineering-causes/v1"
|
||||
)
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA_V1: Final = (
|
||||
"missioncore.e30-engineering-exception/v1"
|
||||
)
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA: Final = (
|
||||
"missioncore.e30-engineering-exception/v2"
|
||||
)
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMAS: Final = (
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA_V1,
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA,
|
||||
)
|
||||
E30_ENGINEERING_DECISIONS_NAME: Final = "engineering-decisions.jsonl"
|
||||
E30_ENGINEERING_EXCEPTIONS_NAME: Final = "human-exceptions.jsonl"
|
||||
E30_ENGINEERING_CAUSES_NAME: Final = "cause-distribution.json"
|
||||
E30_ENGINEERING_SUMMARY_NAME: Final = "summary.json"
|
||||
E30_ENGINEERING_MANIFEST_NAME: Final = "manifest.json"
|
||||
|
||||
STRATA: Final = (
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
)
|
||||
VERDICTS: Final = ("confirmed", "corrected", "insufficient-evidence")
|
||||
DETECTOR_ASSESSMENTS: Final = (
|
||||
"valid",
|
||||
"class-mismatch",
|
||||
"false-positive",
|
||||
"missed-object",
|
||||
"not-applicable",
|
||||
"insufficient-evidence",
|
||||
)
|
||||
PROJECTION_ASSESSMENTS: Final = ("aligned", "misaligned", "not-assessable")
|
||||
OWNERSHIP_ASSESSMENTS: Final = (
|
||||
"object",
|
||||
"surface-or-background",
|
||||
"static-environment",
|
||||
"self",
|
||||
"insufficient-support",
|
||||
"not-applicable",
|
||||
"mixed",
|
||||
"insufficient-evidence",
|
||||
)
|
||||
EXCEPTION_DISPOSITIONS: Final = (
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
)
|
||||
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_GENERATION_ID = re.compile(r"^e30-engineering-generation-[a-f0-9]{64}$")
|
||||
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_SAFE_PRODUCER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}$")
|
||||
|
||||
|
||||
class E30EngineeringGenerationError(RuntimeError):
|
||||
"""An engineering generation or its immutable source is invalid."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _regular_file(root: Path, relative: object) -> Path:
|
||||
if not isinstance(relative, str) or not relative or relative.startswith("/"):
|
||||
raise E30EngineeringGenerationError("artifact path is invalid")
|
||||
path = root / relative
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or root.resolve() not in path.resolve().parents
|
||||
):
|
||||
raise E30EngineeringGenerationError("artifact is unavailable")
|
||||
return path
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30EngineeringGenerationError("required JSON is unavailable")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30EngineeringGenerationError("required JSON is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringGenerationError("required JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30EngineeringGenerationError("required JSONL is unavailable")
|
||||
values: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30EngineeringGenerationError(
|
||||
f"invalid JSONL line {line_number}"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringGenerationError(
|
||||
f"JSONL line {line_number} must be an object"
|
||||
)
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _validate_artifact(root: Path, value: object) -> Path:
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringGenerationError("artifact metadata is invalid")
|
||||
path = _regular_file(root, value.get("path"))
|
||||
digest = value.get("sha256")
|
||||
byte_length = value.get("byte_length")
|
||||
if (
|
||||
not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length <= 0
|
||||
or path.stat().st_size != byte_length
|
||||
or _sha256(path) != digest
|
||||
):
|
||||
raise E30EngineeringGenerationError("artifact content changed")
|
||||
return path
|
||||
|
||||
|
||||
def _materialization(
|
||||
root: Path,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
root = root.resolve()
|
||||
if (
|
||||
root.is_symlink()
|
||||
or not root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(root.name) is None
|
||||
):
|
||||
raise E30EngineeringGenerationError("materialization root is invalid")
|
||||
manifest_path = root / "manifest.json"
|
||||
index_path = root / "materialized-items.jsonl"
|
||||
manifest = _read_json(manifest_path)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version")
|
||||
!= "missioncore.e30-evidence-materialization/v2"
|
||||
or manifest.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
!= identity_sha256
|
||||
or root.name != f"e30-materialization-{identity_sha256}"
|
||||
or manifest.get("camera_evidence_available") is not True
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30EngineeringGenerationError("materialization identity changed")
|
||||
authority = manifest.get("authority")
|
||||
if (
|
||||
not isinstance(authority, dict)
|
||||
or authority.get("commands_enabled") is not False
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30EngineeringGenerationError("materialization authority differs")
|
||||
items = _read_jsonl(index_path)
|
||||
if (
|
||||
len(items) != manifest.get("item_count")
|
||||
or len(items) != 486
|
||||
or len({item.get("item_id") for item in items}) != len(items)
|
||||
):
|
||||
raise E30EngineeringGenerationError("materialization item count differs")
|
||||
for expected_sequence, item in enumerate(items):
|
||||
if (
|
||||
item.get("schema_version")
|
||||
!= "missioncore.e30-evidence-materialization-item/v2"
|
||||
or item.get("sequence") != expected_sequence
|
||||
or _ITEM_ID.fullmatch(str(item.get("item_id"))) is None
|
||||
or item.get("stratum") not in STRATA
|
||||
):
|
||||
raise E30EngineeringGenerationError("materialization item changed")
|
||||
_validate_artifact(root, item.get("artifact"))
|
||||
_validate_artifact(root, item.get("camera_frame"))
|
||||
return manifest, items
|
||||
|
||||
|
||||
def _reason_taxonomy(
|
||||
root: Path,
|
||||
*,
|
||||
expected_binding: object,
|
||||
) -> tuple[str, ...]:
|
||||
root = root.resolve()
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise E30EngineeringGenerationError("review pack root is invalid")
|
||||
manifest = _read_json(root / "manifest.json")
|
||||
identity = manifest.get("identity")
|
||||
if not isinstance(expected_binding, dict) or not isinstance(identity, dict):
|
||||
raise E30EngineeringGenerationError("review pack binding is invalid")
|
||||
if (
|
||||
manifest.get("schema_version")
|
||||
!= "missioncore.e30-evidence-review-pack/v1"
|
||||
or manifest.get("result_id") != expected_binding.get("result_id")
|
||||
or root.name != expected_binding.get("result_id")
|
||||
or manifest.get("identity_sha256")
|
||||
!= expected_binding.get("identity_sha256")
|
||||
or manifest.get("selected_item_count")
|
||||
!= expected_binding.get("item_count")
|
||||
):
|
||||
raise E30EngineeringGenerationError("review pack identity changed")
|
||||
reason_taxonomy = identity.get("reason_taxonomy")
|
||||
if (
|
||||
not isinstance(reason_taxonomy, list)
|
||||
or not reason_taxonomy
|
||||
or not all(isinstance(value, str) and value for value in reason_taxonomy)
|
||||
):
|
||||
raise E30EngineeringGenerationError("reason taxonomy is unavailable")
|
||||
return tuple(reason_taxonomy)
|
||||
|
||||
|
||||
def _sheets(
|
||||
root: Path,
|
||||
*,
|
||||
materialization_root: Path,
|
||||
) -> tuple[dict[str, Any], dict[str, dict[str, object]]]:
|
||||
root = root.resolve()
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise E30EngineeringGenerationError("review sheet root is invalid")
|
||||
manifest = _read_json(root / "manifest.json")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
identity = {
|
||||
key: value
|
||||
for key, value in manifest.items()
|
||||
if key
|
||||
not in {
|
||||
"identity_sha256",
|
||||
"sheet_count",
|
||||
"item_count",
|
||||
"authority",
|
||||
}
|
||||
}
|
||||
if (
|
||||
manifest.get("schema_version")
|
||||
!= "missioncore.e30-engineering-review-sheets/v1"
|
||||
or manifest.get("materialization_id") != materialization_root.name
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
!= identity_sha256
|
||||
or manifest.get("item_count") != 486
|
||||
or manifest.get("sheet_count") != len(manifest.get("sheets", []))
|
||||
):
|
||||
raise E30EngineeringGenerationError("review sheet identity changed")
|
||||
by_item: dict[str, dict[str, object]] = {}
|
||||
sheets = manifest.get("sheets")
|
||||
if not isinstance(sheets, list):
|
||||
raise E30EngineeringGenerationError("review sheet list is invalid")
|
||||
for sheet in sheets:
|
||||
if not isinstance(sheet, dict):
|
||||
raise E30EngineeringGenerationError("review sheet metadata is invalid")
|
||||
path = _regular_file(root, sheet.get("path"))
|
||||
if (
|
||||
path.stat().st_size != sheet.get("byte_length")
|
||||
or _sha256(path) != sheet.get("sha256")
|
||||
or not isinstance(sheet.get("entries"), list)
|
||||
):
|
||||
raise E30EngineeringGenerationError("review sheet changed")
|
||||
for entry in sheet["entries"]:
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or _ITEM_ID.fullmatch(str(entry.get("item_id"))) is None
|
||||
or entry["item_id"] in by_item
|
||||
):
|
||||
raise E30EngineeringGenerationError("review sheet entry is invalid")
|
||||
by_item[str(entry["item_id"])] = {
|
||||
"path": sheet["path"],
|
||||
"sha256": sheet["sha256"],
|
||||
"ordinal": entry.get("ordinal"),
|
||||
}
|
||||
if len(by_item) != 486:
|
||||
raise E30EngineeringGenerationError("review sheet coverage differs")
|
||||
return manifest, by_item
|
||||
|
||||
|
||||
def _validate_decision(
|
||||
*,
|
||||
value: dict[str, Any],
|
||||
item: dict[str, Any],
|
||||
reason_taxonomy: tuple[str, ...],
|
||||
expected_sheet: dict[str, object],
|
||||
) -> dict[str, Any]:
|
||||
confidence = value.get("confidence")
|
||||
verdict = value.get("verdict")
|
||||
effective_stratum = value.get("effective_stratum")
|
||||
cause_code = value.get("cause_code")
|
||||
exception_required = value.get("human_exception_required")
|
||||
exception_reason = value.get("exception_reason")
|
||||
note = value.get("evidence_note")
|
||||
review_prompt = value.get("review_prompt")
|
||||
if (
|
||||
value.get("schema_version") != E30_ENGINEERING_DECISION_SCHEMA
|
||||
or value.get("sequence") != item["sequence"]
|
||||
or value.get("item_id") != item["item_id"]
|
||||
or value.get("review_key") != item["review_key"]
|
||||
or value.get("source_stratum") != item["stratum"]
|
||||
or verdict not in VERDICTS
|
||||
or effective_stratum not in (*STRATA, None)
|
||||
or value.get("detector_assessment") not in DETECTOR_ASSESSMENTS
|
||||
or value.get("projection_assessment") not in PROJECTION_ASSESSMENTS
|
||||
or value.get("point_ownership") not in OWNERSHIP_ASSESSMENTS
|
||||
or not isinstance(confidence, (int, float))
|
||||
or isinstance(confidence, bool)
|
||||
or not math.isfinite(float(confidence))
|
||||
or not 0.0 <= float(confidence) <= 1.0
|
||||
or not isinstance(exception_required, bool)
|
||||
or exception_reason not in (None, "ambiguity", "high-impact")
|
||||
or exception_required != (exception_reason is not None)
|
||||
or not isinstance(note, str)
|
||||
or not note.strip()
|
||||
or len(note) > 1_000
|
||||
or value.get("review_sheet") != expected_sheet
|
||||
):
|
||||
raise E30EngineeringGenerationError("engineering decision is invalid")
|
||||
normalized_review_prompt: dict[str, object] | None = None
|
||||
if exception_required:
|
||||
if (
|
||||
not isinstance(review_prompt, dict)
|
||||
or set(review_prompt) != {"question", "focus", "effects"}
|
||||
or not isinstance(review_prompt.get("question"), str)
|
||||
or not review_prompt["question"].strip()
|
||||
or len(review_prompt["question"]) > 240
|
||||
or not isinstance(review_prompt.get("focus"), str)
|
||||
or not review_prompt["focus"].strip()
|
||||
or len(review_prompt["focus"]) > 500
|
||||
or not isinstance(review_prompt.get("effects"), dict)
|
||||
or set(review_prompt["effects"]) != set(EXCEPTION_DISPOSITIONS)
|
||||
or any(
|
||||
not isinstance(review_prompt["effects"].get(disposition), str)
|
||||
or not review_prompt["effects"][disposition].strip()
|
||||
or len(review_prompt["effects"][disposition]) > 500
|
||||
for disposition in EXCEPTION_DISPOSITIONS
|
||||
)
|
||||
):
|
||||
raise E30EngineeringGenerationError(
|
||||
"engineering exception review prompt is invalid"
|
||||
)
|
||||
normalized_review_prompt = {
|
||||
"question": review_prompt["question"].strip(),
|
||||
"focus": review_prompt["focus"].strip(),
|
||||
"effects": {
|
||||
disposition: review_prompt["effects"][disposition].strip()
|
||||
for disposition in EXCEPTION_DISPOSITIONS
|
||||
},
|
||||
}
|
||||
elif review_prompt is not None:
|
||||
raise E30EngineeringGenerationError(
|
||||
"non-exception decision carries a review prompt"
|
||||
)
|
||||
if cause_code is not None and cause_code not in reason_taxonomy:
|
||||
raise E30EngineeringGenerationError("decision cause is outside taxonomy")
|
||||
if verdict == "confirmed" and effective_stratum != item["stratum"]:
|
||||
raise E30EngineeringGenerationError("confirmed decision changes stratum")
|
||||
if verdict == "corrected" and effective_stratum == item["stratum"]:
|
||||
raise E30EngineeringGenerationError("corrected decision keeps stratum")
|
||||
if verdict == "insufficient-evidence" and (
|
||||
effective_stratum is not None or not exception_required
|
||||
):
|
||||
raise E30EngineeringGenerationError(
|
||||
"insufficient decision must route an exception"
|
||||
)
|
||||
if verdict != "insufficient-evidence" and confidence < 0.5:
|
||||
raise E30EngineeringGenerationError("issued verdict confidence is too low")
|
||||
return {
|
||||
**value,
|
||||
"confidence": round(float(confidence), 4),
|
||||
"evidence_note": note.strip(),
|
||||
"review_prompt": normalized_review_prompt,
|
||||
}
|
||||
|
||||
|
||||
def build_e30_engineering_generation(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
review_pack_root: Path,
|
||||
review_sheets_root: Path,
|
||||
decisions_path: Path,
|
||||
output_root: Path,
|
||||
producer_id: str,
|
||||
method_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate 486 decisions and publish one immutable engineering generation."""
|
||||
|
||||
if (
|
||||
_SAFE_PRODUCER.fullmatch(producer_id) is None
|
||||
or _SAFE_PRODUCER.fullmatch(method_id) is None
|
||||
):
|
||||
raise E30EngineeringGenerationError("producer identity is invalid")
|
||||
materialization_root = materialization_root.resolve()
|
||||
materialization_manifest, items = _materialization(
|
||||
materialization_root
|
||||
)
|
||||
taxonomy = _reason_taxonomy(
|
||||
review_pack_root,
|
||||
expected_binding=materialization_manifest["identity"].get("review_pack"),
|
||||
)
|
||||
sheet_manifest, sheets_by_item = _sheets(
|
||||
review_sheets_root,
|
||||
materialization_root=materialization_root,
|
||||
)
|
||||
raw_decisions = _read_jsonl(decisions_path)
|
||||
if len(raw_decisions) != len(items):
|
||||
raise E30EngineeringGenerationError("engineering coverage is incomplete")
|
||||
decisions = [
|
||||
_validate_decision(
|
||||
value=value,
|
||||
item=item,
|
||||
reason_taxonomy=taxonomy,
|
||||
expected_sheet=sheets_by_item[str(item["item_id"])],
|
||||
)
|
||||
for item, value in zip(items, raw_decisions, strict=True)
|
||||
]
|
||||
if len({decision["item_id"] for decision in decisions}) != len(decisions):
|
||||
raise E30EngineeringGenerationError("engineering decision ids repeat")
|
||||
|
||||
source = {
|
||||
"materialization_id": materialization_root.name,
|
||||
"materialization_identity_sha256": materialization_manifest[
|
||||
"identity_sha256"
|
||||
],
|
||||
"materialization_manifest_sha256": _sha256(
|
||||
materialization_root / "manifest.json"
|
||||
),
|
||||
"materialization_index_sha256": _sha256(
|
||||
materialization_root / "materialized-items.jsonl"
|
||||
),
|
||||
"review_pack_id": materialization_manifest["identity"]["review_pack"][
|
||||
"result_id"
|
||||
],
|
||||
"item_count": len(items),
|
||||
}
|
||||
producer = {
|
||||
"kind": "ai-assisted-engineering-review",
|
||||
"producer_id": producer_id,
|
||||
"method_id": method_id,
|
||||
"review_sheet_identity_sha256": sheet_manifest["identity_sha256"],
|
||||
"claims_human_ground_truth": False,
|
||||
}
|
||||
decisions_input_sha256 = _sha256(decisions_path)
|
||||
identity = {
|
||||
"schema_version": E30_ENGINEERING_GENERATION_SCHEMA,
|
||||
"source": source,
|
||||
"producer": producer,
|
||||
"decisions_input_sha256": decisions_input_sha256,
|
||||
"decision_content_sha256": hashlib.sha256(
|
||||
b"".join(_canonical_json(value) + b"\n" for value in decisions)
|
||||
).hexdigest(),
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"e30-engineering-generation-{identity_sha256}"
|
||||
if _GENERATION_ID.fullmatch(generation_id) is None:
|
||||
raise AssertionError("generated E30 identity is invalid")
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
output_root = output_root.resolve()
|
||||
destination = output_root / generation_id
|
||||
if destination.exists():
|
||||
existing = _read_json(destination / E30_ENGINEERING_MANIFEST_NAME)
|
||||
if (
|
||||
destination.is_symlink()
|
||||
or not destination.is_dir()
|
||||
or existing.get("identity") != identity
|
||||
or existing.get("identity_sha256") != identity_sha256
|
||||
):
|
||||
raise E30EngineeringGenerationError("generation identity collision")
|
||||
return existing
|
||||
|
||||
verdicts = Counter(str(value["verdict"]) for value in decisions)
|
||||
causes = Counter(
|
||||
str(value["cause_code"])
|
||||
for value in decisions
|
||||
if value.get("cause_code") is not None
|
||||
)
|
||||
detector = Counter(str(value["detector_assessment"]) for value in decisions)
|
||||
projection = Counter(str(value["projection_assessment"]) for value in decisions)
|
||||
ownership = Counter(str(value["point_ownership"]) for value in decisions)
|
||||
exceptions = [value for value in decisions if value["human_exception_required"]]
|
||||
summary = {
|
||||
"schema_version": E30_ENGINEERING_SUMMARY_SCHEMA,
|
||||
"item_count": len(decisions),
|
||||
"reviewed_item_count": len(decisions),
|
||||
"verdict_distribution": dict(sorted(verdicts.items())),
|
||||
"detector_distribution": dict(sorted(detector.items())),
|
||||
"projection_distribution": dict(sorted(projection.items())),
|
||||
"point_ownership_distribution": dict(sorted(ownership.items())),
|
||||
"human_exception_count": len(exceptions),
|
||||
"mean_confidence": round(
|
||||
sum(float(value["confidence"]) for value in decisions) / len(decisions),
|
||||
4,
|
||||
),
|
||||
}
|
||||
cause_document = {
|
||||
"schema_version": E30_ENGINEERING_CAUSES_SCHEMA,
|
||||
"item_count_with_cause": sum(causes.values()),
|
||||
"reasons": [
|
||||
{"reason_code": reason, "count": count}
|
||||
for reason, count in sorted(causes.items())
|
||||
],
|
||||
}
|
||||
|
||||
staging = Path(tempfile.mkdtemp(prefix=f".{generation_id}.", dir=output_root))
|
||||
try:
|
||||
decisions_output = staging / E30_ENGINEERING_DECISIONS_NAME
|
||||
with decisions_output.open("wb") as stream:
|
||||
for decision in decisions:
|
||||
stream.write(_canonical_json(decision) + b"\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
exception_output = staging / E30_ENGINEERING_EXCEPTIONS_NAME
|
||||
with exception_output.open("wb") as stream:
|
||||
for decision in exceptions:
|
||||
exception = {
|
||||
"schema_version": E30_ENGINEERING_EXCEPTION_SCHEMA,
|
||||
"sequence": decision["sequence"],
|
||||
"item_id": decision["item_id"],
|
||||
"review_key": decision["review_key"],
|
||||
"source_stratum": decision["source_stratum"],
|
||||
"exception_reason": decision["exception_reason"],
|
||||
"confidence": decision["confidence"],
|
||||
"review_sheet": decision["review_sheet"],
|
||||
"evidence_note": decision["evidence_note"],
|
||||
"review_prompt": decision["review_prompt"],
|
||||
}
|
||||
stream.write(_canonical_json(exception) + b"\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
write_json_atomic(staging / E30_ENGINEERING_SUMMARY_NAME, summary)
|
||||
write_json_atomic(staging / E30_ENGINEERING_CAUSES_NAME, cause_document)
|
||||
artifacts = [
|
||||
{
|
||||
"role": "engineering-decisions",
|
||||
"path": E30_ENGINEERING_DECISIONS_NAME,
|
||||
},
|
||||
{
|
||||
"role": "human-exceptions",
|
||||
"path": E30_ENGINEERING_EXCEPTIONS_NAME,
|
||||
},
|
||||
{
|
||||
"role": "engineering-summary",
|
||||
"path": E30_ENGINEERING_SUMMARY_NAME,
|
||||
},
|
||||
{
|
||||
"role": "cause-distribution",
|
||||
"path": E30_ENGINEERING_CAUSES_NAME,
|
||||
},
|
||||
]
|
||||
artifact_documents = []
|
||||
for artifact in artifacts:
|
||||
path = staging / artifact["path"]
|
||||
artifact_documents.append(
|
||||
{
|
||||
**artifact,
|
||||
"sha256": _sha256(path),
|
||||
"byte_length": path.stat().st_size,
|
||||
}
|
||||
)
|
||||
created_at_utc = utc_now_iso()
|
||||
manifest = {
|
||||
"schema_version": E30_ENGINEERING_GENERATION_SCHEMA,
|
||||
"result_id": generation_id,
|
||||
"identity": identity,
|
||||
"identity_sha256": identity_sha256,
|
||||
"created_at_utc": created_at_utc,
|
||||
"summary": summary,
|
||||
"cause_distribution": cause_document,
|
||||
"artifacts": artifact_documents,
|
||||
"ai_review_complete": True,
|
||||
"human_exception_complete": len(exceptions) == 0,
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
write_json_atomic(staging / E30_ENGINEERING_MANIFEST_NAME, manifest)
|
||||
staging.rename(destination)
|
||||
return manifest
|
||||
except Exception:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
@@ -0,0 +1,759 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
|
||||
E30_HUMAN_REVIEW_DRAFT_SCHEMA: Final = "missioncore.e30-human-review-draft/v2"
|
||||
E30_HUMAN_REVIEW_EVENT_SCHEMA: Final = "missioncore.e30-human-review-event/v2"
|
||||
E30_HUMAN_REVIEW_DECISION_SCHEMA: Final = (
|
||||
"missioncore.e30-human-review-decision/v2"
|
||||
)
|
||||
E30_HUMAN_REVIEW_GENERATION_SCHEMA: Final = (
|
||||
"missioncore.e30-human-review-generation/v2"
|
||||
)
|
||||
E30_HUMAN_REVIEW_FINALIZATION_SCHEMA: Final = (
|
||||
"missioncore.e30-human-review-finalization/v2"
|
||||
)
|
||||
E30_HUMAN_REVIEW_DECISIONS_NAME: Final = "review-decisions.jsonl"
|
||||
E30_HUMAN_REVIEW_DISTRIBUTION_NAME: Final = "disposition-distribution.json"
|
||||
E30_HUMAN_REVIEW_MANIFEST_NAME: Final = "manifest.json"
|
||||
|
||||
E30_STRATA: Final = (
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
)
|
||||
E30_DISPOSITIONS: Final = (
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
)
|
||||
|
||||
E30Stratum = Literal[
|
||||
"conflict",
|
||||
"agree",
|
||||
"camera-only",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
]
|
||||
E30ExceptionDisposition = Literal[
|
||||
"object-present",
|
||||
"background-or-noise",
|
||||
"insufficient-evidence",
|
||||
]
|
||||
|
||||
_DRAFT_ID = re.compile(r"^e30-human-draft-[a-f0-9]{64}$")
|
||||
_GENERATION_ID = re.compile(r"^e30-review-generation-[a-f0-9]{64}$")
|
||||
_EVENT_ID = re.compile(r"^e30-review-event-[a-f0-9]{64}$")
|
||||
_CONTENT_ID = re.compile(r"^[a-z0-9][a-z0-9-]*-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_REVIEWER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
|
||||
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_MAX_JSON_BYTES: Final = 512 * 1024
|
||||
_MAX_EVENT_LOG_BYTES: Final = 32 * 1024 * 1024
|
||||
_MAX_NOTES_LENGTH: Final = 2_000
|
||||
|
||||
|
||||
class E30HumanReviewError(RuntimeError):
|
||||
"""Base error for the exception-review lifecycle."""
|
||||
|
||||
|
||||
class E30HumanReviewNotFoundError(E30HumanReviewError):
|
||||
"""The requested draft or generation does not exist."""
|
||||
|
||||
|
||||
class E30HumanReviewConflictError(E30HumanReviewError):
|
||||
"""The request conflicts with the current append-only revision."""
|
||||
|
||||
|
||||
class E30HumanReviewValidationError(E30HumanReviewError):
|
||||
"""A reviewer decision violates the frozen review protocol."""
|
||||
|
||||
|
||||
class E30HumanReviewIntegrityError(E30HumanReviewError):
|
||||
"""Stored reviewer evidence is incomplete, changed or incompatible."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E30ReviewSubject:
|
||||
item_id: str
|
||||
sequence: int
|
||||
source_stratum: E30Stratum
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E30ReviewSubstrate:
|
||||
materialization_id: str
|
||||
materialization_identity_sha256: str
|
||||
review_pack_id: str
|
||||
review_items_sha256: str
|
||||
reason_taxonomy: tuple[str, ...]
|
||||
subjects: tuple[E30ReviewSubject, ...]
|
||||
engineering_generation_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
_CONTENT_ID.fullmatch(self.materialization_id) is None
|
||||
or _CONTENT_ID.fullmatch(self.review_pack_id) is None
|
||||
or _SHA256.fullmatch(self.materialization_identity_sha256) is None
|
||||
or _SHA256.fullmatch(self.review_items_sha256) is None
|
||||
or (
|
||||
self.engineering_generation_id is not None
|
||||
and _CONTENT_ID.fullmatch(self.engineering_generation_id) is None
|
||||
)
|
||||
):
|
||||
raise E30HumanReviewValidationError("E30 source binding is invalid")
|
||||
if not self.subjects:
|
||||
raise E30HumanReviewValidationError("E30 review substrate is empty")
|
||||
if tuple(subject.sequence for subject in self.subjects) != tuple(
|
||||
range(len(self.subjects))
|
||||
):
|
||||
raise E30HumanReviewValidationError("E30 subjects are out of sequence")
|
||||
if len({subject.item_id for subject in self.subjects}) != len(self.subjects):
|
||||
raise E30HumanReviewValidationError("E30 subject ids are not unique")
|
||||
if any(
|
||||
subject.source_stratum not in E30_STRATA
|
||||
or _CONTENT_ID.fullmatch(subject.item_id) is None
|
||||
for subject in self.subjects
|
||||
):
|
||||
raise E30HumanReviewValidationError("E30 subject is invalid")
|
||||
|
||||
@property
|
||||
def item_set_sha256(self) -> str:
|
||||
return hashlib.sha256(
|
||||
_canonical_json(
|
||||
[
|
||||
{
|
||||
"item_id": subject.item_id,
|
||||
"sequence": subject.sequence,
|
||||
"source_stratum": subject.source_stratum,
|
||||
}
|
||||
for subject in self.subjects
|
||||
]
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
def binding(self) -> dict[str, object]:
|
||||
return {
|
||||
"materialization_id": self.materialization_id,
|
||||
"materialization_identity_sha256": self.materialization_identity_sha256,
|
||||
"review_pack_id": self.review_pack_id,
|
||||
"review_items_sha256": self.review_items_sha256,
|
||||
"engineering_generation_id": self.engineering_generation_id,
|
||||
"item_count": len(self.subjects),
|
||||
"item_set_sha256": self.item_set_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or not 0 < path.stat().st_size <= _MAX_JSON_BYTES
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 human-review JSON is unavailable")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30HumanReviewIntegrityError("E30 human-review JSON is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30HumanReviewIntegrityError("E30 human-review JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_root(root: Path) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise E30HumanReviewIntegrityError("E30 human-review root is invalid")
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def _normalized_reviewer_id(value: str) -> str:
|
||||
value = value.strip()
|
||||
if _REVIEWER_ID.fullmatch(value) is None:
|
||||
raise E30HumanReviewValidationError("reviewer_id is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _normalized_notes(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if len(value) > _MAX_NOTES_LENGTH:
|
||||
raise E30HumanReviewValidationError("review notes are too long")
|
||||
return value or None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_lock(path: Path) -> Iterator[None]:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class E30HumanReviewStore:
|
||||
"""Append-only human decisions over AI-selected exception items."""
|
||||
|
||||
def __init__(self, *, draft_root: Path, generation_root: Path) -> None:
|
||||
self.draft_root = _safe_root(draft_root)
|
||||
self.generation_root = _safe_root(generation_root)
|
||||
|
||||
def create_or_resume(
|
||||
self,
|
||||
*,
|
||||
substrate: E30ReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
) -> dict[str, object]:
|
||||
if substrate.engineering_generation_id is None:
|
||||
raise E30HumanReviewValidationError(
|
||||
"engineering generation binding is required"
|
||||
)
|
||||
reviewer_id = _normalized_reviewer_id(reviewer_id)
|
||||
identity = {
|
||||
"schema_version": E30_HUMAN_REVIEW_DRAFT_SCHEMA,
|
||||
"protocol": "exception-disposition-review/v1",
|
||||
"source": substrate.binding(),
|
||||
"reviewer_id": reviewer_id,
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
draft_id = f"e30-human-draft-{identity_sha256}"
|
||||
root = self.draft_root / draft_id
|
||||
with _exclusive_lock(self.draft_root / ".locks" / f"{draft_id}.lock"):
|
||||
if root.is_symlink():
|
||||
raise E30HumanReviewIntegrityError("E30 draft must not be a symlink")
|
||||
root.mkdir(mode=0o700, parents=False, exist_ok=True)
|
||||
manifest_path = root / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
write_json_atomic(
|
||||
manifest_path,
|
||||
{
|
||||
"schema_version": E30_HUMAN_REVIEW_DRAFT_SCHEMA,
|
||||
"draft_id": draft_id,
|
||||
"identity": identity,
|
||||
"identity_sha256": identity_sha256,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"source": substrate.binding(),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
return self._summary(root, substrate)
|
||||
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
draft_id: str,
|
||||
substrate: E30ReviewSubstrate,
|
||||
) -> dict[str, object]:
|
||||
return self._summary(self._draft_root(draft_id), substrate)
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
*,
|
||||
draft_id: str,
|
||||
substrate: E30ReviewSubstrate,
|
||||
item_id: str,
|
||||
expected_revision: int,
|
||||
idempotency_key: str,
|
||||
disposition: E30ExceptionDisposition,
|
||||
notes: str | None,
|
||||
) -> dict[str, object]:
|
||||
if _IDEMPOTENCY_KEY.fullmatch(idempotency_key) is None:
|
||||
raise E30HumanReviewValidationError("idempotency_key is invalid")
|
||||
if expected_revision < 0:
|
||||
raise E30HumanReviewValidationError("expected_revision is invalid")
|
||||
subject = next(
|
||||
(value for value in substrate.subjects if value.item_id == item_id),
|
||||
None,
|
||||
)
|
||||
if subject is None:
|
||||
raise E30HumanReviewNotFoundError("E30 review item was not found")
|
||||
decision = self._validate_decision(disposition=disposition, notes=notes)
|
||||
fingerprint = hashlib.sha256(
|
||||
_canonical_json({"item_id": item_id, **decision})
|
||||
).hexdigest()
|
||||
root = self._draft_root(draft_id)
|
||||
with _exclusive_lock(self.draft_root / ".locks" / f"{draft_id}.lock"):
|
||||
manifest, events, current = self._load(root, substrate)
|
||||
if self._finalization(root) is not None:
|
||||
raise E30HumanReviewConflictError("E30 review draft is finalized")
|
||||
prior = next(
|
||||
(
|
||||
event
|
||||
for event in events
|
||||
if event.get("idempotency_key") == idempotency_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if prior is not None:
|
||||
if prior.get("request_fingerprint") != fingerprint:
|
||||
raise E30HumanReviewConflictError(
|
||||
"idempotency_key is already bound to another decision"
|
||||
)
|
||||
return self._project_summary(
|
||||
manifest, substrate, events, current, None
|
||||
)
|
||||
if len(events) != expected_revision:
|
||||
raise E30HumanReviewConflictError(
|
||||
f"E30 review revision is {len(events)}, not {expected_revision}"
|
||||
)
|
||||
superseded = current.get(item_id)
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": E30_HUMAN_REVIEW_EVENT_SCHEMA,
|
||||
"draft_id": draft_id,
|
||||
"sequence": len(events) + 1,
|
||||
"item_id": item_id,
|
||||
"source_stratum": subject.source_stratum,
|
||||
**decision,
|
||||
"reviewer_id": manifest["identity"]["reviewer_id"],
|
||||
"decided_at_utc": utc_now_iso(),
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_fingerprint": fingerprint,
|
||||
"supersedes_event_id": (
|
||||
superseded.get("event_id") if superseded else None
|
||||
),
|
||||
"previous_event_sha256": (
|
||||
events[-1]["event_sha256"] if events else None
|
||||
),
|
||||
}
|
||||
event_sha256 = hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
event = {
|
||||
**payload,
|
||||
"event_id": f"e30-review-event-{event_sha256}",
|
||||
"event_sha256": event_sha256,
|
||||
}
|
||||
with (root / "events.jsonl").open("ab", buffering=0) as stream:
|
||||
stream.write(_canonical_json(event) + b"\n")
|
||||
os.fsync(stream.fileno())
|
||||
events.append(event)
|
||||
current[item_id] = event
|
||||
return self._project_summary(manifest, substrate, events, current, None)
|
||||
|
||||
def finalize(
|
||||
self,
|
||||
*,
|
||||
draft_id: str,
|
||||
substrate: E30ReviewSubstrate,
|
||||
expected_revision: int,
|
||||
) -> dict[str, object]:
|
||||
root = self._draft_root(draft_id)
|
||||
with _exclusive_lock(self.draft_root / ".locks" / f"{draft_id}.lock"):
|
||||
manifest, events, current = self._load(root, substrate)
|
||||
existing = self._finalization(root)
|
||||
if existing is not None:
|
||||
return self._generation_summary(existing, substrate)
|
||||
if len(events) != expected_revision:
|
||||
raise E30HumanReviewConflictError(
|
||||
f"E30 review revision is {len(events)}, not {expected_revision}"
|
||||
)
|
||||
if len(current) != len(substrate.subjects):
|
||||
raise E30HumanReviewConflictError(
|
||||
"E30 review coverage is incomplete: "
|
||||
f"{len(substrate.subjects) - len(current)} decisions remain"
|
||||
)
|
||||
generation = self._write_generation(manifest, substrate, current)
|
||||
finalization = {
|
||||
"schema_version": E30_HUMAN_REVIEW_FINALIZATION_SCHEMA,
|
||||
"draft_id": draft_id,
|
||||
"generation_id": generation["result_id"],
|
||||
"generation_identity_sha256": generation["identity_sha256"],
|
||||
"final_revision": len(events),
|
||||
"finalized_at_utc": generation["created_at_utc"],
|
||||
}
|
||||
write_json_atomic(root / "finalized.json", finalization)
|
||||
return self._generation_summary(finalization, substrate)
|
||||
|
||||
@staticmethod
|
||||
def _validate_decision(
|
||||
*,
|
||||
disposition: object,
|
||||
notes: object,
|
||||
) -> dict[str, object]:
|
||||
if disposition not in E30_DISPOSITIONS:
|
||||
raise E30HumanReviewValidationError("review disposition is invalid")
|
||||
if notes is not None and not isinstance(notes, str):
|
||||
raise E30HumanReviewValidationError("review notes are invalid")
|
||||
return {
|
||||
"disposition": disposition,
|
||||
"notes": _normalized_notes(notes),
|
||||
}
|
||||
|
||||
def _draft_root(self, draft_id: str) -> Path:
|
||||
root = self.draft_root / draft_id
|
||||
if (
|
||||
_DRAFT_ID.fullmatch(draft_id) is None
|
||||
or root.is_symlink()
|
||||
or not root.is_dir()
|
||||
):
|
||||
raise E30HumanReviewNotFoundError("E30 human-review draft was not found")
|
||||
return root
|
||||
|
||||
def _summary(
|
||||
self,
|
||||
root: Path,
|
||||
substrate: E30ReviewSubstrate,
|
||||
) -> dict[str, object]:
|
||||
manifest, events, current = self._load(root, substrate)
|
||||
return self._project_summary(
|
||||
manifest,
|
||||
substrate,
|
||||
events,
|
||||
current,
|
||||
self._finalization(root),
|
||||
)
|
||||
|
||||
def _load(
|
||||
self,
|
||||
root: Path,
|
||||
substrate: E30ReviewSubstrate,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
manifest = _read_json(root / "manifest.json")
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_HUMAN_REVIEW_DRAFT_SCHEMA
|
||||
or manifest.get("draft_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
!= identity_sha256
|
||||
or root.name != f"e30-human-draft-{identity_sha256}"
|
||||
or manifest.get("source") != substrate.binding()
|
||||
or not isinstance(manifest.get("created_at_utc"), str)
|
||||
or not self._diagnostic_authority(manifest.get("authority"))
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 human-review draft changed")
|
||||
events_path = root / "events.jsonl"
|
||||
if not events_path.exists():
|
||||
return manifest, [], {}
|
||||
if (
|
||||
events_path.is_symlink()
|
||||
or not events_path.is_file()
|
||||
or events_path.stat().st_size > _MAX_EVENT_LOG_BYTES
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 event log is invalid")
|
||||
subject_by_id = {value.item_id: value for value in substrate.subjects}
|
||||
events: list[dict[str, Any]] = []
|
||||
current: dict[str, dict[str, Any]] = {}
|
||||
previous_sha256: str | None = None
|
||||
with events_path.open("r", encoding="utf-8") as stream:
|
||||
for expected_sequence, line in enumerate(stream, start=1):
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 event is invalid"
|
||||
) from exc
|
||||
subject = subject_by_id.get(event.get("item_id"))
|
||||
event_sha256 = event.get("event_sha256")
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in event.items()
|
||||
if key not in {"event_id", "event_sha256"}
|
||||
}
|
||||
if (
|
||||
event.get("schema_version") != E30_HUMAN_REVIEW_EVENT_SCHEMA
|
||||
or event.get("draft_id") != root.name
|
||||
or event.get("sequence") != expected_sequence
|
||||
or subject is None
|
||||
or event.get("source_stratum") != subject.source_stratum
|
||||
or event.get("reviewer_id") != identity.get("reviewer_id")
|
||||
or event.get("previous_event_sha256") != previous_sha256
|
||||
or not isinstance(event_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
!= event_sha256
|
||||
or event.get("event_id")
|
||||
!= f"e30-review-event-{event_sha256}"
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 event chain changed")
|
||||
self._validate_decision(
|
||||
disposition=event.get("disposition"),
|
||||
notes=event.get("notes"),
|
||||
)
|
||||
superseded = current.get(subject.item_id)
|
||||
if event.get("supersedes_event_id") != (
|
||||
superseded.get("event_id") if superseded else None
|
||||
):
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 supersession chain changed"
|
||||
)
|
||||
events.append(event)
|
||||
current[subject.item_id] = event
|
||||
previous_sha256 = event_sha256
|
||||
return manifest, events, current
|
||||
|
||||
def _project_summary(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
substrate: E30ReviewSubstrate,
|
||||
events: list[dict[str, Any]],
|
||||
current: dict[str, dict[str, Any]],
|
||||
finalization: dict[str, Any] | None,
|
||||
) -> dict[str, object]:
|
||||
distribution = Counter(
|
||||
str(event["disposition"]) for event in current.values()
|
||||
)
|
||||
decisions = [
|
||||
{
|
||||
"item_id": subject.item_id,
|
||||
"source_stratum": subject.source_stratum,
|
||||
"disposition": event["disposition"],
|
||||
"notes": event["notes"],
|
||||
"event_id": event["event_id"],
|
||||
"decided_at_utc": event["decided_at_utc"],
|
||||
}
|
||||
for subject in substrate.subjects
|
||||
if (event := current.get(subject.item_id)) is not None
|
||||
]
|
||||
return {
|
||||
"schema_version": E30_HUMAN_REVIEW_DRAFT_SCHEMA,
|
||||
"draft_id": manifest["draft_id"],
|
||||
"materialization_id": substrate.materialization_id,
|
||||
"engineering_generation_id": substrate.engineering_generation_id,
|
||||
"reviewer_id": manifest["identity"]["reviewer_id"],
|
||||
"created_at_utc": manifest["created_at_utc"],
|
||||
"state": "finalized" if finalization else "active",
|
||||
"revision": len(events),
|
||||
"item_count": len(substrate.subjects),
|
||||
"reviewed_item_count": len(decisions),
|
||||
"remaining_item_count": len(substrate.subjects) - len(decisions),
|
||||
"disposition_distribution": dict(sorted(distribution.items())),
|
||||
"generation_id": (
|
||||
finalization["generation_id"] if finalization else None
|
||||
),
|
||||
"decisions": decisions,
|
||||
"lab_published": False,
|
||||
"access": "read-only" if finalization else "review-write",
|
||||
}
|
||||
|
||||
def _write_generation(
|
||||
self,
|
||||
manifest: dict[str, Any],
|
||||
substrate: E30ReviewSubstrate,
|
||||
current: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
decisions = [
|
||||
{
|
||||
"schema_version": E30_HUMAN_REVIEW_DECISION_SCHEMA,
|
||||
"sequence": subject.sequence,
|
||||
"item_id": subject.item_id,
|
||||
"source_stratum": subject.source_stratum,
|
||||
"disposition": current[subject.item_id]["disposition"],
|
||||
"notes": current[subject.item_id]["notes"],
|
||||
"reviewer_id": manifest["identity"]["reviewer_id"],
|
||||
"decision_event_id": current[subject.item_id]["event_id"],
|
||||
"decided_at_utc": current[subject.item_id]["decided_at_utc"],
|
||||
}
|
||||
for subject in substrate.subjects
|
||||
]
|
||||
identity = {
|
||||
"schema_version": E30_HUMAN_REVIEW_GENERATION_SCHEMA,
|
||||
"protocol": "exception-disposition-review/v1",
|
||||
"source": substrate.binding(),
|
||||
"reviewer_id": manifest["identity"]["reviewer_id"],
|
||||
"decision_event_ids": [
|
||||
decision["decision_event_id"] for decision in decisions
|
||||
],
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"e30-review-generation-{identity_sha256}"
|
||||
destination = self.generation_root / generation_id
|
||||
if destination.exists():
|
||||
existing = _read_json(destination / E30_HUMAN_REVIEW_MANIFEST_NAME)
|
||||
if existing.get("identity") != identity:
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 reviewer generation identity collision"
|
||||
)
|
||||
return existing
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(prefix=f".{generation_id}.", dir=self.generation_root)
|
||||
)
|
||||
try:
|
||||
decisions_path = staging / E30_HUMAN_REVIEW_DECISIONS_NAME
|
||||
with decisions_path.open("wb") as stream:
|
||||
for decision in decisions:
|
||||
stream.write(_canonical_json(decision) + b"\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
distribution = {
|
||||
"schema_version": "missioncore.e30-human-review-dispositions/v1",
|
||||
"dispositions": dict(
|
||||
sorted(Counter(
|
||||
str(decision["disposition"]) for decision in decisions
|
||||
).items())
|
||||
),
|
||||
}
|
||||
distribution_path = staging / E30_HUMAN_REVIEW_DISTRIBUTION_NAME
|
||||
write_json_atomic(distribution_path, distribution)
|
||||
created_at_utc = utc_now_iso()
|
||||
generation_manifest: dict[str, Any] = {
|
||||
"schema_version": E30_HUMAN_REVIEW_GENERATION_SCHEMA,
|
||||
"result_id": generation_id,
|
||||
"identity": identity,
|
||||
"identity_sha256": identity_sha256,
|
||||
"created_at_utc": created_at_utc,
|
||||
"reviewer_id": manifest["identity"]["reviewer_id"],
|
||||
"item_count": len(decisions),
|
||||
"disposition_distribution": distribution["dispositions"],
|
||||
"coverage": {
|
||||
"expected_item_count": len(substrate.subjects),
|
||||
"reviewed_item_count": len(decisions),
|
||||
"complete": True,
|
||||
},
|
||||
"human_review_complete": True,
|
||||
"lab_published": False,
|
||||
"artifacts": [
|
||||
self._artifact("review-decisions", decisions_path),
|
||||
self._artifact("disposition-distribution", distribution_path),
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
write_json_atomic(
|
||||
staging / E30_HUMAN_REVIEW_MANIFEST_NAME,
|
||||
generation_manifest,
|
||||
)
|
||||
os.replace(staging, destination)
|
||||
return generation_manifest
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
@staticmethod
|
||||
def _artifact(role: str, path: Path) -> dict[str, object]:
|
||||
return {
|
||||
"role": role,
|
||||
"path": path.name,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
|
||||
def _finalization(self, root: Path) -> dict[str, Any] | None:
|
||||
path = root / "finalized.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
value = _read_json(path)
|
||||
if (
|
||||
value.get("schema_version") != E30_HUMAN_REVIEW_FINALIZATION_SCHEMA
|
||||
or value.get("draft_id") != root.name
|
||||
or _GENERATION_ID.fullmatch(str(value.get("generation_id"))) is None
|
||||
or _SHA256.fullmatch(
|
||||
str(value.get("generation_identity_sha256"))
|
||||
) is None
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 finalization marker changed")
|
||||
return value
|
||||
|
||||
def _generation_summary(
|
||||
self,
|
||||
finalization: dict[str, Any],
|
||||
substrate: E30ReviewSubstrate,
|
||||
) -> dict[str, object]:
|
||||
generation_id = str(finalization["generation_id"])
|
||||
root = self.generation_root / generation_id
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 reviewer generation is unavailable"
|
||||
)
|
||||
manifest = _read_json(root / E30_HUMAN_REVIEW_MANIFEST_NAME)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_HUMAN_REVIEW_GENERATION_SCHEMA
|
||||
or manifest.get("result_id") != generation_id
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
!= identity_sha256
|
||||
or generation_id != f"e30-review-generation-{identity_sha256}"
|
||||
or identity.get("source") != substrate.binding()
|
||||
or manifest.get("human_review_complete") is not True
|
||||
or manifest.get("lab_published") is not False
|
||||
or not self._diagnostic_authority(manifest.get("authority"))
|
||||
):
|
||||
raise E30HumanReviewIntegrityError("E30 reviewer generation changed")
|
||||
expected = {
|
||||
"review-decisions": E30_HUMAN_REVIEW_DECISIONS_NAME,
|
||||
"disposition-distribution": E30_HUMAN_REVIEW_DISTRIBUTION_NAME,
|
||||
}
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != len(expected):
|
||||
raise E30HumanReviewIntegrityError("E30 generation artifacts are invalid")
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 generation artifact metadata is invalid"
|
||||
)
|
||||
path = root / str(artifact.get("path"))
|
||||
if (
|
||||
expected.get(str(artifact.get("role"))) != path.name
|
||||
or path.is_symlink()
|
||||
or not path.is_file()
|
||||
or path.stat().st_size != artifact.get("byte_length")
|
||||
or _sha256(path) != artifact.get("sha256")
|
||||
):
|
||||
raise E30HumanReviewIntegrityError(
|
||||
"E30 generation artifact content changed"
|
||||
)
|
||||
return {
|
||||
"schema_version": E30_HUMAN_REVIEW_GENERATION_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"materialization_id": substrate.materialization_id,
|
||||
"engineering_generation_id": substrate.engineering_generation_id,
|
||||
"reviewer_id": manifest["reviewer_id"],
|
||||
"created_at_utc": manifest["created_at_utc"],
|
||||
"item_count": manifest["item_count"],
|
||||
"disposition_distribution": manifest["disposition_distribution"],
|
||||
"coverage": manifest["coverage"],
|
||||
"human_review_complete": True,
|
||||
"lab_published": False,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _diagnostic_authority(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and value.get("commands_enabled") is False
|
||||
and value.get("navigation_or_safety_accepted") is False
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,734 @@
|
||||
"""Deterministic E30 review selection over one immutable E29 result.
|
||||
|
||||
The pack is a review substrate, not a human-reviewed LAB result. It binds each
|
||||
selected item to the exact E29 frame and its camera/LiDAR/pose/surface source
|
||||
identities, preserves the E29 observation or geometry-cluster snapshot and
|
||||
leaves the reviewer decision empty. No perception threshold is changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
from collections import Counter
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from .semantic_geometry_fusion import (
|
||||
CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||
CAMERA_GEOMETRY_FRAMES_NAME,
|
||||
CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||
CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||
CAMERA_GEOMETRY_REPORT_NAME,
|
||||
CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||
)
|
||||
|
||||
E30_REVIEW_PACK_SCHEMA: Final = "missioncore.e30-evidence-review-pack/v1"
|
||||
E30_REVIEW_ITEM_SCHEMA: Final = "missioncore.e30-evidence-review-item/v1"
|
||||
E30_REVIEW_ITEMS_NAME: Final = "review-items.jsonl"
|
||||
E30_REVIEW_MANIFEST_NAME: Final = "manifest.json"
|
||||
|
||||
E30_REASON_TAXONOMY: Final = (
|
||||
"no_lidar_observation",
|
||||
"outside_lidar_support",
|
||||
"outside_camera_fov",
|
||||
"time_mismatch",
|
||||
"pose_age",
|
||||
"calibration_residual",
|
||||
"surface_rejection",
|
||||
"ground_leakage",
|
||||
"foreground_occlusion",
|
||||
"background_leakage",
|
||||
"component_split",
|
||||
"component_merge",
|
||||
"self_points",
|
||||
"sparse_support",
|
||||
"semantic_mask_error",
|
||||
"detector_error",
|
||||
"track_identity_error",
|
||||
"post_lio_motion_artifact",
|
||||
"unknown",
|
||||
)
|
||||
|
||||
_STATUS_TO_STRATUM: Final = {
|
||||
"agree": "agree",
|
||||
"single-source-camera": "camera-only",
|
||||
"conflict": "conflict",
|
||||
"unknown": "unknown",
|
||||
}
|
||||
|
||||
|
||||
class E30ReviewPackError(ValueError):
|
||||
"""An E29 source or E30 review-pack derivative violates the contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class E30ReviewSelectionProfile:
|
||||
"""Bounded deterministic sample sizes for non-conflict E29 strata."""
|
||||
|
||||
profile_id: str = "e30-e29-stratified-review/v1"
|
||||
agree_maximum: int = 96
|
||||
camera_only_maximum: int = 128
|
||||
unknown_maximum: int = 96
|
||||
geometry_only_maximum: int = 128
|
||||
temporal_bins: int = 12
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
not self.profile_id
|
||||
or len(self.profile_id) > 160
|
||||
or not 1 <= self.agree_maximum <= 4096
|
||||
or not 1 <= self.camera_only_maximum <= 4096
|
||||
or not 1 <= self.unknown_maximum <= 4096
|
||||
or not 1 <= self.geometry_only_maximum <= 4096
|
||||
or not 2 <= self.temporal_bins <= 128
|
||||
):
|
||||
raise E30ReviewPackError("E30 review selection profile is invalid")
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": "missioncore.e30-review-selection-profile/v1",
|
||||
**asdict(self),
|
||||
"conflict_selection": "all",
|
||||
"sample_selection": "round-robin-class-range-time-strata-sha256",
|
||||
}
|
||||
|
||||
def maximum_for(self, stratum: str) -> int:
|
||||
values = {
|
||||
"agree": self.agree_maximum,
|
||||
"camera-only": self.camera_only_maximum,
|
||||
"unknown": self.unknown_maximum,
|
||||
"geometry-only": self.geometry_only_maximum,
|
||||
}
|
||||
try:
|
||||
return values[stratum]
|
||||
except KeyError as exc:
|
||||
raise E30ReviewPackError("E30 review stratum is unknown") from exc
|
||||
|
||||
|
||||
DEFAULT_E30_REVIEW_SELECTION_PROFILE: Final = E30ReviewSelectionProfile()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class E30ReviewPack:
|
||||
result_root: Path
|
||||
result_id: str
|
||||
manifest: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ReviewCandidate:
|
||||
review_key: str
|
||||
stratum: str
|
||||
group_key: tuple[str, ...]
|
||||
document: dict[str, object]
|
||||
|
||||
|
||||
def build_e30_review_pack(
|
||||
*,
|
||||
e29_result_root: Path,
|
||||
output_root: Path,
|
||||
profile: E30ReviewSelectionProfile = DEFAULT_E30_REVIEW_SELECTION_PROFILE,
|
||||
) -> E30ReviewPack:
|
||||
"""Build one immutable, bounded selection pack over a complete E29 result."""
|
||||
|
||||
source_root = e29_result_root.expanduser()
|
||||
if source_root.is_symlink():
|
||||
raise E30ReviewPackError("E29 result root must not be a symlink")
|
||||
source_root = source_root.resolve(strict=True)
|
||||
if not source_root.is_dir():
|
||||
raise E30ReviewPackError("E29 result root must be a directory")
|
||||
|
||||
source_manifest_path = _regular_file(
|
||||
source_root,
|
||||
CAMERA_GEOMETRY_MANIFEST_NAME,
|
||||
)
|
||||
source_manifest = _read_json(source_manifest_path, "E29 manifest")
|
||||
if source_manifest.get("schema_version") != CAMERA_GEOMETRY_FUSION_SCHEMA:
|
||||
raise E30ReviewPackError("E29 manifest schema is incompatible")
|
||||
result_id = _required_string(source_manifest, "result_id")
|
||||
if not result_id.startswith("e29-camera-geometry-"):
|
||||
raise E30ReviewPackError("E29 result id is incompatible")
|
||||
if source_root.name != result_id:
|
||||
raise E30ReviewPackError("E29 result directory and manifest id disagree")
|
||||
if source_manifest.get("ground_truth") is not False:
|
||||
raise E30ReviewPackError("E29 diagnostic result must not claim ground truth")
|
||||
identity = _required_object(source_manifest, "identity")
|
||||
source_identity_sha256 = _required_string(source_manifest, "identity_sha256")
|
||||
if (
|
||||
hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
!= source_identity_sha256
|
||||
or result_id != f"e29-camera-geometry-{source_identity_sha256}"
|
||||
):
|
||||
raise E30ReviewPackError("E29 manifest identity digest is invalid")
|
||||
_reject_authority(_required_object(identity, "authority"), "E29 identity")
|
||||
|
||||
frame_record = _artifact_record(source_manifest, "camera-geometry-frames")
|
||||
report_record = _artifact_record(source_manifest, "camera-geometry-report")
|
||||
frames_path = _verified_artifact(
|
||||
source_root,
|
||||
frame_record,
|
||||
CAMERA_GEOMETRY_FRAMES_NAME,
|
||||
)
|
||||
report_path = _verified_artifact(
|
||||
source_root,
|
||||
report_record,
|
||||
CAMERA_GEOMETRY_REPORT_NAME,
|
||||
)
|
||||
report = _read_json(report_path, "E29 report")
|
||||
_validate_report(report, result_id, identity)
|
||||
|
||||
frame_count = _required_int(identity, "frame_count")
|
||||
timeline_start = _required_float(identity, "timeline_start_seconds")
|
||||
timeline_end = _required_float(identity, "timeline_end_seconds")
|
||||
if frame_count < 1 or timeline_end < timeline_start:
|
||||
raise E30ReviewPackError("E29 identity timeline is invalid")
|
||||
|
||||
source_bindings: dict[str, object] = {
|
||||
"e29_result_id": result_id,
|
||||
"e29_identity_sha256": source_identity_sha256,
|
||||
"e29_frames_sha256": _required_string(frame_record, "sha256"),
|
||||
"e29_report_sha256": _required_string(report_record, "sha256"),
|
||||
"camera_result_id": _required_string(identity, "source_result_id"),
|
||||
"lidar_pack_id": _required_string(identity, "source_pack_id"),
|
||||
"local_surface_model_id": _required_string(
|
||||
identity,
|
||||
"local_surface_model_id",
|
||||
),
|
||||
"fusion_profile_id": _required_string(
|
||||
_required_object(identity, "profile"),
|
||||
"profile_id",
|
||||
),
|
||||
}
|
||||
|
||||
candidates, observed_counts = _read_candidates(
|
||||
frames_path,
|
||||
source_bindings=source_bindings,
|
||||
expected_frame_count=frame_count,
|
||||
timeline_start=timeline_start,
|
||||
timeline_end=timeline_end,
|
||||
temporal_bins=profile.temporal_bins,
|
||||
)
|
||||
_validate_observed_counts(report, observed_counts)
|
||||
|
||||
profile_document = profile.to_dict()
|
||||
identity_document = {
|
||||
"schema_version": E30_REVIEW_PACK_SCHEMA,
|
||||
"source": source_bindings,
|
||||
"selection_profile": profile_document,
|
||||
"reason_taxonomy": list(E30_REASON_TAXONOMY),
|
||||
"human_review_complete": False,
|
||||
"producer_sha256": _sha256_file(Path(__file__).resolve(strict=True)),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity_document)).hexdigest()
|
||||
review_result_id = f"e30-review-pack-{identity_sha256}"
|
||||
|
||||
selected = _select_candidates(candidates, profile, identity_sha256)
|
||||
selected_counts = Counter(candidate.stratum for candidate in selected)
|
||||
output = output_root.expanduser().absolute()
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
result_root = output / review_result_id
|
||||
if result_root.exists():
|
||||
manifest = _read_existing_pack(result_root, identity_document)
|
||||
return E30ReviewPack(result_root, review_result_id, manifest)
|
||||
|
||||
staging = output / f".{review_result_id}.{os.getpid()}.incomplete"
|
||||
staging.mkdir(mode=0o700, exist_ok=False)
|
||||
items_path = staging / E30_REVIEW_ITEMS_NAME
|
||||
try:
|
||||
with items_path.open("x", encoding="utf-8") as stream:
|
||||
for sequence, candidate in enumerate(selected):
|
||||
document = {
|
||||
"schema_version": E30_REVIEW_ITEM_SCHEMA,
|
||||
"sequence": sequence,
|
||||
"item_id": _item_id(identity_sha256, candidate.review_key),
|
||||
**candidate.document,
|
||||
"review": {
|
||||
"state": "unreviewed",
|
||||
"reason_code": None,
|
||||
"notes": None,
|
||||
},
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
stream.write(_canonical_json(document).decode("utf-8") + "\n")
|
||||
items_artifact = _artifact("review-items", items_path, "application/x-ndjson")
|
||||
manifest = {
|
||||
"schema_version": E30_REVIEW_PACK_SCHEMA,
|
||||
"result_id": review_result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity_document,
|
||||
"created_at_utc": datetime.now(UTC).isoformat(),
|
||||
"classification": "private-derived-perception-review-substrate",
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"source_counts": dict(sorted(observed_counts.items())),
|
||||
"selected_counts": dict(sorted(selected_counts.items())),
|
||||
"selected_item_count": len(selected),
|
||||
"artifacts": [items_artifact],
|
||||
"limitations": [
|
||||
"the pack contains no human decisions",
|
||||
"E29 stores support counts but not point-index ownership",
|
||||
"selected and rejected point visualization requires source-bound reprojection",
|
||||
"selection is not object-detection or association accuracy",
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
_write_json(staging / E30_REVIEW_MANIFEST_NAME, manifest)
|
||||
os.replace(staging, result_root)
|
||||
except BaseException:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
return E30ReviewPack(result_root, review_result_id, manifest)
|
||||
|
||||
|
||||
def _read_candidates(
|
||||
frames_path: Path,
|
||||
*,
|
||||
source_bindings: dict[str, object],
|
||||
expected_frame_count: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
temporal_bins: int,
|
||||
) -> tuple[list[_ReviewCandidate], Counter[str]]:
|
||||
candidates: list[_ReviewCandidate] = []
|
||||
counts: Counter[str] = Counter()
|
||||
observed_frame_count = 0
|
||||
with frames_path.open("r", encoding="utf-8") as stream:
|
||||
for expected_frame_index, line in enumerate(stream):
|
||||
observed_frame_count += 1
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30ReviewPackError("E29 frame JSON is invalid") from exc
|
||||
frame = _object(value, "E29 frame")
|
||||
if frame.get("schema_version") != CAMERA_GEOMETRY_FRAME_SCHEMA:
|
||||
raise E30ReviewPackError("E29 frame schema is incompatible")
|
||||
frame_index = _required_int(frame, "frame_index")
|
||||
if frame_index != expected_frame_index:
|
||||
raise E30ReviewPackError("E29 frame order is not contiguous")
|
||||
source_frame_index = _required_int(frame, "source_frame_index")
|
||||
if source_frame_index < 0:
|
||||
raise E30ReviewPackError("E29 source frame index is invalid")
|
||||
session_seconds = _required_float(frame, "session_seconds")
|
||||
temporal_bin = _temporal_bin(
|
||||
session_seconds,
|
||||
timeline_start,
|
||||
timeline_end,
|
||||
temporal_bins,
|
||||
)
|
||||
binding = {
|
||||
**source_bindings,
|
||||
"frame_index": frame_index,
|
||||
"source_frame_index": source_frame_index,
|
||||
"session_seconds": session_seconds,
|
||||
}
|
||||
|
||||
observations = _required_array(frame, "semantic_observations")
|
||||
for observation_index, raw_observation in enumerate(observations):
|
||||
observation = _object(raw_observation, "E29 semantic observation")
|
||||
status = _required_string(observation, "geometry_status")
|
||||
try:
|
||||
stratum = _STATUS_TO_STRATUM[status]
|
||||
except KeyError as exc:
|
||||
raise E30ReviewPackError(
|
||||
"E29 semantic geometry status is incompatible"
|
||||
) from exc
|
||||
counts[stratum] += 1
|
||||
label = _required_string(observation, "label")
|
||||
association_group = _required_string(
|
||||
observation,
|
||||
"association_group",
|
||||
)
|
||||
range_bucket = _range_bucket(
|
||||
_optional_float(observation.get("range_m"))
|
||||
)
|
||||
review_key = f"semantic:{frame_index}:{observation_index}"
|
||||
candidates.append(
|
||||
_ReviewCandidate(
|
||||
review_key=review_key,
|
||||
stratum=stratum,
|
||||
group_key=(
|
||||
association_group,
|
||||
label,
|
||||
range_bucket,
|
||||
f"time-{temporal_bin:03d}",
|
||||
),
|
||||
document={
|
||||
"review_key": review_key,
|
||||
"stratum": stratum,
|
||||
"range_bucket": range_bucket,
|
||||
"evidence_binding": binding,
|
||||
"e29_locator": {
|
||||
"kind": "semantic-observation",
|
||||
"observation_index": observation_index,
|
||||
},
|
||||
"e29_snapshot": observation,
|
||||
"materialization": {
|
||||
"camera_frame_bound": True,
|
||||
"lidar_frame_bound": True,
|
||||
"pose_frame_bound": True,
|
||||
"surface_frame_bound": True,
|
||||
"point_indices_available_in_e29": False,
|
||||
"source_reprojection_required": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
geometry_clusters = _required_array(frame, "geometry_only_occupied")
|
||||
for cluster_index, raw_cluster in enumerate(geometry_clusters):
|
||||
cluster = _object(raw_cluster, "E29 geometry-only cluster")
|
||||
if cluster.get("geometry_status") != "single-source-geometry":
|
||||
raise E30ReviewPackError(
|
||||
"E29 geometry-only status is incompatible"
|
||||
)
|
||||
counts["geometry-only"] += 1
|
||||
range_bucket = _range_bucket(
|
||||
_optional_float(cluster.get("nearest_range_m"))
|
||||
)
|
||||
review_key = f"geometry:{frame_index}:{cluster_index}"
|
||||
candidates.append(
|
||||
_ReviewCandidate(
|
||||
review_key=review_key,
|
||||
stratum="geometry-only",
|
||||
group_key=(
|
||||
range_bucket,
|
||||
f"time-{temporal_bin:03d}",
|
||||
),
|
||||
document={
|
||||
"review_key": review_key,
|
||||
"stratum": "geometry-only",
|
||||
"range_bucket": range_bucket,
|
||||
"evidence_binding": binding,
|
||||
"e29_locator": {
|
||||
"kind": "geometry-only-cluster",
|
||||
"cluster_index": cluster_index,
|
||||
},
|
||||
"e29_snapshot": cluster,
|
||||
"materialization": {
|
||||
"camera_frame_bound": True,
|
||||
"lidar_frame_bound": True,
|
||||
"pose_frame_bound": True,
|
||||
"surface_frame_bound": True,
|
||||
"point_indices_available_in_e29": False,
|
||||
"source_reprojection_required": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
if observed_frame_count != expected_frame_count:
|
||||
raise E30ReviewPackError("E29 frame artifact is incomplete")
|
||||
return candidates, counts
|
||||
|
||||
|
||||
def _select_candidates(
|
||||
candidates: list[_ReviewCandidate],
|
||||
profile: E30ReviewSelectionProfile,
|
||||
seed: str,
|
||||
) -> list[_ReviewCandidate]:
|
||||
by_stratum: dict[str, list[_ReviewCandidate]] = {}
|
||||
for candidate in candidates:
|
||||
by_stratum.setdefault(candidate.stratum, []).append(candidate)
|
||||
admitted_strata = {
|
||||
"agree",
|
||||
"camera-only",
|
||||
"conflict",
|
||||
"unknown",
|
||||
"geometry-only",
|
||||
}
|
||||
if not set(by_stratum).issubset(admitted_strata):
|
||||
raise E30ReviewPackError("E29 result contains an unknown E30 review stratum")
|
||||
|
||||
selected = list(by_stratum.get("conflict", []))
|
||||
for stratum in ("agree", "camera-only", "unknown", "geometry-only"):
|
||||
selected.extend(
|
||||
_round_robin_sample(
|
||||
by_stratum.get(stratum, []),
|
||||
profile.maximum_for(stratum),
|
||||
seed,
|
||||
)
|
||||
)
|
||||
selected.sort(key=lambda candidate: candidate.review_key)
|
||||
if len({candidate.review_key for candidate in selected}) != len(selected):
|
||||
raise E30ReviewPackError("E30 selection contains duplicate review items")
|
||||
return selected
|
||||
|
||||
|
||||
def _round_robin_sample(
|
||||
candidates: list[_ReviewCandidate],
|
||||
maximum: int,
|
||||
seed: str,
|
||||
) -> list[_ReviewCandidate]:
|
||||
if len(candidates) <= maximum:
|
||||
return list(candidates)
|
||||
groups: dict[tuple[str, ...], list[_ReviewCandidate]] = {}
|
||||
for candidate in candidates:
|
||||
groups.setdefault(candidate.group_key, []).append(candidate)
|
||||
for group in groups.values():
|
||||
group.sort(key=lambda candidate: _selection_rank(seed, candidate.review_key))
|
||||
|
||||
selected: list[_ReviewCandidate] = []
|
||||
active = sorted(groups)
|
||||
while active and len(selected) < maximum:
|
||||
next_active: list[tuple[str, ...]] = []
|
||||
for key in active:
|
||||
group = groups[key]
|
||||
if group and len(selected) < maximum:
|
||||
selected.append(group.pop(0))
|
||||
if group:
|
||||
next_active.append(key)
|
||||
active = next_active
|
||||
if len(selected) != maximum:
|
||||
raise E30ReviewPackError("E30 stratified selector did not fill its quota")
|
||||
return selected
|
||||
|
||||
|
||||
def _selection_rank(seed: str, review_key: str) -> str:
|
||||
return hashlib.sha256(f"{seed}:{review_key}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _item_id(identity_sha256: str, review_key: str) -> str:
|
||||
digest = hashlib.sha256(f"{identity_sha256}:{review_key}".encode()).hexdigest()
|
||||
return f"e30-review-item-{digest}"
|
||||
|
||||
|
||||
def _temporal_bin(
|
||||
session_seconds: float,
|
||||
start: float,
|
||||
end: float,
|
||||
bin_count: int,
|
||||
) -> int:
|
||||
if not start <= session_seconds <= end:
|
||||
raise E30ReviewPackError("E29 frame escaped the identity timeline")
|
||||
if end == start:
|
||||
return 0
|
||||
fraction = (session_seconds - start) / (end - start)
|
||||
return min(bin_count - 1, max(0, int(fraction * bin_count)))
|
||||
|
||||
|
||||
def _range_bucket(value: float | None) -> str:
|
||||
if value is None:
|
||||
return "unavailable"
|
||||
if value < 3.0:
|
||||
return "near"
|
||||
if value < 7.0:
|
||||
return "middle"
|
||||
return "far"
|
||||
|
||||
|
||||
def _validate_report(
|
||||
report: dict[str, Any],
|
||||
result_id: str,
|
||||
identity: dict[str, Any],
|
||||
) -> None:
|
||||
if (
|
||||
report.get("schema_version") != CAMERA_GEOMETRY_REPORT_SCHEMA
|
||||
or report.get("result_id") != result_id
|
||||
or report.get("status") != "diagnostic-replay-complete"
|
||||
or report.get("ground_truth") is not False
|
||||
or report.get("identity") != identity
|
||||
):
|
||||
raise E30ReviewPackError("E29 report is not a complete diagnostic replay")
|
||||
_reject_authority(_required_object(report, "authority"), "E29 report")
|
||||
|
||||
|
||||
def _validate_observed_counts(
|
||||
report: dict[str, Any],
|
||||
observed: Counter[str],
|
||||
) -> None:
|
||||
metrics = _required_object(report, "metrics")
|
||||
semantic = _required_object(metrics, "semantic_observations")
|
||||
statuses = _required_object(semantic, "geometry_status")
|
||||
expected = {
|
||||
"agree": _required_int(statuses, "agree"),
|
||||
"camera-only": _required_int(statuses, "single-source-camera"),
|
||||
"conflict": _required_int(statuses, "conflict"),
|
||||
"unknown": _required_int(statuses, "unknown"),
|
||||
"geometry-only": _required_int(
|
||||
_required_object(metrics, "geometry_only_occupied"),
|
||||
"cluster_count",
|
||||
),
|
||||
}
|
||||
actual = {stratum: observed[stratum] for stratum in expected}
|
||||
if actual != expected:
|
||||
raise E30ReviewPackError("E29 frame and report stratum counts disagree")
|
||||
|
||||
|
||||
def _artifact_record(
|
||||
manifest: dict[str, Any],
|
||||
role: str,
|
||||
) -> dict[str, Any]:
|
||||
artifacts = _required_array(manifest, "artifacts")
|
||||
matches = [
|
||||
_object(item, "E29 artifact")
|
||||
for item in artifacts
|
||||
if isinstance(item, dict) and item.get("role") == role
|
||||
]
|
||||
if len(matches) != 1:
|
||||
raise E30ReviewPackError(f"E29 manifest has no unique {role} artifact")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _verified_artifact(
|
||||
root: Path,
|
||||
record: dict[str, Any],
|
||||
expected_name: str,
|
||||
) -> Path:
|
||||
relative = _required_string(record, "path")
|
||||
if Path(relative).is_absolute() or relative != expected_name:
|
||||
raise E30ReviewPackError("E29 artifact path is incompatible")
|
||||
path = _regular_file(root, relative)
|
||||
if path.stat().st_size != _required_int(record, "byte_length"):
|
||||
raise E30ReviewPackError("E29 artifact byte length changed")
|
||||
if _sha256_file(path) != _required_string(record, "sha256"):
|
||||
raise E30ReviewPackError("E29 artifact digest changed")
|
||||
return path
|
||||
|
||||
|
||||
def _regular_file(root: Path, relative: str) -> Path:
|
||||
raw = root / relative
|
||||
if raw.is_symlink():
|
||||
raise E30ReviewPackError("evidence artifact must not be a symlink")
|
||||
path = raw.resolve(strict=True)
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise E30ReviewPackError("evidence artifact escaped its result root") from exc
|
||||
if not path.is_file():
|
||||
raise E30ReviewPackError("evidence artifact must be a regular file")
|
||||
return path
|
||||
|
||||
|
||||
def _artifact(role: str, path: Path, media_type: str) -> dict[str, object]:
|
||||
return {
|
||||
"role": role,
|
||||
"path": path.name,
|
||||
"media_type": media_type,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def _read_existing_pack(
|
||||
result_root: Path,
|
||||
expected_identity: dict[str, object],
|
||||
) -> dict[str, Any]:
|
||||
manifest_path = _regular_file(result_root, E30_REVIEW_MANIFEST_NAME)
|
||||
manifest = _read_json(manifest_path, "E30 review manifest")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_REVIEW_PACK_SCHEMA
|
||||
or manifest.get("identity") != expected_identity
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30ReviewPackError("existing E30 review pack identity differs")
|
||||
_reject_authority(_required_object(manifest, "authority"), "E30 review pack")
|
||||
artifact = _artifact_record(manifest, "review-items")
|
||||
_verified_artifact(result_root, artifact, E30_REVIEW_ITEMS_NAME)
|
||||
return manifest
|
||||
|
||||
|
||||
def _reject_authority(authority: dict[str, Any], label: str) -> None:
|
||||
if (
|
||||
authority.get("commands_enabled") is not False
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30ReviewPackError(f"{label} must remain diagnostic-only")
|
||||
|
||||
|
||||
def _read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
return _object(json.loads(path.read_text(encoding="utf-8")), label)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30ReviewPackError(f"{label} is invalid") from exc
|
||||
|
||||
|
||||
def _write_json(path: Path, value: dict[str, object] | dict[str, Any]) -> None:
|
||||
with path.open("x", encoding="utf-8") as stream:
|
||||
stream.write(_canonical_json(value).decode("utf-8"))
|
||||
stream.write("\n")
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise E30ReviewPackError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _required_object(document: dict[str, Any], key: str) -> dict[str, Any]:
|
||||
return _object(document.get(key), key)
|
||||
|
||||
|
||||
def _required_array(document: dict[str, Any], key: str) -> list[object]:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise E30ReviewPackError(f"{key} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _required_string(document: dict[str, Any], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise E30ReviewPackError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _required_int(document: dict[str, Any], key: str) -> int:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise E30ReviewPackError(f"{key} must be an integer")
|
||||
return value
|
||||
|
||||
|
||||
def _required_float(document: dict[str, Any], key: str) -> float:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, int | float) or isinstance(value, bool):
|
||||
raise E30ReviewPackError(f"{key} must be numeric")
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise E30ReviewPackError(f"{key} must be finite")
|
||||
return parsed
|
||||
|
||||
|
||||
def _optional_float(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, int | float) or isinstance(value, bool):
|
||||
raise E30ReviewPackError("optional range must be numeric")
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise E30ReviewPackError("optional range must be finite")
|
||||
return parsed
|
||||
@@ -132,6 +132,12 @@ class _SemanticSupport:
|
||||
occupied_source_indices: IntArray
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GeometryClusterSupport:
|
||||
document: dict[str, object]
|
||||
occupied_source_indices: IntArray
|
||||
|
||||
|
||||
def build_camera_geometry_fusion(
|
||||
*,
|
||||
fusion_frames_path: Path,
|
||||
@@ -552,6 +558,28 @@ def _geometry_clusters(
|
||||
claimed_source_indices: set[int],
|
||||
profile: CameraGeometryFusionProfile,
|
||||
) -> list[dict[str, object]]:
|
||||
return [
|
||||
support.document
|
||||
for support in _geometry_cluster_supports(
|
||||
points_map=points_map,
|
||||
point_class=point_class,
|
||||
point_height_m=point_height_m,
|
||||
sensor_position_map=sensor_position_map,
|
||||
claimed_source_indices=claimed_source_indices,
|
||||
profile=profile,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _geometry_cluster_supports(
|
||||
*,
|
||||
points_map: FloatArray,
|
||||
point_class: npt.NDArray[np.uint8],
|
||||
point_height_m: npt.NDArray[np.float32],
|
||||
sensor_position_map: FloatArray,
|
||||
claimed_source_indices: set[int],
|
||||
profile: CameraGeometryFusionProfile,
|
||||
) -> list[_GeometryClusterSupport]:
|
||||
occupied = np.flatnonzero(point_class == POINT_OCCUPIED).astype(np.int64)
|
||||
if occupied.size == 0:
|
||||
return []
|
||||
@@ -564,7 +592,7 @@ def _geometry_clusters(
|
||||
occupied,
|
||||
profile.geometry_voxel_size_m,
|
||||
)
|
||||
documents: list[dict[str, object]] = []
|
||||
supports: list[_GeometryClusterSupport] = []
|
||||
for indices, voxel_count in components:
|
||||
if (
|
||||
indices.size < profile.geometry_minimum_cluster_points
|
||||
@@ -574,33 +602,38 @@ def _geometry_clusters(
|
||||
continue
|
||||
values = points_map[indices]
|
||||
distances = np.linalg.norm(values - sensor_position_map, axis=1)
|
||||
documents.append(
|
||||
{
|
||||
"geometry_status": "single-source-geometry",
|
||||
"semantic_class": None,
|
||||
"point_count": int(indices.size),
|
||||
"voxel_count": voxel_count,
|
||||
"centroid_map_xyz_m": np.median(values, axis=0).astype(np.float64).tolist(),
|
||||
"bounds_map_xyz_m": [
|
||||
np.min(values, axis=0).astype(np.float64).tolist(),
|
||||
np.max(values, axis=0).astype(np.float64).tolist(),
|
||||
],
|
||||
"height_range_m": [
|
||||
float(np.min(point_height_m[indices])),
|
||||
float(np.max(point_height_m[indices])),
|
||||
],
|
||||
"nearest_range_m": float(np.min(distances)),
|
||||
"unknown_is_occupied": True,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
supports.append(
|
||||
_GeometryClusterSupport(
|
||||
document={
|
||||
"geometry_status": "single-source-geometry",
|
||||
"semantic_class": None,
|
||||
"point_count": int(indices.size),
|
||||
"voxel_count": voxel_count,
|
||||
"centroid_map_xyz_m": np.median(values, axis=0)
|
||||
.astype(np.float64)
|
||||
.tolist(),
|
||||
"bounds_map_xyz_m": [
|
||||
np.min(values, axis=0).astype(np.float64).tolist(),
|
||||
np.max(values, axis=0).astype(np.float64).tolist(),
|
||||
],
|
||||
"height_range_m": [
|
||||
float(np.min(point_height_m[indices])),
|
||||
float(np.max(point_height_m[indices])),
|
||||
],
|
||||
"nearest_range_m": float(np.min(distances)),
|
||||
"unknown_is_occupied": True,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
occupied_source_indices=indices.astype(np.int64, copy=False),
|
||||
)
|
||||
)
|
||||
documents.sort(
|
||||
key=lambda item: (
|
||||
_required_float(item["nearest_range_m"]),
|
||||
-_required_int(item["point_count"]),
|
||||
supports.sort(
|
||||
key=lambda support: (
|
||||
_required_float(support.document["nearest_range_m"]),
|
||||
-_required_int(support.document["point_count"]),
|
||||
)
|
||||
)
|
||||
return documents[: profile.maximum_geometry_clusters_per_frame]
|
||||
return supports[: profile.maximum_geometry_clusters_per_frame]
|
||||
|
||||
|
||||
def _voxel_components(
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Provider-neutral sensor representation capabilities and algorithm admission.
|
||||
|
||||
This module does not reinterpret source evidence. It records which physical
|
||||
properties a representation actually provides and rejects algorithms whose
|
||||
declared requirements are not satisfied. Compatibility never grants command,
|
||||
navigation or safety authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
SENSOR_REPRESENTATION_CAPABILITIES_SCHEMA: Final = (
|
||||
"missioncore.sensor-representation-capabilities/v1"
|
||||
)
|
||||
SENSOR_ALGORITHM_REQUIREMENTS_SCHEMA: Final = (
|
||||
"missioncore.sensor-algorithm-requirements/v1"
|
||||
)
|
||||
SENSOR_ALGORITHM_ADMISSION_SCHEMA: Final = "missioncore.sensor-algorithm-admission/v1"
|
||||
|
||||
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$")
|
||||
|
||||
|
||||
class SensorRepresentationContractError(ValueError):
|
||||
"""A representation, requirement or admission violates the v1 contract."""
|
||||
|
||||
|
||||
class SensorRepresentationKind(StrEnum):
|
||||
NATIVE_SENSOR_SCAN = "native-sensor-scan"
|
||||
REGISTERED_MAP_INCREMENT = "registered-map-increment"
|
||||
ACCUMULATED_MAP = "accumulated-map"
|
||||
DEPTH_IMAGE = "depth-image"
|
||||
|
||||
|
||||
class SensorSourceCurrentness(StrEnum):
|
||||
CURRENT_OBSERVATION = "current-observation"
|
||||
FRAME_INCREMENT = "frame-increment"
|
||||
PERSISTENT_RECONSTRUCTION = "persistent-reconstruction"
|
||||
|
||||
|
||||
class SensorCapability(StrEnum):
|
||||
METRIC_XYZ = "metric_xyz"
|
||||
METRIC_INTENSITY = "metric_intensity"
|
||||
MAP_REGISTERED = "map_registered"
|
||||
SENSOR_POSE_AVAILABLE = "sensor_pose_available"
|
||||
PER_POINT_TIME = "per_point_time"
|
||||
RING_OR_CHANNEL = "ring_or_channel"
|
||||
SEPARATE_IMU = "separate_imu"
|
||||
SHARED_HARDWARE_CLOCK = "shared_hardware_clock"
|
||||
NATIVE_RAY_MODEL = "native_ray_model"
|
||||
SENSOR_ORIGIN_PER_POINT = "sensor_origin_per_point"
|
||||
RAY_CLEARING_VALID = "ray_clearing_valid"
|
||||
MOTION_COMPENSATION_VALID = "motion_compensation_valid"
|
||||
FREE_SPACE_EVIDENCE_VALID = "free_space_evidence_valid"
|
||||
PERSISTENT_RECONSTRUCTION = "persistent_reconstruction"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SensorRepresentationCapabilities:
|
||||
"""State the admitted physical meaning of one versioned sensor product."""
|
||||
|
||||
profile_id: str
|
||||
source_profile_id: str
|
||||
representation_kind: SensorRepresentationKind
|
||||
coordinate_frame: str
|
||||
source_currentness: SensorSourceCurrentness
|
||||
capabilities: frozenset[SensorCapability]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_safe_identifier(self.profile_id, "representation profile id")
|
||||
_safe_identifier(self.source_profile_id, "source profile id")
|
||||
_safe_identifier(self.coordinate_frame, "coordinate frame")
|
||||
if not self.capabilities:
|
||||
raise SensorRepresentationContractError(
|
||||
"representation must declare at least one admitted capability"
|
||||
)
|
||||
|
||||
capabilities = self.capabilities
|
||||
point_capabilities = {
|
||||
SensorCapability.METRIC_INTENSITY,
|
||||
SensorCapability.MAP_REGISTERED,
|
||||
SensorCapability.PER_POINT_TIME,
|
||||
SensorCapability.RING_OR_CHANNEL,
|
||||
SensorCapability.NATIVE_RAY_MODEL,
|
||||
SensorCapability.SENSOR_ORIGIN_PER_POINT,
|
||||
SensorCapability.RAY_CLEARING_VALID,
|
||||
SensorCapability.MOTION_COMPENSATION_VALID,
|
||||
SensorCapability.FREE_SPACE_EVIDENCE_VALID,
|
||||
}
|
||||
if (
|
||||
capabilities.intersection(point_capabilities)
|
||||
and SensorCapability.METRIC_XYZ not in capabilities
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"point-level capabilities require metric xyz"
|
||||
)
|
||||
if (
|
||||
SensorCapability.RAY_CLEARING_VALID in capabilities
|
||||
and (
|
||||
SensorCapability.NATIVE_RAY_MODEL not in capabilities
|
||||
or SensorCapability.SENSOR_ORIGIN_PER_POINT not in capabilities
|
||||
)
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"ray clearing requires a native ray model and per-point sensor origin"
|
||||
)
|
||||
if (
|
||||
SensorCapability.MOTION_COMPENSATION_VALID in capabilities
|
||||
and (
|
||||
SensorCapability.PER_POINT_TIME not in capabilities
|
||||
or SensorCapability.SENSOR_POSE_AVAILABLE not in capabilities
|
||||
)
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"motion compensation requires point time and sensor pose"
|
||||
)
|
||||
if (
|
||||
SensorCapability.FREE_SPACE_EVIDENCE_VALID in capabilities
|
||||
and SensorCapability.RAY_CLEARING_VALID not in capabilities
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"free-space evidence requires admitted ray clearing"
|
||||
)
|
||||
|
||||
persistent = SensorCapability.PERSISTENT_RECONSTRUCTION in capabilities
|
||||
currentness_is_persistent = (
|
||||
self.source_currentness
|
||||
is SensorSourceCurrentness.PERSISTENT_RECONSTRUCTION
|
||||
)
|
||||
if persistent is not currentness_is_persistent:
|
||||
raise SensorRepresentationContractError(
|
||||
"persistent reconstruction capability and currentness disagree"
|
||||
)
|
||||
|
||||
if self.representation_kind is SensorRepresentationKind.REGISTERED_MAP_INCREMENT:
|
||||
required = {
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.MAP_REGISTERED,
|
||||
}
|
||||
if not required.issubset(capabilities):
|
||||
raise SensorRepresentationContractError(
|
||||
"registered map increments require metric map-registered xyz"
|
||||
)
|
||||
if self.source_currentness is not SensorSourceCurrentness.FRAME_INCREMENT:
|
||||
raise SensorRepresentationContractError(
|
||||
"registered map increments must declare frame-increment currentness"
|
||||
)
|
||||
elif self.representation_kind is SensorRepresentationKind.ACCUMULATED_MAP:
|
||||
required = {
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.MAP_REGISTERED,
|
||||
SensorCapability.PERSISTENT_RECONSTRUCTION,
|
||||
}
|
||||
if not required.issubset(capabilities):
|
||||
raise SensorRepresentationContractError(
|
||||
"accumulated maps require persistent map-registered metric xyz"
|
||||
)
|
||||
|
||||
def supports(self, capability: SensorCapability) -> bool:
|
||||
return capability in self.capabilities
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SENSOR_REPRESENTATION_CAPABILITIES_SCHEMA,
|
||||
"profile_id": self.profile_id,
|
||||
"source_profile_id": self.source_profile_id,
|
||||
"representation_kind": self.representation_kind.value,
|
||||
"coordinate_frame": self.coordinate_frame,
|
||||
"source_currentness": self.source_currentness.value,
|
||||
"capabilities": {
|
||||
capability.value: capability in self.capabilities
|
||||
for capability in SensorCapability
|
||||
},
|
||||
"semantics": {
|
||||
"absence_of_endpoints_means_free": False,
|
||||
"unknown_remains_unknown": True,
|
||||
},
|
||||
"authority": {
|
||||
"compatibility_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SensorRepresentationCapabilities:
|
||||
document = _object(value, "sensor representation capabilities")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"schema_version",
|
||||
"profile_id",
|
||||
"source_profile_id",
|
||||
"representation_kind",
|
||||
"coordinate_frame",
|
||||
"source_currentness",
|
||||
"capabilities",
|
||||
"semantics",
|
||||
"authority",
|
||||
},
|
||||
"sensor representation capabilities",
|
||||
)
|
||||
if (
|
||||
document.get("schema_version")
|
||||
!= SENSOR_REPRESENTATION_CAPABILITIES_SCHEMA
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"sensor representation capabilities schema is incompatible"
|
||||
)
|
||||
|
||||
capability_document = _object(
|
||||
document.get("capabilities"),
|
||||
"sensor capabilities",
|
||||
)
|
||||
expected_capability_keys = {capability.value for capability in SensorCapability}
|
||||
_exact_keys(
|
||||
capability_document,
|
||||
expected_capability_keys,
|
||||
"sensor capabilities",
|
||||
)
|
||||
capabilities = frozenset(
|
||||
capability
|
||||
for capability in SensorCapability
|
||||
if _bool(capability_document, capability.value)
|
||||
)
|
||||
|
||||
semantics = _object(document.get("semantics"), "sensor semantics")
|
||||
_exact_keys(
|
||||
semantics,
|
||||
{"absence_of_endpoints_means_free", "unknown_remains_unknown"},
|
||||
"sensor semantics",
|
||||
)
|
||||
if (
|
||||
_bool(semantics, "absence_of_endpoints_means_free")
|
||||
or not _bool(semantics, "unknown_remains_unknown")
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"v1 sensor semantics cannot infer free space from missing endpoints"
|
||||
)
|
||||
|
||||
authority = _object(document.get("authority"), "sensor authority")
|
||||
_exact_keys(
|
||||
authority,
|
||||
{
|
||||
"compatibility_only",
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
},
|
||||
"sensor authority",
|
||||
)
|
||||
if (
|
||||
not _bool(authority, "compatibility_only")
|
||||
or _bool(authority, "commands_enabled")
|
||||
or _bool(authority, "navigation_or_safety_accepted")
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"v1 sensor capability admission cannot grant authority"
|
||||
)
|
||||
|
||||
try:
|
||||
return cls(
|
||||
profile_id=_string(document, "profile_id"),
|
||||
source_profile_id=_string(document, "source_profile_id"),
|
||||
representation_kind=SensorRepresentationKind(
|
||||
_string(document, "representation_kind")
|
||||
),
|
||||
coordinate_frame=_string(document, "coordinate_frame"),
|
||||
source_currentness=SensorSourceCurrentness(
|
||||
_string(document, "source_currentness")
|
||||
),
|
||||
capabilities=capabilities,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SensorRepresentationContractError(
|
||||
"sensor representation enum is unknown"
|
||||
) from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SensorAlgorithmRequirements:
|
||||
"""Declare the minimum representation properties required by an algorithm."""
|
||||
|
||||
algorithm_id: str
|
||||
accepted_representations: tuple[SensorRepresentationKind, ...]
|
||||
required_capabilities: frozenset[SensorCapability]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_safe_identifier(self.algorithm_id, "algorithm id")
|
||||
if not self.accepted_representations:
|
||||
raise SensorRepresentationContractError(
|
||||
"algorithm must declare at least one accepted representation"
|
||||
)
|
||||
if len(self.accepted_representations) != len(
|
||||
set(self.accepted_representations)
|
||||
):
|
||||
raise SensorRepresentationContractError(
|
||||
"accepted algorithm representations must be unique"
|
||||
)
|
||||
if not self.required_capabilities:
|
||||
raise SensorRepresentationContractError(
|
||||
"algorithm must declare at least one required capability"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SENSOR_ALGORITHM_REQUIREMENTS_SCHEMA,
|
||||
"algorithm_id": self.algorithm_id,
|
||||
"accepted_representations": [
|
||||
representation.value for representation in self.accepted_representations
|
||||
],
|
||||
"required_capabilities": sorted(
|
||||
capability.value for capability in self.required_capabilities
|
||||
),
|
||||
"on_capability_mismatch": "reject",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: object) -> SensorAlgorithmRequirements:
|
||||
document = _object(value, "sensor algorithm requirements")
|
||||
_exact_keys(
|
||||
document,
|
||||
{
|
||||
"schema_version",
|
||||
"algorithm_id",
|
||||
"accepted_representations",
|
||||
"required_capabilities",
|
||||
"on_capability_mismatch",
|
||||
},
|
||||
"sensor algorithm requirements",
|
||||
)
|
||||
if document.get("schema_version") != SENSOR_ALGORITHM_REQUIREMENTS_SCHEMA:
|
||||
raise SensorRepresentationContractError(
|
||||
"sensor algorithm requirements schema is incompatible"
|
||||
)
|
||||
if document.get("on_capability_mismatch") != "reject":
|
||||
raise SensorRepresentationContractError(
|
||||
"sensor algorithm capability mismatch must reject"
|
||||
)
|
||||
try:
|
||||
accepted_representations = tuple(
|
||||
SensorRepresentationKind(_string_value(item, "representation kind"))
|
||||
for item in _array(document, "accepted_representations")
|
||||
)
|
||||
required_capabilities = frozenset(
|
||||
SensorCapability(_string_value(item, "sensor capability"))
|
||||
for item in _array(document, "required_capabilities")
|
||||
)
|
||||
return cls(
|
||||
algorithm_id=_string(document, "algorithm_id"),
|
||||
accepted_representations=accepted_representations,
|
||||
required_capabilities=required_capabilities,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SensorRepresentationContractError(
|
||||
"sensor algorithm requirement enum is unknown"
|
||||
) from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SensorAlgorithmAdmission:
|
||||
profile_id: str
|
||||
algorithm_id: str
|
||||
admitted: bool
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": SENSOR_ALGORITHM_ADMISSION_SCHEMA,
|
||||
"profile_id": self.profile_id,
|
||||
"algorithm_id": self.algorithm_id,
|
||||
"admitted": self.admitted,
|
||||
"reasons": list(self.reasons),
|
||||
"authority": {
|
||||
"compatibility_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def assess_sensor_algorithm(
|
||||
profile: SensorRepresentationCapabilities,
|
||||
requirements: SensorAlgorithmRequirements,
|
||||
) -> SensorAlgorithmAdmission:
|
||||
"""Return a deterministic binary admission without inferring capabilities."""
|
||||
|
||||
reasons: list[str] = []
|
||||
if profile.representation_kind not in requirements.accepted_representations:
|
||||
reasons.append(
|
||||
f"representation-not-accepted:{profile.representation_kind.value}"
|
||||
)
|
||||
missing_capabilities = requirements.required_capabilities.difference(
|
||||
profile.capabilities
|
||||
)
|
||||
reasons.extend(
|
||||
f"missing-capability:{capability.value}"
|
||||
for capability in sorted(missing_capabilities, key=lambda item: item.value)
|
||||
)
|
||||
return SensorAlgorithmAdmission(
|
||||
profile_id=profile.profile_id,
|
||||
algorithm_id=requirements.algorithm_id,
|
||||
admitted=not reasons,
|
||||
reasons=tuple(reasons),
|
||||
)
|
||||
|
||||
|
||||
def require_sensor_algorithm_admission(
|
||||
profile: SensorRepresentationCapabilities,
|
||||
requirements: SensorAlgorithmRequirements,
|
||||
) -> SensorAlgorithmAdmission:
|
||||
"""Return admission or reject the algorithm/representation pairing."""
|
||||
|
||||
admission = assess_sensor_algorithm(profile, requirements)
|
||||
if not admission.admitted:
|
||||
raise SensorRepresentationContractError(
|
||||
f"{requirements.algorithm_id} rejected for {profile.profile_id}: "
|
||||
+ ", ".join(admission.reasons)
|
||||
)
|
||||
return admission
|
||||
|
||||
|
||||
def _safe_identifier(value: str, label: str) -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise SensorRepresentationContractError(f"{label} is not a safe identifier")
|
||||
return value
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
||||
raise SensorRepresentationContractError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_keys(
|
||||
document: dict[str, object],
|
||||
expected: set[str],
|
||||
label: str,
|
||||
) -> None:
|
||||
if set(document) != expected:
|
||||
raise SensorRepresentationContractError(f"{label} fields are incompatible")
|
||||
|
||||
|
||||
def _array(document: dict[str, object], key: str) -> list[object]:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, list):
|
||||
raise SensorRepresentationContractError(f"{key} must be an array")
|
||||
return value
|
||||
|
||||
|
||||
def _string(document: dict[str, object], key: str) -> str:
|
||||
return _string_value(document.get(key), key)
|
||||
|
||||
|
||||
def _string_value(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise SensorRepresentationContractError(f"{label} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _bool(document: dict[str, object], key: str) -> bool:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, bool):
|
||||
raise SensorRepresentationContractError(f"{key} must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
K1_LIO_PCL_CAPABILITIES: Final = SensorRepresentationCapabilities(
|
||||
profile_id="xgrids-k1-live-lio-pcl-capabilities/v1",
|
||||
source_profile_id="xgrids-k1-live-lio-pcl/v1",
|
||||
representation_kind=SensorRepresentationKind.REGISTERED_MAP_INCREMENT,
|
||||
coordinate_frame="map",
|
||||
source_currentness=SensorSourceCurrentness.FRAME_INCREMENT,
|
||||
capabilities=frozenset(
|
||||
{
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.METRIC_INTENSITY,
|
||||
SensorCapability.MAP_REGISTERED,
|
||||
SensorCapability.SENSOR_POSE_AVAILABLE,
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -33,6 +33,9 @@ from k1link.sessions import (
|
||||
SessionStore,
|
||||
)
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
from k1link.web.e30_review_api import build_e30_review_router
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
@@ -493,6 +496,88 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_review_router(
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_engineering_router(
|
||||
generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "engineering-generations"
|
||||
),
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_human_review_router(
|
||||
materialization_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "materializations"
|
||||
),
|
||||
review_pack_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "review-packs"
|
||||
),
|
||||
engineering_generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "engineering-generations"
|
||||
),
|
||||
draft_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "human-review-drafts"
|
||||
),
|
||||
generation_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e30"
|
||||
/ "human-review-generations"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from k1link.compute.e30_engineering_generation import (
|
||||
E30_ENGINEERING_CAUSES_SCHEMA,
|
||||
E30_ENGINEERING_DECISION_SCHEMA,
|
||||
E30_ENGINEERING_DECISION_SCHEMAS,
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMA,
|
||||
E30_ENGINEERING_EXCEPTION_SCHEMAS,
|
||||
E30_ENGINEERING_GENERATION_SCHEMA,
|
||||
E30_ENGINEERING_SUMMARY_SCHEMA,
|
||||
EXCEPTION_DISPOSITIONS,
|
||||
)
|
||||
from k1link.web.e30_review_api import (
|
||||
E30ReviewEvidenceError,
|
||||
e30_review_item_summary,
|
||||
load_verified_e30_review,
|
||||
)
|
||||
|
||||
LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-generations/v1"
|
||||
)
|
||||
LABORATORY_E30_ENGINEERING_DECISION_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-decision/v1"
|
||||
)
|
||||
LABORATORY_E30_ENGINEERING_EXCEPTIONS_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-engineering-exceptions/v1"
|
||||
)
|
||||
|
||||
_GENERATION_ID = re.compile(r"^e30-engineering-generation-[a-f0-9]{64}$")
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_MAX_MANIFEST_BYTES: Final = 1024 * 1024
|
||||
_MAX_DECISIONS_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_AUXILIARY_BYTES: Final = 2 * 1024 * 1024
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E30EngineeringEvidenceError(ValueError):
|
||||
"""An engineering generation is incomplete, changed, or incompatible."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30EngineeringEvidenceError("engineering JSON is unavailable")
|
||||
size = path.stat().st_size
|
||||
if not 0 < size <= maximum_bytes:
|
||||
raise E30EngineeringEvidenceError("engineering JSON is out of bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30EngineeringEvidenceError("engineering JSON is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError("engineering JSON must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_file(root: Path, value: object) -> Path:
|
||||
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
||||
raise E30EngineeringEvidenceError("engineering artifact path is invalid")
|
||||
if ".." in Path(value).parts:
|
||||
raise E30EngineeringEvidenceError("engineering artifact escaped its root")
|
||||
candidate = root / value
|
||||
if candidate.is_symlink():
|
||||
raise E30EngineeringEvidenceError("engineering artifact is a symlink")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering artifact escaped its root"
|
||||
) from exc
|
||||
if not candidate.is_file():
|
||||
raise E30EngineeringEvidenceError("engineering artifact is unavailable")
|
||||
return candidate
|
||||
|
||||
|
||||
def _artifact(
|
||||
root: Path,
|
||||
value: object,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError("engineering artifact metadata is invalid")
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < (0 if allow_empty else 1)
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering artifact metadata is invalid")
|
||||
path = _safe_file(root, value.get("path"))
|
||||
if path.stat().st_size != byte_length or _sha256(path) != digest:
|
||||
raise E30EngineeringEvidenceError("engineering artifact content changed")
|
||||
return path, value
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, maximum_bytes: int) -> list[dict[str, Any]]:
|
||||
if path.stat().st_size > maximum_bytes:
|
||||
raise E30EngineeringEvidenceError("engineering JSONL is out of bounds")
|
||||
values: list[dict[str, Any]] = []
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering JSONL is invalid"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering JSONL row must be an object"
|
||||
)
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def _signature(root: Path) -> tuple[int, ...]:
|
||||
paths = [
|
||||
root / "manifest.json",
|
||||
root / "engineering-decisions.jsonl",
|
||||
root / "human-exceptions.jsonl",
|
||||
root / "summary.json",
|
||||
root / "cause-distribution.json",
|
||||
]
|
||||
signature: list[int] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _authority(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("commands_enabled") is not False
|
||||
or value.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation must remain diagnostic-only"
|
||||
)
|
||||
|
||||
|
||||
def _verify_materialization_binding(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
source: dict[str, Any],
|
||||
) -> None:
|
||||
result_id = source.get("materialization_id")
|
||||
if (
|
||||
not isinstance(result_id, str)
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("materialization binding is invalid")
|
||||
candidate = materialization_root / result_id
|
||||
manifest_path = candidate / "manifest.json"
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30EngineeringEvidenceError("materialization binding is unavailable")
|
||||
manifest = _read_json(manifest_path, _MAX_MANIFEST_BYTES)
|
||||
if (
|
||||
manifest.get("result_id") != result_id
|
||||
or manifest.get("identity_sha256")
|
||||
!= source.get("materialization_identity_sha256")
|
||||
or _sha256(manifest_path) != source.get("materialization_manifest_sha256")
|
||||
or _sha256(candidate / "materialized-items.jsonl")
|
||||
!= source.get("materialization_index_sha256")
|
||||
):
|
||||
raise E30EngineeringEvidenceError("materialization binding changed")
|
||||
|
||||
|
||||
def _validate_decisions(
|
||||
values: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, object], dict[str, object]]:
|
||||
if (
|
||||
len(values) != 486
|
||||
or len({value.get("item_id") for value in values}) != len(values)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering coverage is incomplete")
|
||||
verdicts: Counter[str] = Counter()
|
||||
detector: Counter[str] = Counter()
|
||||
projection: Counter[str] = Counter()
|
||||
ownership: Counter[str] = Counter()
|
||||
causes: Counter[str] = Counter()
|
||||
exceptions = 0
|
||||
confidence_total = 0.0
|
||||
for sequence, value in enumerate(values):
|
||||
confidence = value.get("confidence")
|
||||
schema_version = value.get("schema_version")
|
||||
exception_required = value.get("human_exception_required")
|
||||
review_prompt = value.get("review_prompt")
|
||||
if (
|
||||
schema_version not in E30_ENGINEERING_DECISION_SCHEMAS
|
||||
or value.get("sequence") != sequence
|
||||
or _ITEM_ID.fullmatch(str(value.get("item_id"))) is None
|
||||
or not isinstance(value.get("verdict"), str)
|
||||
or not isinstance(value.get("detector_assessment"), str)
|
||||
or not isinstance(value.get("projection_assessment"), str)
|
||||
or not isinstance(value.get("point_ownership"), str)
|
||||
or not isinstance(confidence, (int, float))
|
||||
or isinstance(confidence, bool)
|
||||
or not math.isfinite(float(confidence))
|
||||
or not isinstance(exception_required, bool)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering decision is invalid")
|
||||
if schema_version == E30_ENGINEERING_DECISION_SCHEMA and (
|
||||
exception_required != _valid_review_prompt(review_prompt)
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception prompt is invalid"
|
||||
)
|
||||
verdicts[value["verdict"]] += 1
|
||||
detector[value["detector_assessment"]] += 1
|
||||
projection[value["projection_assessment"]] += 1
|
||||
ownership[value["point_ownership"]] += 1
|
||||
if isinstance(value.get("cause_code"), str):
|
||||
causes[value["cause_code"]] += 1
|
||||
if value["human_exception_required"]:
|
||||
exceptions += 1
|
||||
confidence_total += float(confidence)
|
||||
summary: dict[str, object] = {
|
||||
"schema_version": E30_ENGINEERING_SUMMARY_SCHEMA,
|
||||
"item_count": len(values),
|
||||
"reviewed_item_count": len(values),
|
||||
"verdict_distribution": dict(sorted(verdicts.items())),
|
||||
"detector_distribution": dict(sorted(detector.items())),
|
||||
"projection_distribution": dict(sorted(projection.items())),
|
||||
"point_ownership_distribution": dict(sorted(ownership.items())),
|
||||
"human_exception_count": exceptions,
|
||||
"mean_confidence": round(confidence_total / len(values), 4),
|
||||
}
|
||||
cause_document: dict[str, object] = {
|
||||
"schema_version": E30_ENGINEERING_CAUSES_SCHEMA,
|
||||
"item_count_with_cause": sum(causes.values()),
|
||||
"reasons": [
|
||||
{"reason_code": reason, "count": count}
|
||||
for reason, count in sorted(causes.items())
|
||||
],
|
||||
}
|
||||
return summary, cause_document
|
||||
|
||||
|
||||
def _valid_review_prompt(value: object) -> bool:
|
||||
if not isinstance(value, dict) or set(value) != {"question", "focus", "effects"}:
|
||||
return False
|
||||
effects = value.get("effects")
|
||||
return (
|
||||
isinstance(value.get("question"), str)
|
||||
and bool(value["question"].strip())
|
||||
and isinstance(value.get("focus"), str)
|
||||
and bool(value["focus"].strip())
|
||||
and isinstance(effects, dict)
|
||||
and set(effects) == set(EXCEPTION_DISPOSITIONS)
|
||||
and all(
|
||||
isinstance(effects.get(disposition), str)
|
||||
and bool(effects[disposition].strip())
|
||||
for disposition in EXCEPTION_DISPOSITIONS
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _generation_cached(
|
||||
root_text: str,
|
||||
materialization_root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> tuple[dict[str, object], tuple[dict[str, Any], ...]]:
|
||||
del signature
|
||||
root = Path(root_text)
|
||||
materialization_root = Path(materialization_root_text)
|
||||
if (
|
||||
root.is_symlink()
|
||||
or not root.is_dir()
|
||||
or _GENERATION_ID.fullmatch(root.name) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering generation id is invalid")
|
||||
manifest = _read_json(root / "manifest.json", _MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_ENGINEERING_GENERATION_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or root.name != f"e30-engineering-generation-{identity_sha256}"
|
||||
or manifest.get("ai_review_complete") is not True
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation identity is invalid"
|
||||
)
|
||||
_authority(manifest.get("authority"))
|
||||
source = identity.get("source")
|
||||
producer = identity.get("producer")
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or not isinstance(producer, dict)
|
||||
or producer.get("kind") != "ai-assisted-engineering-review"
|
||||
or producer.get("claims_human_ground_truth") is not False
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering provenance is invalid")
|
||||
_verify_materialization_binding(
|
||||
materialization_root=materialization_root,
|
||||
source=source,
|
||||
)
|
||||
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise E30EngineeringEvidenceError("engineering artifacts are invalid")
|
||||
by_role = {
|
||||
str(value.get("role")): value
|
||||
for value in artifacts
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
required_roles = {
|
||||
"engineering-decisions",
|
||||
"human-exceptions",
|
||||
"engineering-summary",
|
||||
"cause-distribution",
|
||||
}
|
||||
if set(by_role) != required_roles or len(artifacts) != len(required_roles):
|
||||
raise E30EngineeringEvidenceError("engineering artifacts are incomplete")
|
||||
decisions_path, decisions_artifact = _artifact(
|
||||
root,
|
||||
by_role["engineering-decisions"],
|
||||
)
|
||||
exceptions_path, _ = _artifact(
|
||||
root,
|
||||
by_role["human-exceptions"],
|
||||
allow_empty=True,
|
||||
)
|
||||
summary_path, _ = _artifact(root, by_role["engineering-summary"])
|
||||
causes_path, _ = _artifact(root, by_role["cause-distribution"])
|
||||
decisions = _read_jsonl(decisions_path, _MAX_DECISIONS_BYTES)
|
||||
exceptions = _read_jsonl(exceptions_path, _MAX_AUXILIARY_BYTES)
|
||||
for exception in exceptions:
|
||||
exception_schema = exception.get("schema_version")
|
||||
if (
|
||||
exception_schema not in E30_ENGINEERING_EXCEPTION_SCHEMAS
|
||||
or (
|
||||
exception_schema == E30_ENGINEERING_EXCEPTION_SCHEMA
|
||||
and not _valid_review_prompt(exception.get("review_prompt"))
|
||||
)
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception contract is invalid"
|
||||
)
|
||||
calculated_summary, calculated_causes = _validate_decisions(decisions)
|
||||
summary = _read_json(summary_path, _MAX_AUXILIARY_BYTES)
|
||||
causes = _read_json(causes_path, _MAX_AUXILIARY_BYTES)
|
||||
if (
|
||||
summary != calculated_summary
|
||||
or causes != calculated_causes
|
||||
or manifest.get("summary") != summary
|
||||
or manifest.get("cause_distribution") != causes
|
||||
or len(exceptions) != summary["human_exception_count"]
|
||||
or identity.get("decision_content_sha256")
|
||||
!= decisions_artifact.get("sha256")
|
||||
or source.get("item_count") != len(decisions)
|
||||
):
|
||||
raise E30EngineeringEvidenceError("engineering summary changed")
|
||||
exception_ids = {value.get("item_id") for value in exceptions}
|
||||
expected_exception_ids = {
|
||||
value["item_id"]
|
||||
for value in decisions
|
||||
if value["human_exception_required"]
|
||||
}
|
||||
if exception_ids != expected_exception_ids:
|
||||
raise E30EngineeringEvidenceError("engineering exception queue changed")
|
||||
catalog_item: dict[str, object] = {
|
||||
"generation_id": root.name,
|
||||
"created_at_utc": manifest.get("created_at_utc"),
|
||||
"materialization_id": source["materialization_id"],
|
||||
"producer": copy.deepcopy(producer),
|
||||
"summary": copy.deepcopy(summary),
|
||||
"cause_distribution": copy.deepcopy(causes),
|
||||
"human_exceptions": [
|
||||
{
|
||||
"item_id": value["item_id"],
|
||||
"review_key": value["review_key"],
|
||||
"source_stratum": value["source_stratum"],
|
||||
"confidence": value["confidence"],
|
||||
"review_prompt": copy.deepcopy(value.get("review_prompt")),
|
||||
}
|
||||
for value in exceptions
|
||||
],
|
||||
"ai_review_complete": True,
|
||||
"human_exception_complete": (
|
||||
manifest.get("human_exception_complete") is True
|
||||
),
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"access": "read-only",
|
||||
"authority": copy.deepcopy(manifest["authority"]),
|
||||
}
|
||||
return catalog_item, tuple(decisions)
|
||||
|
||||
|
||||
def load_verified_e30_engineering_generation(
|
||||
*,
|
||||
generation_root: Path,
|
||||
materialization_root: Path,
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
) -> tuple[dict[str, object], tuple[dict[str, Any], ...]]:
|
||||
generation_root = generation_root.resolve()
|
||||
materialization_root = materialization_root.resolve()
|
||||
if (
|
||||
not generation_root.is_dir()
|
||||
or not materialization_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation is unavailable"
|
||||
)
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation is unavailable"
|
||||
)
|
||||
catalog_item, decisions = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering generation materialization differs"
|
||||
)
|
||||
return copy.deepcopy(catalog_item), decisions
|
||||
|
||||
|
||||
def build_e30_engineering_router(
|
||||
*,
|
||||
generation_root_provider: RootProvider = lambda: None,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
def roots() -> tuple[Path, Path] | None:
|
||||
generation_root = generation_root_provider()
|
||||
materialization_root = materialization_root_provider()
|
||||
if generation_root is None or materialization_root is None:
|
||||
return None
|
||||
generation_root = generation_root.resolve()
|
||||
materialization_root = materialization_root.resolve()
|
||||
if not generation_root.is_dir() or not materialization_root.is_dir():
|
||||
return None
|
||||
return generation_root, materialization_root
|
||||
|
||||
@router.get("/reviews/{result_id}/engineering-generations")
|
||||
def list_engineering_generations(
|
||||
result_id: str,
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
if (
|
||||
configured_roots is None
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA,
|
||||
"configured": configured_roots is not None,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidates = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in generation_root.iterdir()
|
||||
if candidate.is_dir()
|
||||
and _GENERATION_ID.fullmatch(candidate.name) is not None
|
||||
),
|
||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
matching_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
item, _ = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if item["materialization_id"] != result_id:
|
||||
continue
|
||||
matching_total += 1
|
||||
if len(items) < limit:
|
||||
items.append(copy.deepcopy(item))
|
||||
except (E30EngineeringEvidenceError, OSError):
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items,
|
||||
"candidate_total": matching_total,
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get(
|
||||
"/reviews/{result_id}/engineering-generations/"
|
||||
"{generation_id}/exceptions"
|
||||
)
|
||||
def list_engineering_exceptions(
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
limit: int = Query(default=48, ge=1, le=128),
|
||||
cursor: int = Query(default=0, ge=0),
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
review_pack_root = review_pack_root_provider()
|
||||
if (
|
||||
configured_roots is None
|
||||
or review_pack_root is None
|
||||
or not review_pack_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
try:
|
||||
catalog_item, _ = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering exception queue не найдена",
|
||||
)
|
||||
_, rows, _ = load_verified_e30_review(
|
||||
materialization_root=materialization_root,
|
||||
review_pack_root=review_pack_root,
|
||||
result_id=result_id,
|
||||
)
|
||||
rows_by_id = {row["item_id"]: row for row in rows}
|
||||
exception_ids = [
|
||||
value["item_id"]
|
||||
for value in catalog_item["human_exceptions"]
|
||||
]
|
||||
if any(item_id not in rows_by_id for item_id in exception_ids):
|
||||
raise E30EngineeringEvidenceError(
|
||||
"engineering exception source item is unavailable"
|
||||
)
|
||||
page_ids = exception_ids[cursor : cursor + limit]
|
||||
next_cursor = cursor + len(page_ids)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_EXCEPTIONS_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"generation_id": generation_id,
|
||||
"items": [
|
||||
e30_review_item_summary(rows_by_id[item_id])
|
||||
for item_id in page_ids
|
||||
],
|
||||
"total": len(exception_ids),
|
||||
"next_cursor": (
|
||||
next_cursor if next_cursor < len(exception_ids) else None
|
||||
),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (
|
||||
E30EngineeringEvidenceError,
|
||||
E30ReviewEvidenceError,
|
||||
OSError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 engineering exception queue не прошла проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get(
|
||||
"/reviews/{result_id}/engineering-generations/"
|
||||
"{generation_id}/items/{item_id}"
|
||||
)
|
||||
def get_engineering_decision(
|
||||
result_id: str,
|
||||
generation_id: str,
|
||||
item_id: str,
|
||||
) -> dict[str, object]:
|
||||
configured_roots = roots()
|
||||
if (
|
||||
configured_roots is None
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
or _GENERATION_ID.fullmatch(generation_id) is None
|
||||
or _ITEM_ID.fullmatch(item_id) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
generation_root, materialization_root = configured_roots
|
||||
candidate = generation_root / generation_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
try:
|
||||
catalog_item, decisions = _generation_cached(
|
||||
str(candidate.resolve()),
|
||||
str(materialization_root),
|
||||
_signature(candidate),
|
||||
)
|
||||
if catalog_item["materialization_id"] != result_id:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
decision = next(
|
||||
(value for value in decisions if value["item_id"] == item_id),
|
||||
None,
|
||||
)
|
||||
if decision is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 engineering decision не найден",
|
||||
)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ENGINEERING_DECISION_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"decision": copy.deepcopy(decision),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30EngineeringEvidenceError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 engineering generation не прошла проверку целостности",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Path as ApiPath
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30HumanReviewConflictError,
|
||||
E30HumanReviewIntegrityError,
|
||||
E30HumanReviewNotFoundError,
|
||||
E30HumanReviewStore,
|
||||
E30HumanReviewValidationError,
|
||||
E30ExceptionDisposition,
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
)
|
||||
from k1link.web.e30_engineering_api import (
|
||||
E30EngineeringEvidenceError,
|
||||
load_verified_e30_engineering_generation,
|
||||
)
|
||||
from k1link.web.e30_review_api import (
|
||||
E30ReviewEvidenceError,
|
||||
load_verified_e30_review,
|
||||
)
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E30HumanReviewCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reviewer_id: str = Field(min_length=1, max_length=128)
|
||||
engineering_generation_id: str = Field(
|
||||
pattern=r"^e30-engineering-generation-[a-f0-9]{64}$"
|
||||
)
|
||||
|
||||
|
||||
class E30HumanReviewDecisionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=0)
|
||||
idempotency_key: str = Field(min_length=1, max_length=128)
|
||||
disposition: E30ExceptionDisposition
|
||||
notes: str | None = Field(default=None, max_length=2_000)
|
||||
|
||||
|
||||
class E30HumanReviewFinalizeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=0)
|
||||
confirm_generation: Literal[True]
|
||||
|
||||
|
||||
def build_e30_human_review_router(
|
||||
*,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
engineering_generation_root_provider: RootProvider = lambda: None,
|
||||
draft_root_provider: RootProvider = lambda: None,
|
||||
generation_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
def source(
|
||||
result_id: str,
|
||||
engineering_generation_id: str,
|
||||
) -> E30ReviewSubstrate:
|
||||
materialization_root = materialization_root_provider()
|
||||
review_pack_root = review_pack_root_provider()
|
||||
engineering_generation_root = engineering_generation_root_provider()
|
||||
if (
|
||||
materialization_root is None
|
||||
or review_pack_root is None
|
||||
or engineering_generation_root is None
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
try:
|
||||
_, _, source_substrate = load_verified_e30_review(
|
||||
materialization_root=materialization_root,
|
||||
review_pack_root=review_pack_root,
|
||||
result_id=result_id,
|
||||
)
|
||||
generation, _ = load_verified_e30_engineering_generation(
|
||||
generation_root=engineering_generation_root,
|
||||
materialization_root=materialization_root,
|
||||
result_id=result_id,
|
||||
generation_id=engineering_generation_id,
|
||||
)
|
||||
subjects_by_id = {
|
||||
subject.item_id: subject
|
||||
for subject in source_substrate.subjects
|
||||
}
|
||||
exception_ids = [
|
||||
value["item_id"]
|
||||
for value in generation["human_exceptions"]
|
||||
]
|
||||
if (
|
||||
not exception_ids
|
||||
or any(item_id not in subjects_by_id for item_id in exception_ids)
|
||||
):
|
||||
raise E30HumanReviewValidationError(
|
||||
"engineering exception substrate is invalid"
|
||||
)
|
||||
return E30ReviewSubstrate(
|
||||
materialization_id=source_substrate.materialization_id,
|
||||
materialization_identity_sha256=(
|
||||
source_substrate.materialization_identity_sha256
|
||||
),
|
||||
review_pack_id=source_substrate.review_pack_id,
|
||||
review_items_sha256=source_substrate.review_items_sha256,
|
||||
reason_taxonomy=(),
|
||||
subjects=tuple(
|
||||
E30ReviewSubject(
|
||||
item_id=item_id,
|
||||
sequence=sequence,
|
||||
source_stratum=subjects_by_id[item_id].source_stratum,
|
||||
)
|
||||
for sequence, item_id in enumerate(exception_ids)
|
||||
),
|
||||
engineering_generation_id=engineering_generation_id,
|
||||
)
|
||||
except (
|
||||
E30EngineeringEvidenceError,
|
||||
E30ReviewEvidenceError,
|
||||
E30HumanReviewValidationError,
|
||||
OSError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 source evidence не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
def store() -> E30HumanReviewStore:
|
||||
draft_root = draft_root_provider()
|
||||
generation_root = generation_root_provider()
|
||||
if draft_root is None or generation_root is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="A3 human review не настроен",
|
||||
)
|
||||
try:
|
||||
return E30HumanReviewStore(
|
||||
draft_root=draft_root,
|
||||
generation_root=generation_root,
|
||||
)
|
||||
except (E30HumanReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="A3 human review storage недоступен",
|
||||
) from exc
|
||||
|
||||
def invoke(
|
||||
operation: Callable[[], dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return operation()
|
||||
except E30HumanReviewNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="A3 human review draft не найден",
|
||||
) from exc
|
||||
except E30HumanReviewValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except E30HumanReviewConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (E30HumanReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A3 human review не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.post("/reviews/{result_id}/human-review")
|
||||
def create_or_resume_human_review(
|
||||
result_id: str,
|
||||
request: E30HumanReviewCreateRequest,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, request.engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.create_or_resume(
|
||||
substrate=substrate,
|
||||
reviewer_id=request.reviewer_id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/reviews/{result_id}/human-review/{draft_id}")
|
||||
def get_human_review(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.get(draft_id=draft_id, substrate=substrate)
|
||||
)
|
||||
|
||||
@router.put(
|
||||
"/reviews/{result_id}/human-review/{draft_id}/decisions/{item_id}"
|
||||
)
|
||||
def record_human_review_decision(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
item_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-review-item-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E30HumanReviewDecisionRequest,
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=item_id,
|
||||
expected_revision=request.expected_revision,
|
||||
idempotency_key=request.idempotency_key,
|
||||
disposition=request.disposition,
|
||||
notes=request.notes,
|
||||
)
|
||||
)
|
||||
|
||||
@router.post("/reviews/{result_id}/human-review/{draft_id}/finalize")
|
||||
def finalize_human_review(
|
||||
result_id: str,
|
||||
draft_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-human-draft-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E30HumanReviewFinalizeRequest,
|
||||
engineering_generation_id: str,
|
||||
) -> dict[str, object]:
|
||||
substrate = source(result_id, engineering_generation_id)
|
||||
review_store = store()
|
||||
generation = invoke(
|
||||
lambda: review_store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=request.expected_revision,
|
||||
)
|
||||
)
|
||||
draft = invoke(
|
||||
lambda: review_store.get(draft_id=draft_id, substrate=substrate)
|
||||
)
|
||||
return {
|
||||
"schema_version": "missioncore.laboratory-e30-human-review-finalized/v2",
|
||||
"draft": draft,
|
||||
"generation": generation,
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,818 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal, cast
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
E30Stratum,
|
||||
)
|
||||
from k1link.compute.e30_materialization import (
|
||||
E30_MATERIALIZATION_INDEX_NAME,
|
||||
E30_MATERIALIZATION_ITEM_SCHEMA,
|
||||
E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
E30_MATERIALIZATION_SCHEMA,
|
||||
)
|
||||
|
||||
LABORATORY_E30_CATALOG_SCHEMA: Final = "missioncore.laboratory-e30-catalog/v1"
|
||||
LABORATORY_E30_ITEMS_SCHEMA: Final = "missioncore.laboratory-e30-items/v1"
|
||||
LABORATORY_E30_ITEM_DETAIL_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e30-item-detail/v1"
|
||||
)
|
||||
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_REVIEW_PACK_ID = re.compile(r"^e30-review-pack-[a-f0-9]{64}$")
|
||||
_REVIEW_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_STRATA: Final = ("conflict", "agree", "camera-only", "unknown", "geometry-only")
|
||||
_MAX_MANIFEST_BYTES: Final = 512 * 1024
|
||||
_MAX_INDEX_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_ITEM_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_CAMERA_FRAME_BYTES: Final = 8 * 1024 * 1024
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
Stratum = Literal["conflict", "agree", "camera-only", "unknown", "geometry-only"]
|
||||
|
||||
|
||||
class E30ReviewEvidenceError(ValueError):
|
||||
"""E30 review evidence is incomplete, changed or incompatible."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_json(path: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is unavailable")
|
||||
size = path.stat().st_size
|
||||
if not 0 < size <= maximum_bytes:
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is out of bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact is invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError("E30 JSON artifact must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_file(root: Path, value: object) -> Path:
|
||||
if not isinstance(value, str) or not value or Path(value).is_absolute():
|
||||
raise E30ReviewEvidenceError("E30 artifact path is invalid")
|
||||
if ".." in Path(value).parts:
|
||||
raise E30ReviewEvidenceError("E30 artifact path escaped its root")
|
||||
candidate = root / value
|
||||
if candidate.is_symlink():
|
||||
raise E30ReviewEvidenceError("E30 artifact must not be a symlink")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
try:
|
||||
candidate.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise E30ReviewEvidenceError("E30 artifact escaped its root") from exc
|
||||
if not candidate.is_file():
|
||||
raise E30ReviewEvidenceError("E30 artifact is unavailable")
|
||||
return candidate
|
||||
|
||||
|
||||
def _artifact(
|
||||
root: Path,
|
||||
value: object,
|
||||
*,
|
||||
expected_role: str | None = None,
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError("E30 artifact metadata is invalid")
|
||||
role = value.get("role")
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
(expected_role is not None and role != expected_role)
|
||||
or not isinstance(byte_length, int)
|
||||
or byte_length <= 0
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 artifact metadata is invalid")
|
||||
path = _safe_relative_file(root, value.get("path"))
|
||||
if path.stat().st_size != byte_length or _sha256(path) != digest:
|
||||
raise E30ReviewEvidenceError("E30 artifact content changed")
|
||||
return path, value
|
||||
|
||||
|
||||
def _authority(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("commands_enabled") is not False
|
||||
or value.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 evidence must remain diagnostic-only")
|
||||
|
||||
|
||||
def _signature(root: Path) -> tuple[int, ...]:
|
||||
paths = [
|
||||
root / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
root / E30_MATERIALIZATION_INDEX_NAME,
|
||||
*sorted((root / "items").glob("*.npz")),
|
||||
*sorted((root / "frames").glob("*.jpg")),
|
||||
]
|
||||
signature: list[int] = []
|
||||
for path in paths:
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
def _review_pack(
|
||||
root: Path,
|
||||
*,
|
||||
expected_id: str,
|
||||
expected_items_sha256: str,
|
||||
) -> dict[str, Any]:
|
||||
candidate = root / expected_id
|
||||
if (
|
||||
_REVIEW_PACK_ID.fullmatch(expected_id) is None
|
||||
or candidate.is_symlink()
|
||||
or not candidate.is_dir()
|
||||
):
|
||||
raise E30ReviewEvidenceError("linked E30 review pack is unavailable")
|
||||
manifest = _read_json(candidate / "manifest.json", _MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != "missioncore.e30-evidence-review-pack/v1"
|
||||
or manifest.get("result_id") != expected_id
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or expected_id != f"e30-review-pack-{identity_sha256}"
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("linked E30 review pack identity is invalid")
|
||||
_authority(manifest.get("authority"))
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||
raise E30ReviewEvidenceError("linked E30 review artifacts are invalid")
|
||||
_, artifact = _artifact(
|
||||
candidate,
|
||||
artifacts[0],
|
||||
expected_role="review-items",
|
||||
)
|
||||
if artifact.get("sha256") != expected_items_sha256:
|
||||
raise E30ReviewEvidenceError("linked E30 review item digest differs")
|
||||
return manifest
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read_materialization_cached(
|
||||
root_text: str,
|
||||
review_root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> tuple[dict[str, Any], tuple[dict[str, Any], ...]]:
|
||||
del signature
|
||||
root = Path(root_text)
|
||||
review_root = Path(review_root_text)
|
||||
if (
|
||||
root.is_symlink()
|
||||
or not root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(root.name) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization id is invalid")
|
||||
manifest = _read_json(
|
||||
root / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
_MAX_MANIFEST_BYTES,
|
||||
)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != E30_MATERIALIZATION_SCHEMA
|
||||
or manifest.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or root.name != f"e30-materialization-{identity_sha256}"
|
||||
or manifest.get("human_review_complete") is not False
|
||||
or manifest.get("lab_published") is not False
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization identity is invalid")
|
||||
_authority(manifest.get("authority"))
|
||||
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list) or len(artifacts) != 1:
|
||||
raise E30ReviewEvidenceError("E30 materialization artifacts are invalid")
|
||||
index_path, _ = _artifact(
|
||||
root,
|
||||
artifacts[0],
|
||||
expected_role="materialized-items",
|
||||
)
|
||||
if index_path.stat().st_size > _MAX_INDEX_BYTES:
|
||||
raise E30ReviewEvidenceError("E30 materialization index is out of bounds")
|
||||
|
||||
review_binding = identity.get("review_pack")
|
||||
if not isinstance(review_binding, dict):
|
||||
raise E30ReviewEvidenceError("E30 review binding is invalid")
|
||||
review_manifest = _review_pack(
|
||||
review_root,
|
||||
expected_id=_string(review_binding, "result_id"),
|
||||
expected_items_sha256=_sha_value(review_binding, "items_sha256"),
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
counts: Counter[str] = Counter()
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
for expected_sequence, line in enumerate(stream):
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item is invalid"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item must be an object"
|
||||
)
|
||||
item_id = value.get("item_id")
|
||||
stratum = value.get("stratum")
|
||||
if (
|
||||
value.get("schema_version") != E30_MATERIALIZATION_ITEM_SCHEMA
|
||||
or value.get("sequence") != expected_sequence
|
||||
or not isinstance(item_id, str)
|
||||
or _REVIEW_ITEM_ID.fullmatch(item_id) is None
|
||||
or stratum not in _STRATA
|
||||
or not isinstance(value.get("review"), dict)
|
||||
or value["review"].get("state") != "unreviewed"
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 materialization item contract is invalid"
|
||||
)
|
||||
_authority(value.get("authority"))
|
||||
_, artifact = _artifact(root, value.get("artifact"))
|
||||
if artifact.get("path") != f"items/{item_id}.npz":
|
||||
raise E30ReviewEvidenceError("E30 item artifact path differs")
|
||||
camera_frame = value.get("camera_frame")
|
||||
if camera_frame is not None:
|
||||
camera_path, camera_artifact = _artifact(
|
||||
root,
|
||||
camera_frame,
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
expected_frame = value.get("evidence_binding", {}).get(
|
||||
"source_frame_index"
|
||||
)
|
||||
if (
|
||||
not isinstance(expected_frame, int)
|
||||
or isinstance(expected_frame, bool)
|
||||
or expected_frame < 0
|
||||
or camera_path.stat().st_size > _MAX_CAMERA_FRAME_BYTES
|
||||
or camera_artifact.get("media_type") != "image/jpeg"
|
||||
or camera_artifact.get("source_frame_index")
|
||||
!= expected_frame
|
||||
or camera_artifact.get("exact_source_frame") is not True
|
||||
or camera_artifact.get("path")
|
||||
!= f"frames/frame-{expected_frame:06d}.jpg"
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 camera frame binding is invalid"
|
||||
)
|
||||
triage = value.get("engineering_triage")
|
||||
if (
|
||||
not isinstance(triage, dict)
|
||||
or triage.get("schema_version")
|
||||
!= "missioncore.e30-engineering-triage/v1"
|
||||
or triage.get("semantic_verdict") is not None
|
||||
or triage.get("human_exception_required") is not None
|
||||
):
|
||||
raise E30ReviewEvidenceError(
|
||||
"E30 engineering triage contract is invalid"
|
||||
)
|
||||
rows.append(value)
|
||||
counts[stratum] += 1
|
||||
if (
|
||||
len(rows) != manifest.get("item_count")
|
||||
or len(rows) != review_binding.get("item_count")
|
||||
or len({row["item_id"] for row in rows}) != len(rows)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization item count differs")
|
||||
|
||||
review_identity = review_manifest.get("identity")
|
||||
reason_taxonomy = (
|
||||
review_identity.get("reason_taxonomy")
|
||||
if isinstance(review_identity, dict)
|
||||
else None
|
||||
)
|
||||
source = identity.get("source")
|
||||
projection = identity.get("projection")
|
||||
if (
|
||||
not isinstance(reason_taxonomy, list)
|
||||
or not all(isinstance(reason, str) for reason in reason_taxonomy)
|
||||
or not isinstance(source, dict)
|
||||
or not isinstance(projection, dict)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 catalog metadata is incomplete")
|
||||
catalog_item = {
|
||||
"result_id": root.name,
|
||||
"created_at_utc": manifest.get("created_at_utc"),
|
||||
"review_pack_id": review_binding["result_id"],
|
||||
"e29_result_id": source.get("e29_result_id"),
|
||||
"source_session_id": source.get("source_session_id"),
|
||||
"item_count": len(rows),
|
||||
"stratum_counts": {stratum: counts[stratum] for stratum in _STRATA},
|
||||
"reason_taxonomy": reason_taxonomy,
|
||||
"projection": projection,
|
||||
"camera_evidence_available": (
|
||||
manifest.get("camera_evidence_available") is True
|
||||
),
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"access": "read-only",
|
||||
"authority": manifest.get("authority"),
|
||||
}
|
||||
return catalog_item, tuple(rows)
|
||||
|
||||
|
||||
def _roots(
|
||||
*,
|
||||
materialization_root_provider: RootProvider,
|
||||
review_pack_root_provider: RootProvider,
|
||||
) -> tuple[Path, Path] | None:
|
||||
materialization_root = materialization_root_provider()
|
||||
review_root = review_pack_root_provider()
|
||||
if materialization_root is None or review_root is None:
|
||||
return None
|
||||
materialization_root = materialization_root.resolve()
|
||||
review_root = review_root.resolve()
|
||||
if not materialization_root.is_dir() or not review_root.is_dir():
|
||||
return None
|
||||
return materialization_root, review_root
|
||||
|
||||
|
||||
def _candidate(
|
||||
materialization_root: Path,
|
||||
result_id: str,
|
||||
) -> Path:
|
||||
if _MATERIALIZATION_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
candidate = materialization_root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
return candidate
|
||||
|
||||
|
||||
def e30_review_item_summary(row: dict[str, Any]) -> dict[str, object]:
|
||||
binding = row.get("evidence_binding")
|
||||
locator = row.get("e29_locator")
|
||||
snapshot = row.get("e29_snapshot")
|
||||
materialization = row.get("materialization")
|
||||
if (
|
||||
not isinstance(binding, dict)
|
||||
or not isinstance(locator, dict)
|
||||
or not isinstance(snapshot, dict)
|
||||
or not isinstance(materialization, dict)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 item summary is incomplete")
|
||||
return {
|
||||
"item_id": row["item_id"],
|
||||
"sequence": row["sequence"],
|
||||
"review_key": row["review_key"],
|
||||
"stratum": row["stratum"],
|
||||
"range_bucket": row["range_bucket"],
|
||||
"frame_index": binding.get("frame_index"),
|
||||
"source_frame_index": binding.get("source_frame_index"),
|
||||
"session_seconds": binding.get("session_seconds"),
|
||||
"locator": locator,
|
||||
"snapshot": snapshot,
|
||||
"materialization": materialization,
|
||||
"camera_frame_available": isinstance(row.get("camera_frame"), dict),
|
||||
"engineering_triage": row.get("engineering_triage"),
|
||||
"review": row["review"],
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _logical_arrays_sha256(arrays: dict[str, npt.NDArray[Any]]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for name in sorted(arrays):
|
||||
array = np.ascontiguousarray(arrays[name])
|
||||
digest.update(name.encode())
|
||||
digest.update(array.dtype.str.encode())
|
||||
digest.update(_canonical_json(list(array.shape)))
|
||||
digest.update(array.tobytes(order="C"))
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _item_detail(root: Path, row: dict[str, Any]) -> dict[str, object]:
|
||||
path, artifact = _artifact(root, row.get("artifact"))
|
||||
if path.stat().st_size > _MAX_ITEM_BYTES:
|
||||
raise E30ReviewEvidenceError("E30 item artifact is out of bounds")
|
||||
expected_files = {
|
||||
"selected_source_indices",
|
||||
"selected_points_map_xyz_m",
|
||||
"candidate_source_indices",
|
||||
"candidate_points_map_xyz_m",
|
||||
"projected_source_indices",
|
||||
"projected_points_map_xyz_m",
|
||||
"projected_pixels_xy",
|
||||
"projected_depth_m",
|
||||
"projected_point_class",
|
||||
"projected_point_height_m",
|
||||
"projected_candidate_mask",
|
||||
"projected_selected_mask",
|
||||
"sensor_position_map_xyz_m",
|
||||
"sensor_orientation_map_from_lidar_xyzw",
|
||||
}
|
||||
with np.load(path, allow_pickle=False) as archive:
|
||||
if set(archive.files) != expected_files:
|
||||
raise E30ReviewEvidenceError("E30 item arrays are incompatible")
|
||||
arrays = {
|
||||
name: np.ascontiguousarray(archive[name])
|
||||
for name in expected_files
|
||||
}
|
||||
if _logical_arrays_sha256(arrays) != artifact.get("logical_sha256"):
|
||||
raise E30ReviewEvidenceError("E30 item logical content changed")
|
||||
projected_count = arrays["projected_source_indices"].shape[0]
|
||||
selected_count = arrays["selected_source_indices"].shape[0]
|
||||
candidate_count = arrays["candidate_source_indices"].shape[0]
|
||||
if (
|
||||
arrays["selected_source_indices"].shape != (selected_count,)
|
||||
or arrays["selected_points_map_xyz_m"].shape != (selected_count, 3)
|
||||
or arrays["candidate_source_indices"].shape != (candidate_count,)
|
||||
or arrays["candidate_points_map_xyz_m"].shape != (candidate_count, 3)
|
||||
or arrays["projected_points_map_xyz_m"].shape != (projected_count, 3)
|
||||
or arrays["projected_pixels_xy"].shape != (projected_count, 2)
|
||||
or arrays["projected_depth_m"].shape != (projected_count,)
|
||||
or arrays["projected_point_class"].shape != (projected_count,)
|
||||
or arrays["projected_point_height_m"].shape != (projected_count,)
|
||||
or arrays["projected_candidate_mask"].shape != (projected_count,)
|
||||
or arrays["projected_selected_mask"].shape != (projected_count,)
|
||||
or arrays["sensor_position_map_xyz_m"].shape != (3,)
|
||||
or arrays["sensor_orientation_map_from_lidar_xyzw"].shape != (4,)
|
||||
or not all(np.isfinite(value).all() for value in arrays.values())
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 item array shape is invalid")
|
||||
camera_frame = row.get("camera_frame")
|
||||
camera_document: dict[str, object] | None = None
|
||||
if camera_frame is not None:
|
||||
_, camera_artifact = _artifact(
|
||||
root,
|
||||
camera_frame,
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
camera_document = {
|
||||
"available": True,
|
||||
"url": (
|
||||
f"/api/v1/laboratory/e30/reviews/{root.name}/items/"
|
||||
f"{row['item_id']}/camera-frame"
|
||||
f"?generation={camera_artifact['sha256']}"
|
||||
),
|
||||
"sha256": camera_artifact["sha256"],
|
||||
"width": camera_artifact.get("width"),
|
||||
"height": camera_artifact.get("height"),
|
||||
"source_frame_index": camera_artifact.get("source_frame_index"),
|
||||
"exact_source_frame": True,
|
||||
}
|
||||
return {
|
||||
**e30_review_item_summary(row),
|
||||
"camera_frame": camera_document,
|
||||
"selected": {
|
||||
"source_indices": arrays["selected_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["selected_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
},
|
||||
"candidate": {
|
||||
"source_indices": arrays["candidate_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["candidate_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
},
|
||||
"projection": {
|
||||
"source_indices": arrays["projected_source_indices"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"points_map_xyz_m": arrays["projected_points_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"pixels_xy": arrays["projected_pixels_xy"].astype(np.float64).tolist(),
|
||||
"depth_m": arrays["projected_depth_m"].astype(np.float64).tolist(),
|
||||
"point_class": arrays["projected_point_class"].astype(np.int64).tolist(),
|
||||
"point_height_m": arrays["projected_point_height_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"candidate_mask": arrays["projected_candidate_mask"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
"selected_mask": arrays["projected_selected_mask"].astype(
|
||||
np.int64
|
||||
).tolist(),
|
||||
},
|
||||
"pose": {
|
||||
"position_map_xyz_m": arrays["sensor_position_map_xyz_m"].astype(
|
||||
np.float64
|
||||
).tolist(),
|
||||
"orientation_map_from_lidar_xyzw": arrays[
|
||||
"sensor_orientation_map_from_lidar_xyzw"
|
||||
]
|
||||
.astype(np.float64)
|
||||
.tolist(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _materialization(
|
||||
candidate: Path,
|
||||
review_root: Path,
|
||||
) -> tuple[dict[str, Any], tuple[dict[str, Any], ...]]:
|
||||
return _read_materialization_cached(
|
||||
str(candidate.resolve()),
|
||||
str(review_root.resolve()),
|
||||
_signature(candidate),
|
||||
)
|
||||
|
||||
|
||||
def load_verified_e30_review(
|
||||
*,
|
||||
materialization_root: Path,
|
||||
review_pack_root: Path,
|
||||
result_id: str,
|
||||
) -> tuple[
|
||||
dict[str, Any],
|
||||
tuple[dict[str, Any], ...],
|
||||
E30ReviewSubstrate,
|
||||
]:
|
||||
"""Load the verified A2 evidence and its exact A3 source binding."""
|
||||
|
||||
materialization_root = materialization_root.resolve()
|
||||
review_pack_root = review_pack_root.resolve()
|
||||
if (
|
||||
not materialization_root.is_dir()
|
||||
or not review_pack_root.is_dir()
|
||||
or _MATERIALIZATION_ID.fullmatch(result_id) is None
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 materialization is unavailable")
|
||||
candidate = materialization_root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise E30ReviewEvidenceError("E30 materialization is unavailable")
|
||||
catalog, rows = _materialization(candidate, review_pack_root)
|
||||
manifest = _read_json(
|
||||
candidate / E30_MATERIALIZATION_MANIFEST_NAME,
|
||||
_MAX_MANIFEST_BYTES,
|
||||
)
|
||||
identity = manifest.get("identity")
|
||||
review_binding = identity.get("review_pack") if isinstance(identity, dict) else None
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or not isinstance(review_binding, dict)
|
||||
or not isinstance(manifest.get("identity_sha256"), str)
|
||||
):
|
||||
raise E30ReviewEvidenceError("E30 reviewer source binding is incomplete")
|
||||
substrate = E30ReviewSubstrate(
|
||||
materialization_id=result_id,
|
||||
materialization_identity_sha256=cast(str, manifest["identity_sha256"]),
|
||||
review_pack_id=_string(review_binding, "result_id"),
|
||||
review_items_sha256=_sha_value(review_binding, "items_sha256"),
|
||||
reason_taxonomy=tuple(cast(list[str], catalog["reason_taxonomy"])),
|
||||
subjects=tuple(
|
||||
E30ReviewSubject(
|
||||
item_id=cast(str, row["item_id"]),
|
||||
sequence=cast(int, row["sequence"]),
|
||||
source_stratum=cast(E30Stratum, row["stratum"]),
|
||||
)
|
||||
for row in rows
|
||||
),
|
||||
)
|
||||
return catalog, rows, substrate
|
||||
|
||||
|
||||
def _string(document: dict[str, Any], key: str) -> str:
|
||||
value = document.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise E30ReviewEvidenceError(f"{key} must be a nonempty string")
|
||||
return value
|
||||
|
||||
|
||||
def _sha_value(document: dict[str, Any], key: str) -> str:
|
||||
value = _string(document, key)
|
||||
if _SHA256.fullmatch(value) is None:
|
||||
raise E30ReviewEvidenceError(f"{key} must be a SHA-256 digest")
|
||||
return value
|
||||
|
||||
|
||||
def build_e30_review_router(
|
||||
*,
|
||||
materialization_root_provider: RootProvider = lambda: None,
|
||||
review_pack_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e30", tags=["laboratory"])
|
||||
|
||||
@router.get("/reviews")
|
||||
def list_reviews(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None:
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_CATALOG_SCHEMA,
|
||||
"configured": False,
|
||||
"items": [],
|
||||
"candidate_total": 0,
|
||||
"invalid_total": 0,
|
||||
"access": "read-only",
|
||||
}
|
||||
materialization_root, review_root = roots
|
||||
candidates = sorted(
|
||||
(
|
||||
candidate
|
||||
for candidate in materialization_root.iterdir()
|
||||
if candidate.is_dir()
|
||||
and _MATERIALIZATION_ID.fullmatch(candidate.name) is not None
|
||||
),
|
||||
key=lambda candidate: candidate.stat().st_mtime_ns,
|
||||
reverse=True,
|
||||
)
|
||||
items: list[dict[str, object]] = []
|
||||
invalid_total = 0
|
||||
for candidate in candidates:
|
||||
try:
|
||||
item, _ = _materialization(candidate, review_root)
|
||||
if len(items) < limit:
|
||||
items.append(copy.deepcopy(item))
|
||||
except (E30ReviewEvidenceError, OSError):
|
||||
invalid_total += 1
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_CATALOG_SCHEMA,
|
||||
"configured": True,
|
||||
"items": items,
|
||||
"candidate_total": len(candidates),
|
||||
"invalid_total": invalid_total,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
@router.get("/reviews/{result_id}/items")
|
||||
def list_review_items(
|
||||
result_id: str,
|
||||
stratum: Annotated[Stratum, Query()] = "conflict",
|
||||
limit: Annotated[int, Query(ge=1, le=128)] = 48,
|
||||
cursor: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review не найден")
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
catalog, rows = _materialization(candidate, review_root)
|
||||
filtered = [row for row in rows if row.get("stratum") == stratum]
|
||||
page = filtered[cursor : cursor + limit]
|
||||
next_cursor = cursor + len(page)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ITEMS_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"stratum": stratum,
|
||||
"items": [e30_review_item_summary(row) for row in page],
|
||||
"total": len(filtered),
|
||||
"next_cursor": next_cursor if next_cursor < len(filtered) else None,
|
||||
"reason_taxonomy": catalog["reason_taxonomy"],
|
||||
"access": "read-only",
|
||||
}
|
||||
except (E30ReviewEvidenceError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 review evidence не прошло проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/reviews/{result_id}/items/{item_id}")
|
||||
def get_review_item(
|
||||
result_id: str,
|
||||
item_id: str,
|
||||
) -> dict[str, object]:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if roots is None or _REVIEW_ITEM_ID.fullmatch(item_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E30 review item не найден")
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
_, rows = _materialization(candidate, review_root)
|
||||
row = next((value for value in rows if value.get("item_id") == item_id), None)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 review item не найден",
|
||||
)
|
||||
return {
|
||||
"schema_version": LABORATORY_E30_ITEM_DETAIL_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"item": _item_detail(candidate, row),
|
||||
"access": "read-only",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30ReviewEvidenceError, OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 review item не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/reviews/{result_id}/items/{item_id}/camera-frame")
|
||||
def get_review_camera_frame(
|
||||
result_id: str,
|
||||
item_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
) -> FileResponse:
|
||||
roots = _roots(
|
||||
materialization_root_provider=materialization_root_provider,
|
||||
review_pack_root_provider=review_pack_root_provider,
|
||||
)
|
||||
if (
|
||||
roots is None
|
||||
or _REVIEW_ITEM_ID.fullmatch(item_id) is None
|
||||
or _SHA256.fullmatch(generation) is None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 camera frame не найден",
|
||||
)
|
||||
materialization_root, review_root = roots
|
||||
candidate = _candidate(materialization_root, result_id)
|
||||
try:
|
||||
_, rows = _materialization(candidate, review_root)
|
||||
row = next(
|
||||
(value for value in rows if value.get("item_id") == item_id),
|
||||
None,
|
||||
)
|
||||
if row is None or row.get("camera_frame") is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="E30 camera frame не найден",
|
||||
)
|
||||
path, artifact = _artifact(
|
||||
candidate,
|
||||
row["camera_frame"],
|
||||
expected_role="camera-frame",
|
||||
)
|
||||
if artifact.get("sha256") != generation:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 camera generation изменилась",
|
||||
)
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="image/jpeg",
|
||||
filename=f"e30-frame-{artifact['source_frame_index']:06d}.jpg",
|
||||
headers={
|
||||
"ETag": f'"{generation}"',
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (E30ReviewEvidenceError, OSError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E30 camera frame не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
return router
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.compute.e30_engineering_generation import (
|
||||
E30_ENGINEERING_DECISION_SCHEMA,
|
||||
E30EngineeringGenerationError,
|
||||
_validate_decision,
|
||||
)
|
||||
from k1link.web.e30_engineering_api import _validate_decisions
|
||||
|
||||
|
||||
def _item() -> dict[str, object]:
|
||||
return {
|
||||
"sequence": 0,
|
||||
"item_id": f"e30-review-item-{'a' * 64}",
|
||||
"review_key": "geometry:100:0",
|
||||
"stratum": "geometry-only",
|
||||
}
|
||||
|
||||
|
||||
def _decision(**overrides: object) -> dict[str, object]:
|
||||
value: dict[str, object] = {
|
||||
"schema_version": E30_ENGINEERING_DECISION_SCHEMA,
|
||||
"sequence": 0,
|
||||
"item_id": f"e30-review-item-{'a' * 64}",
|
||||
"review_key": "geometry:100:0",
|
||||
"source_stratum": "geometry-only",
|
||||
"verdict": "confirmed",
|
||||
"effective_stratum": "geometry-only",
|
||||
"detector_assessment": "missed-object",
|
||||
"projection_assessment": "aligned",
|
||||
"point_ownership": "object",
|
||||
"cause_code": "detector_error",
|
||||
"confidence": 0.88,
|
||||
"human_exception_required": False,
|
||||
"exception_reason": None,
|
||||
"review_prompt": None,
|
||||
"evidence_note": "Visible object has geometry but no semantic observation.",
|
||||
"review_sheet": {
|
||||
"path": "geometry-only-01.jpg",
|
||||
"sha256": "b" * 64,
|
||||
"ordinal": 1,
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
def test_engineering_decision_supports_detector_miss_without_rewriting_a2() -> None:
|
||||
validated = _validate_decision(
|
||||
value=_decision(),
|
||||
item=_item(),
|
||||
reason_taxonomy=("detector_error", "unknown"),
|
||||
expected_sheet={
|
||||
"path": "geometry-only-01.jpg",
|
||||
"sha256": "b" * 64,
|
||||
"ordinal": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert validated["verdict"] == "confirmed"
|
||||
assert validated["effective_stratum"] == "geometry-only"
|
||||
assert validated["detector_assessment"] == "missed-object"
|
||||
|
||||
|
||||
def test_engineering_uncertainty_must_route_a_bounded_exception() -> None:
|
||||
with pytest.raises(
|
||||
E30EngineeringGenerationError,
|
||||
match="insufficient decision must route an exception",
|
||||
):
|
||||
_validate_decision(
|
||||
value=_decision(
|
||||
verdict="insufficient-evidence",
|
||||
effective_stratum=None,
|
||||
detector_assessment="insufficient-evidence",
|
||||
confidence=0.48,
|
||||
),
|
||||
item=_item(),
|
||||
reason_taxonomy=("detector_error", "unknown"),
|
||||
expected_sheet={
|
||||
"path": "geometry-only-01.jpg",
|
||||
"sha256": "b" * 64,
|
||||
"ordinal": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_engineering_api_recomputes_distributions_from_all_486_decisions() -> None:
|
||||
decisions = []
|
||||
for sequence in range(486):
|
||||
decision = _decision(
|
||||
sequence=sequence,
|
||||
item_id=f"e30-review-item-{sequence:064x}",
|
||||
review_key=f"geometry:{sequence}:0",
|
||||
)
|
||||
decisions.append(decision)
|
||||
decisions[-1] = {
|
||||
**decisions[-1],
|
||||
"verdict": "insufficient-evidence",
|
||||
"effective_stratum": None,
|
||||
"detector_assessment": "insufficient-evidence",
|
||||
"projection_assessment": "not-assessable",
|
||||
"point_ownership": "insufficient-evidence",
|
||||
"cause_code": "unknown",
|
||||
"confidence": 0.48,
|
||||
"human_exception_required": True,
|
||||
"exception_reason": "ambiguity",
|
||||
"review_prompt": {
|
||||
"question": "Is this an occupied physical object?",
|
||||
"focus": "Inspect the selected white LiDAR cluster.",
|
||||
"effects": {
|
||||
"object-present": "Retain occupied geometry.",
|
||||
"background-or-noise": "Reject the cluster.",
|
||||
"insufficient-evidence": "Keep the item unknown.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
summary, causes = _validate_decisions(decisions)
|
||||
|
||||
assert summary["reviewed_item_count"] == 486
|
||||
assert summary["human_exception_count"] == 1
|
||||
assert summary["verdict_distribution"] == {
|
||||
"confirmed": 485,
|
||||
"insufficient-evidence": 1,
|
||||
}
|
||||
assert causes["reasons"] == [
|
||||
{"reason_code": "detector_error", "count": 485},
|
||||
{"reason_code": "unknown", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_engineering_exception_requires_a_specific_review_prompt() -> None:
|
||||
with pytest.raises(
|
||||
E30EngineeringGenerationError,
|
||||
match="exception review prompt is invalid",
|
||||
):
|
||||
_validate_decision(
|
||||
value=_decision(
|
||||
verdict="insufficient-evidence",
|
||||
effective_stratum=None,
|
||||
detector_assessment="insufficient-evidence",
|
||||
projection_assessment="not-assessable",
|
||||
point_ownership="insufficient-evidence",
|
||||
cause_code="unknown",
|
||||
confidence=0.48,
|
||||
human_exception_required=True,
|
||||
exception_reason="ambiguity",
|
||||
),
|
||||
item=_item(),
|
||||
reason_taxonomy=("detector_error", "unknown"),
|
||||
expected_sheet={
|
||||
"path": "geometry-only-01.jpg",
|
||||
"sha256": "b" * 64,
|
||||
"ordinal": 1,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30HumanReviewConflictError,
|
||||
E30HumanReviewIntegrityError,
|
||||
E30HumanReviewStore,
|
||||
E30HumanReviewValidationError,
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
)
|
||||
from k1link.web import e30_human_review_api
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Any:
|
||||
for route in router.routes:
|
||||
if (
|
||||
getattr(route, "path", None) == path
|
||||
and method in getattr(route, "methods", set())
|
||||
):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} endpoint {path} not found")
|
||||
|
||||
|
||||
def _substrate() -> E30ReviewSubstrate:
|
||||
return E30ReviewSubstrate(
|
||||
materialization_id=f"e30-materialization-{'a' * 64}",
|
||||
materialization_identity_sha256="a" * 64,
|
||||
review_pack_id=f"e30-review-pack-{'b' * 64}",
|
||||
review_items_sha256="c" * 64,
|
||||
reason_taxonomy=(),
|
||||
subjects=(
|
||||
E30ReviewSubject(
|
||||
item_id=f"e30-review-item-{'d' * 64}",
|
||||
sequence=0,
|
||||
source_stratum="geometry-only",
|
||||
),
|
||||
E30ReviewSubject(
|
||||
item_id=f"e30-review-item-{'e' * 64}",
|
||||
sequence=1,
|
||||
source_stratum="unknown",
|
||||
),
|
||||
),
|
||||
engineering_generation_id=f"e30-engineering-generation-{'f' * 64}",
|
||||
)
|
||||
|
||||
|
||||
def _store(tmp_path: Path) -> E30HumanReviewStore:
|
||||
return E30HumanReviewStore(
|
||||
draft_root=tmp_path / "drafts",
|
||||
generation_root=tmp_path / "generations",
|
||||
)
|
||||
|
||||
|
||||
def test_exception_review_is_generation_bound_and_resumable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
|
||||
created = store.create_or_resume(substrate=substrate, reviewer_id="DC")
|
||||
resumed = store.create_or_resume(substrate=substrate, reviewer_id="DC")
|
||||
|
||||
assert resumed["draft_id"] == created["draft_id"]
|
||||
assert resumed["engineering_generation_id"] == (
|
||||
substrate.engineering_generation_id
|
||||
)
|
||||
assert resumed["item_count"] == 2
|
||||
assert resumed["reviewed_item_count"] == 0
|
||||
|
||||
|
||||
def test_decisions_are_append_only_idempotent_and_superseding(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
subject = substrate.subjects[0]
|
||||
|
||||
first = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
replay = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
changed = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=1,
|
||||
idempotency_key="decision-002",
|
||||
disposition="background-or-noise",
|
||||
notes="Static façade points.",
|
||||
)
|
||||
|
||||
assert first["revision"] == replay["revision"] == 1
|
||||
assert changed["revision"] == 2
|
||||
assert changed["reviewed_item_count"] == 1
|
||||
assert changed["disposition_distribution"] == {
|
||||
"background-or-noise": 1
|
||||
}
|
||||
assert len(
|
||||
(
|
||||
tmp_path / "drafts" / draft_id / "events.jsonl"
|
||||
).read_text().splitlines()
|
||||
) == 2
|
||||
|
||||
|
||||
def test_decision_rejects_unknown_disposition(tmp_path: Path) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
|
||||
with pytest.raises(E30HumanReviewValidationError):
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-invalid",
|
||||
disposition="invented", # type: ignore[arg-type]
|
||||
notes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_finalization_requires_every_exception_and_freezes_generation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
first = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
with pytest.raises(E30HumanReviewConflictError, match="coverage is incomplete"):
|
||||
store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=int(first["revision"]),
|
||||
)
|
||||
|
||||
complete = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[1].item_id,
|
||||
expected_revision=1,
|
||||
idempotency_key="decision-002",
|
||||
disposition="insufficient-evidence",
|
||||
notes="Occluded.",
|
||||
)
|
||||
generation = store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=int(complete["revision"]),
|
||||
)
|
||||
|
||||
assert generation["human_review_complete"] is True
|
||||
assert generation["lab_published"] is False
|
||||
assert generation["engineering_generation_id"] == (
|
||||
substrate.engineering_generation_id
|
||||
)
|
||||
assert generation["disposition_distribution"] == {
|
||||
"insufficient-evidence": 1,
|
||||
"object-present": 1,
|
||||
}
|
||||
generation_id = str(generation["generation_id"])
|
||||
decision_rows = [
|
||||
json.loads(line)
|
||||
for line in (
|
||||
tmp_path
|
||||
/ "generations"
|
||||
/ generation_id
|
||||
/ "review-decisions.jsonl"
|
||||
).read_text().splitlines()
|
||||
]
|
||||
assert [row["disposition"] for row in decision_rows] == [
|
||||
"object-present",
|
||||
"insufficient-evidence",
|
||||
]
|
||||
|
||||
repeated = store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=2,
|
||||
)
|
||||
assert repeated["generation_id"] == generation_id
|
||||
with pytest.raises(E30HumanReviewConflictError, match="finalized"):
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=2,
|
||||
idempotency_key="decision-after-finalize",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_event_and_generation_tampering_fail_closed(tmp_path: Path) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
events_path = tmp_path / "drafts" / draft_id / "events.jsonl"
|
||||
event = json.loads(events_path.read_text())
|
||||
event["disposition"] = "background-or-noise"
|
||||
events_path.write_text(json.dumps(event) + "\n")
|
||||
|
||||
with pytest.raises(E30HumanReviewIntegrityError):
|
||||
store.get(draft_id=draft_id, substrate=substrate)
|
||||
|
||||
|
||||
def test_http_lifecycle_is_bound_to_the_engineering_exception_queue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source_substrate = _substrate()
|
||||
generation_id = str(source_substrate.engineering_generation_id)
|
||||
monkeypatch.setattr(
|
||||
e30_human_review_api,
|
||||
"load_verified_e30_review",
|
||||
lambda **_: ({}, (), source_substrate),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
e30_human_review_api,
|
||||
"load_verified_e30_engineering_generation",
|
||||
lambda **_: (
|
||||
{
|
||||
"human_exceptions": [
|
||||
{"item_id": subject.item_id}
|
||||
for subject in source_substrate.subjects
|
||||
]
|
||||
},
|
||||
(),
|
||||
),
|
||||
)
|
||||
router = build_e30_human_review_router(
|
||||
materialization_root_provider=lambda: tmp_path / "materializations",
|
||||
review_pack_root_provider=lambda: tmp_path / "review-packs",
|
||||
engineering_generation_root_provider=lambda: (
|
||||
tmp_path / "engineering-generations"
|
||||
),
|
||||
draft_root_provider=lambda: tmp_path / "drafts",
|
||||
generation_root_provider=lambda: tmp_path / "generations",
|
||||
)
|
||||
create = _endpoint(
|
||||
router,
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review",
|
||||
"POST",
|
||||
)
|
||||
decide = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review/"
|
||||
"{draft_id}/decisions/{item_id}"
|
||||
),
|
||||
"PUT",
|
||||
)
|
||||
finalize = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review/"
|
||||
"{draft_id}/finalize"
|
||||
),
|
||||
"POST",
|
||||
)
|
||||
|
||||
created = create(
|
||||
result_id=source_substrate.materialization_id,
|
||||
request=e30_human_review_api.E30HumanReviewCreateRequest(
|
||||
reviewer_id="DC",
|
||||
engineering_generation_id=generation_id,
|
||||
),
|
||||
)
|
||||
draft = created
|
||||
for revision, subject in enumerate(source_substrate.subjects):
|
||||
draft = decide(
|
||||
result_id=source_substrate.materialization_id,
|
||||
draft_id=draft["draft_id"],
|
||||
item_id=subject.item_id,
|
||||
engineering_generation_id=generation_id,
|
||||
request=e30_human_review_api.E30HumanReviewDecisionRequest(
|
||||
expected_revision=revision,
|
||||
idempotency_key=f"http-{revision}",
|
||||
disposition=(
|
||||
"object-present"
|
||||
if revision == 0
|
||||
else "insufficient-evidence"
|
||||
),
|
||||
notes=None,
|
||||
),
|
||||
)
|
||||
|
||||
finalized = finalize(
|
||||
result_id=source_substrate.materialization_id,
|
||||
draft_id=draft["draft_id"],
|
||||
engineering_generation_id=generation_id,
|
||||
request=e30_human_review_api.E30HumanReviewFinalizeRequest(
|
||||
expected_revision=draft["revision"],
|
||||
confirm_generation=True,
|
||||
),
|
||||
)
|
||||
assert finalized["draft"]["state"] == "finalized"
|
||||
@@ -0,0 +1,394 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from k1link.compute import e30_materialization as materialization
|
||||
from k1link.compute.e30_materialization import build_e30_materialization
|
||||
from k1link.compute.semantic_geometry_fusion import (
|
||||
CameraGeometryFusionProfile,
|
||||
_projection_profile,
|
||||
_semantic_support,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
project_map_points_kb4,
|
||||
)
|
||||
from k1link.web.e30_review_api import build_e30_review_router
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str) -> object:
|
||||
for route in router.routes:
|
||||
if (
|
||||
isinstance(route, APIRoute)
|
||||
and route.path == path
|
||||
and route.methods is not None
|
||||
and "GET" in route.methods
|
||||
):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"GET {path} route is missing")
|
||||
|
||||
|
||||
class _FakeSource:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.pack_id = root.name
|
||||
self.arrays = {
|
||||
"cloud_offsets": np.asarray([0, 4], dtype=np.int64),
|
||||
"cloud_points_map": np.asarray(
|
||||
[
|
||||
[0.00, 0.00, 2.0],
|
||||
[0.10, 0.00, 2.0],
|
||||
[0.20, 0.00, 2.0],
|
||||
[1.50, 1.50, 2.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
"pose_positions_map": np.zeros((1, 3), dtype=np.float64),
|
||||
"pose_quaternions_map_from_lidar": np.asarray(
|
||||
[[0.0, 0.0, 0.0, 1.0]],
|
||||
dtype=np.float64,
|
||||
),
|
||||
"sample_available": np.asarray([True], dtype=np.bool_),
|
||||
"source_frame_indices": np.asarray([10], dtype=np.int64),
|
||||
"session_seconds": np.asarray([12.5], dtype=np.float64),
|
||||
"intrinsic_fx_fy_cx_cy": np.asarray(
|
||||
[100.0, 100.0, 50.0, 50.0],
|
||||
dtype=np.float64,
|
||||
),
|
||||
"distortion_kb4": np.zeros(4, dtype=np.float64),
|
||||
"t_camera_from_lidar": np.eye(4, dtype=np.float64),
|
||||
}
|
||||
self.identity: dict[str, Any] = {
|
||||
"session_id": "source-session",
|
||||
"frame_count": 1,
|
||||
"point_count": 4,
|
||||
"source_id": "sensor.camera.right",
|
||||
"camera_slot": "camera_1",
|
||||
"projection": {"width": 100, "height": 100},
|
||||
}
|
||||
self.manifest = {"artifact": {"sha256": "1" * 64}}
|
||||
|
||||
@property
|
||||
def frame_count(self) -> int:
|
||||
return 1
|
||||
|
||||
@property
|
||||
def point_count(self) -> int:
|
||||
return 4
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSurface:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.model_id = root.name
|
||||
self.identity: dict[str, Any] = {
|
||||
"source_pack_id": "e10-lidar-pack-" + "b" * 64,
|
||||
"frame_count": 1,
|
||||
"point_count": 4,
|
||||
}
|
||||
self.arrays = {
|
||||
"frame_valid": np.asarray([True], dtype=np.bool_),
|
||||
"point_class": np.asarray([2, 2, 2, 1], dtype=np.uint8),
|
||||
"point_height_m": np.asarray([0.5, 0.5, 0.5, 0.0], dtype=np.float32),
|
||||
}
|
||||
self.manifest = {
|
||||
"artifacts": [
|
||||
{"role": "local-surface", "sha256": "2" * 64},
|
||||
]
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _write_source_tree(tmp_path: Path) -> tuple[dict[str, Path], str]:
|
||||
source_pack_id = "e10-lidar-pack-" + "b" * 64
|
||||
local_surface_id = "k1-local-surface-" + "c" * 64
|
||||
source_result_identity = {"schema_version": "test-source/v1"}
|
||||
source_result_identity_sha256 = hashlib.sha256(
|
||||
_canonical(source_result_identity)
|
||||
).hexdigest()
|
||||
source_result_id = (
|
||||
f"e10-integrated-perception-{source_result_identity_sha256}"
|
||||
)
|
||||
|
||||
roots = {
|
||||
"e29": tmp_path / "e29",
|
||||
"source_results": tmp_path / "source-results",
|
||||
"source_packs": tmp_path / "source-packs",
|
||||
"surfaces": tmp_path / "surfaces",
|
||||
"reviews": tmp_path / "reviews",
|
||||
"output": tmp_path / "output",
|
||||
}
|
||||
for root in roots.values():
|
||||
root.mkdir()
|
||||
(roots["source_packs"] / source_pack_id).mkdir()
|
||||
(roots["surfaces"] / local_surface_id).mkdir()
|
||||
|
||||
source_result = roots["source_results"] / source_result_id
|
||||
source_result.mkdir()
|
||||
result_document = {
|
||||
"result_id": source_result_id,
|
||||
"identity_sha256": source_result_identity_sha256,
|
||||
"identity": source_result_identity,
|
||||
}
|
||||
(source_result / "result.json").write_bytes(_canonical(result_document))
|
||||
fusion_frame = {
|
||||
"schema_version": "missioncore.e10-fusion-frame/v1",
|
||||
"frame_index": 0,
|
||||
"source_frame_index": 10,
|
||||
"session_seconds": 12.5,
|
||||
"objects": [
|
||||
{
|
||||
"source_track_id": 7,
|
||||
"track_id": 70,
|
||||
"label": "car",
|
||||
"association_group": "vehicle",
|
||||
"score": 0.9,
|
||||
"bbox_xyxy": [40.0, 40.0, 70.0, 60.0],
|
||||
"cuboid_status": "observed",
|
||||
"camera_motion_state": "static",
|
||||
"camera_motion_confidence": 0.8,
|
||||
"motion_state": "unknown",
|
||||
"motion_status": "test",
|
||||
}
|
||||
],
|
||||
}
|
||||
fusion_path = source_result / "fusion-frames.jsonl"
|
||||
fusion_path.write_bytes(_canonical(fusion_frame) + b"\n")
|
||||
|
||||
source = _FakeSource(roots["source_packs"] / source_pack_id)
|
||||
profile = CameraGeometryFusionProfile()
|
||||
points = np.asarray(source.arrays["cloud_points_map"], dtype=np.float64)
|
||||
projected = project_map_points_kb4(
|
||||
points,
|
||||
position_map_xyz=(0.0, 0.0, 0.0),
|
||||
orientation_map_from_lidar_xyzw=(0.0, 0.0, 0.0, 1.0),
|
||||
profile=_projection_profile(source), # type: ignore[arg-type]
|
||||
)
|
||||
snapshot = _semantic_support(
|
||||
fusion_frame["objects"][0],
|
||||
projected=projected,
|
||||
frame_points_map=points,
|
||||
point_class=np.asarray([2, 2, 2, 1], dtype=np.uint8),
|
||||
point_height_m=np.asarray([0.5, 0.5, 0.5, 0.0], dtype=np.float32),
|
||||
source_available=True,
|
||||
surface_valid=True,
|
||||
profile=profile,
|
||||
).document
|
||||
assert snapshot["geometry_status"] == "agree"
|
||||
|
||||
e29_identity = {
|
||||
"schema_version": "missioncore.e29-camera-geometry-fusion/v1",
|
||||
"source_result_id": source_result_id,
|
||||
"source_fusion_frames_sha256": _sha256(fusion_path),
|
||||
"source_pack_id": source_pack_id,
|
||||
"local_surface_model_id": local_surface_id,
|
||||
"frame_count": 1,
|
||||
"profile": profile.to_dict(),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
e29_identity_sha256 = hashlib.sha256(_canonical(e29_identity)).hexdigest()
|
||||
e29_result_id = f"e29-camera-geometry-{e29_identity_sha256}"
|
||||
e29_result = roots["e29"] / e29_result_id
|
||||
e29_result.mkdir()
|
||||
e29_manifest = {
|
||||
"schema_version": "missioncore.e29-camera-geometry-fusion/v1",
|
||||
"result_id": e29_result_id,
|
||||
"identity_sha256": e29_identity_sha256,
|
||||
"identity": e29_identity,
|
||||
}
|
||||
(e29_result / "manifest.json").write_bytes(_canonical(e29_manifest))
|
||||
|
||||
review_item_id = "e30-review-item-" + "d" * 64
|
||||
review_item = {
|
||||
"schema_version": "missioncore.e30-evidence-review-item/v1",
|
||||
"sequence": 0,
|
||||
"item_id": review_item_id,
|
||||
"review_key": "semantic:0:0",
|
||||
"stratum": "agree",
|
||||
"range_bucket": "near",
|
||||
"evidence_binding": {
|
||||
"frame_index": 0,
|
||||
"source_frame_index": 10,
|
||||
"session_seconds": 12.5,
|
||||
},
|
||||
"e29_locator": {
|
||||
"kind": "semantic-observation",
|
||||
"observation_index": 0,
|
||||
},
|
||||
"e29_snapshot": snapshot,
|
||||
"review": {"state": "unreviewed", "reason_code": None, "notes": None},
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
review_items = _canonical(review_item) + b"\n"
|
||||
review_identity = {
|
||||
"source": {
|
||||
"e29_result_id": e29_result_id,
|
||||
"e29_identity_sha256": e29_identity_sha256,
|
||||
"camera_result_id": source_result_id,
|
||||
"lidar_pack_id": source_pack_id,
|
||||
"local_surface_model_id": local_surface_id,
|
||||
},
|
||||
"reason_taxonomy": [
|
||||
"no_lidar_observation",
|
||||
"outside_lidar_support",
|
||||
"outside_camera_fov",
|
||||
"time_mismatch",
|
||||
"semantic_mismatch",
|
||||
"geometry_mismatch",
|
||||
"insufficient_evidence",
|
||||
"other",
|
||||
],
|
||||
}
|
||||
review_identity_sha256 = hashlib.sha256(_canonical(review_identity)).hexdigest()
|
||||
review_result_id = f"e30-review-pack-{review_identity_sha256}"
|
||||
review_root = roots["reviews"] / review_result_id
|
||||
review_root.mkdir()
|
||||
items_path = review_root / "review-items.jsonl"
|
||||
items_path.write_bytes(review_items)
|
||||
review_manifest = {
|
||||
"schema_version": "missioncore.e30-evidence-review-pack/v1",
|
||||
"result_id": review_result_id,
|
||||
"identity_sha256": review_identity_sha256,
|
||||
"identity": review_identity,
|
||||
"human_review_complete": False,
|
||||
"lab_published": False,
|
||||
"selected_item_count": 1,
|
||||
"artifacts": [
|
||||
{
|
||||
"role": "review-items",
|
||||
"path": items_path.name,
|
||||
"byte_length": items_path.stat().st_size,
|
||||
"sha256": _sha256(items_path),
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
(review_root / "manifest.json").write_bytes(_canonical(review_manifest))
|
||||
roots["review_root"] = review_root
|
||||
return roots, review_item_id
|
||||
|
||||
|
||||
def test_materialization_replays_exact_support_and_publishes_point_indices(
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
roots, review_item_id = _write_source_tree(tmp_path)
|
||||
monkeypatch.setattr(materialization, "E10LidarFieldSource", _FakeSource)
|
||||
monkeypatch.setattr(materialization, "K1LocalSurfaceV1", _FakeSurface)
|
||||
|
||||
result = build_e30_materialization(
|
||||
review_pack_root=roots["review_root"],
|
||||
e29_root=roots["e29"],
|
||||
source_result_root=roots["source_results"],
|
||||
source_pack_root=roots["source_packs"],
|
||||
local_surface_root=roots["surfaces"],
|
||||
output_root=roots["output"],
|
||||
)
|
||||
|
||||
assert result.manifest["item_count"] == 1
|
||||
assert result.manifest["human_review_complete"] is False
|
||||
index = json.loads(
|
||||
(result.result_root / "materialized-items.jsonl").read_text()
|
||||
)
|
||||
assert index["item_id"] == review_item_id
|
||||
assert index["materialization"]["selected_point_count"] == 3
|
||||
assert index["materialization"]["source_reprojection_required"] is False
|
||||
artifact = result.result_root / index["artifact"]["path"]
|
||||
with np.load(artifact, allow_pickle=False) as arrays:
|
||||
assert arrays["selected_source_indices"].tolist() == [0, 1, 2]
|
||||
assert arrays["projected_selected_mask"].sum() == 3
|
||||
assert arrays["projected_candidate_mask"].sum() >= 3
|
||||
|
||||
|
||||
def test_e30_review_api_exposes_verified_read_only_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch: Any,
|
||||
) -> None:
|
||||
roots, review_item_id = _write_source_tree(tmp_path)
|
||||
monkeypatch.setattr(materialization, "E10LidarFieldSource", _FakeSource)
|
||||
monkeypatch.setattr(materialization, "K1LocalSurfaceV1", _FakeSurface)
|
||||
result = build_e30_materialization(
|
||||
review_pack_root=roots["review_root"],
|
||||
e29_root=roots["e29"],
|
||||
source_result_root=roots["source_results"],
|
||||
source_pack_root=roots["source_packs"],
|
||||
local_surface_root=roots["surfaces"],
|
||||
output_root=roots["output"],
|
||||
)
|
||||
router = build_e30_review_router(
|
||||
materialization_root_provider=lambda: roots["output"],
|
||||
review_pack_root_provider=lambda: roots["reviews"],
|
||||
)
|
||||
catalog_route = _endpoint(router, "/api/v1/laboratory/e30/reviews")
|
||||
items_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/items",
|
||||
)
|
||||
detail_route = _endpoint(
|
||||
router,
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/items/{item_id}",
|
||||
)
|
||||
|
||||
catalog = catalog_route(limit=1) # type: ignore[operator]
|
||||
items = items_route( # type: ignore[operator]
|
||||
result_id=result.result_id,
|
||||
stratum="agree",
|
||||
limit=48,
|
||||
cursor=0,
|
||||
)
|
||||
detail = detail_route( # type: ignore[operator]
|
||||
result_id=result.result_id,
|
||||
item_id=review_item_id,
|
||||
)
|
||||
|
||||
assert catalog["configured"] is True
|
||||
assert catalog["items"][0]["access"] == "read-only"
|
||||
assert catalog["items"][0]["stratum_counts"]["agree"] == 1
|
||||
assert items["total"] == 1
|
||||
assert items["items"][0]["item_id"] == review_item_id
|
||||
assert detail["item"]["selected"]["source_indices"] == [0, 1, 2]
|
||||
assert detail["item"]["projection"]["selected_mask"] == [1, 1, 1]
|
||||
assert str(tmp_path) not in repr(catalog)
|
||||
assert str(tmp_path) not in repr(items)
|
||||
assert str(tmp_path) not in repr(detail)
|
||||
|
||||
artifact = result.result_root / "items" / f"{review_item_id}.npz"
|
||||
artifact.write_bytes(artifact.read_bytes() + b"changed")
|
||||
tampered = catalog_route(limit=1) # type: ignore[operator]
|
||||
assert tampered["candidate_total"] == 1
|
||||
assert tampered["invalid_total"] == 1
|
||||
assert tampered["items"] == []
|
||||
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.compute.e30_review_pack import (
|
||||
E30_REASON_TAXONOMY,
|
||||
E30ReviewPackError,
|
||||
E30ReviewSelectionProfile,
|
||||
build_e30_review_pack,
|
||||
)
|
||||
from k1link.compute.semantic_geometry_fusion import (
|
||||
CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||
CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||
CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _observation(status: str, index: int) -> dict[str, object]:
|
||||
return {
|
||||
"track_id": index,
|
||||
"label": "car" if index % 2 else "person",
|
||||
"association_group": "vehicle" if index % 2 else "person",
|
||||
"geometry_status": status,
|
||||
"geometry_reason": f"reason-{status}",
|
||||
"range_m": None if status in {"single-source-camera", "unknown"} else 2.5 + index,
|
||||
"support": {"connected_occupied_points": 0},
|
||||
}
|
||||
|
||||
|
||||
def _source_result(root: Path) -> Path:
|
||||
result_id = "e29-camera-geometry-" + "a" * 64
|
||||
result = root / result_id
|
||||
result.mkdir()
|
||||
frames = [
|
||||
{
|
||||
"schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||
"frame_index": 0,
|
||||
"source_frame_index": 10,
|
||||
"session_seconds": 1.0,
|
||||
"semantic_observations": [
|
||||
_observation("agree", 1),
|
||||
_observation("single-source-camera", 2),
|
||||
_observation("conflict", 3),
|
||||
_observation("unknown", 4),
|
||||
],
|
||||
"geometry_only_occupied": [
|
||||
{
|
||||
"geometry_status": "single-source-geometry",
|
||||
"nearest_range_m": 8.0,
|
||||
"point_count": 10,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"schema_version": CAMERA_GEOMETRY_FRAME_SCHEMA,
|
||||
"frame_index": 1,
|
||||
"source_frame_index": 11,
|
||||
"session_seconds": 2.0,
|
||||
"semantic_observations": [
|
||||
_observation("agree", 5),
|
||||
_observation("single-source-camera", 6),
|
||||
_observation("conflict", 7),
|
||||
_observation("unknown", 8),
|
||||
],
|
||||
"geometry_only_occupied": [
|
||||
{
|
||||
"geometry_status": "single-source-geometry",
|
||||
"nearest_range_m": 2.0,
|
||||
"point_count": 12,
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
frames_path = result / "camera-geometry-frames.jsonl"
|
||||
frames_path.write_bytes(b"".join(_canonical(frame) + b"\n" for frame in frames))
|
||||
|
||||
identity: dict[str, Any] = {
|
||||
"frame_count": 2,
|
||||
"timeline_start_seconds": 1.0,
|
||||
"timeline_end_seconds": 2.0,
|
||||
"source_result_id": "e10-integrated-perception-" + "b" * 64,
|
||||
"source_pack_id": "e10-lidar-pack-" + "c" * 64,
|
||||
"local_surface_model_id": "k1-local-surface-" + "d" * 64,
|
||||
"profile": {"profile_id": "camera-first-local-surface-validation/v1"},
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
report = {
|
||||
"schema_version": CAMERA_GEOMETRY_REPORT_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"status": "diagnostic-replay-complete",
|
||||
"ground_truth": False,
|
||||
"identity": identity,
|
||||
"metrics": {
|
||||
"semantic_observations": {
|
||||
"geometry_status": {
|
||||
"agree": 2,
|
||||
"single-source-camera": 2,
|
||||
"conflict": 2,
|
||||
"unknown": 2,
|
||||
}
|
||||
},
|
||||
"geometry_only_occupied": {"cluster_count": 2},
|
||||
},
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
}
|
||||
report_path = result / "camera-geometry-report.json"
|
||||
report_path.write_bytes(_canonical(report))
|
||||
identity_sha256 = hashlib.sha256(_canonical(identity)).hexdigest()
|
||||
result_id = f"e29-camera-geometry-{identity_sha256}"
|
||||
result_with_identity = root / result_id
|
||||
result.rename(result_with_identity)
|
||||
result = result_with_identity
|
||||
frames_path = result / frames_path.name
|
||||
report_path = result / report_path.name
|
||||
report["result_id"] = result_id
|
||||
report_path.write_bytes(_canonical(report))
|
||||
manifest = {
|
||||
"schema_version": CAMERA_GEOMETRY_FUSION_SCHEMA,
|
||||
"result_id": result_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"ground_truth": False,
|
||||
"artifacts": [
|
||||
{
|
||||
"role": "camera-geometry-frames",
|
||||
"path": frames_path.name,
|
||||
"byte_length": frames_path.stat().st_size,
|
||||
"sha256": _sha256(frames_path),
|
||||
},
|
||||
{
|
||||
"role": "camera-geometry-report",
|
||||
"path": report_path.name,
|
||||
"byte_length": report_path.stat().st_size,
|
||||
"sha256": _sha256(report_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
(result / "manifest.json").write_bytes(_canonical(manifest))
|
||||
return result
|
||||
|
||||
|
||||
def test_review_pack_binds_all_strata_and_keeps_human_decision_open(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = _source_result(tmp_path)
|
||||
profile = E30ReviewSelectionProfile(
|
||||
agree_maximum=1,
|
||||
camera_only_maximum=1,
|
||||
unknown_maximum=1,
|
||||
geometry_only_maximum=1,
|
||||
temporal_bins=2,
|
||||
)
|
||||
|
||||
first = build_e30_review_pack(
|
||||
e29_result_root=source,
|
||||
output_root=tmp_path / "review-packs",
|
||||
profile=profile,
|
||||
)
|
||||
second = build_e30_review_pack(
|
||||
e29_result_root=source,
|
||||
output_root=tmp_path / "review-packs",
|
||||
profile=profile,
|
||||
)
|
||||
|
||||
assert first.result_id == second.result_id
|
||||
assert first.manifest["human_review_complete"] is False
|
||||
assert first.manifest["lab_published"] is False
|
||||
assert first.manifest["source_counts"] == {
|
||||
"agree": 2,
|
||||
"camera-only": 2,
|
||||
"conflict": 2,
|
||||
"geometry-only": 2,
|
||||
"unknown": 2,
|
||||
}
|
||||
assert first.manifest["selected_counts"] == {
|
||||
"agree": 1,
|
||||
"camera-only": 1,
|
||||
"conflict": 2,
|
||||
"geometry-only": 1,
|
||||
"unknown": 1,
|
||||
}
|
||||
lines = (first.result_root / "review-items.jsonl").read_text().splitlines()
|
||||
items = [json.loads(line) for line in lines]
|
||||
assert len(items) == 6
|
||||
assert all(item["review"]["state"] == "unreviewed" for item in items)
|
||||
assert all(item["materialization"]["source_reprojection_required"] for item in items)
|
||||
assert len({item["item_id"] for item in items}) == 6
|
||||
|
||||
|
||||
def test_review_pack_taxonomy_is_fixed_and_source_tampering_rejects(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
assert E30_REASON_TAXONOMY[0] == "no_lidar_observation"
|
||||
assert E30_REASON_TAXONOMY[-1] == "unknown"
|
||||
assert len(E30_REASON_TAXONOMY) == 19
|
||||
source = _source_result(tmp_path)
|
||||
frames = source / "camera-geometry-frames.jsonl"
|
||||
frames.write_bytes(frames.read_bytes() + b"\n")
|
||||
|
||||
with pytest.raises(E30ReviewPackError, match="byte length changed"):
|
||||
build_e30_review_pack(
|
||||
e29_result_root=source,
|
||||
output_root=tmp_path / "review-packs",
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.compute.lidar_contract import K1_LIVE_LIDAR_PROFILE
|
||||
from k1link.compute.sensor_representation import (
|
||||
K1_LIO_PCL_CAPABILITIES,
|
||||
SensorAlgorithmRequirements,
|
||||
SensorCapability,
|
||||
SensorRepresentationCapabilities,
|
||||
SensorRepresentationContractError,
|
||||
SensorRepresentationKind,
|
||||
SensorSourceCurrentness,
|
||||
assess_sensor_algorithm,
|
||||
require_sensor_algorithm_admission,
|
||||
)
|
||||
|
||||
|
||||
def _projective_mapper_requirements() -> SensorAlgorithmRequirements:
|
||||
return SensorAlgorithmRequirements(
|
||||
algorithm_id="lidar-projective-mapper/v1",
|
||||
accepted_representations=(SensorRepresentationKind.NATIVE_SENSOR_SCAN,),
|
||||
required_capabilities=frozenset(
|
||||
{
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.NATIVE_RAY_MODEL,
|
||||
SensorCapability.SENSOR_ORIGIN_PER_POINT,
|
||||
SensorCapability.RAY_CLEARING_VALID,
|
||||
SensorCapability.FREE_SPACE_EVIDENCE_VALID,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_k1_lio_pcl_capabilities_round_trip_without_free_space_or_authority() -> None:
|
||||
document = K1_LIO_PCL_CAPABILITIES.to_dict()
|
||||
restored = SensorRepresentationCapabilities.from_dict(document)
|
||||
|
||||
assert restored == K1_LIO_PCL_CAPABILITIES
|
||||
assert restored.source_profile_id == K1_LIVE_LIDAR_PROFILE.profile_id
|
||||
assert document["representation_kind"] == "registered-map-increment"
|
||||
assert document["source_currentness"] == "frame-increment"
|
||||
assert document["semantics"] == {
|
||||
"absence_of_endpoints_means_free": False,
|
||||
"unknown_remains_unknown": True,
|
||||
}
|
||||
assert document["authority"] == {
|
||||
"compatibility_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
assert not restored.supports(SensorCapability.NATIVE_RAY_MODEL)
|
||||
assert not restored.supports(SensorCapability.RAY_CLEARING_VALID)
|
||||
assert not restored.supports(SensorCapability.FREE_SPACE_EVIDENCE_VALID)
|
||||
|
||||
|
||||
def test_k1_lio_pcl_rejects_projective_free_space_mapper() -> None:
|
||||
requirements = _projective_mapper_requirements()
|
||||
|
||||
admission = assess_sensor_algorithm(K1_LIO_PCL_CAPABILITIES, requirements)
|
||||
|
||||
assert admission.admitted is False
|
||||
assert admission.reasons == (
|
||||
"representation-not-accepted:registered-map-increment",
|
||||
"missing-capability:free_space_evidence_valid",
|
||||
"missing-capability:native_ray_model",
|
||||
"missing-capability:ray_clearing_valid",
|
||||
"missing-capability:sensor_origin_per_point",
|
||||
)
|
||||
with pytest.raises(
|
||||
SensorRepresentationContractError,
|
||||
match="lidar-projective-mapper/v1 rejected",
|
||||
):
|
||||
require_sensor_algorithm_admission(K1_LIO_PCL_CAPABILITIES, requirements)
|
||||
|
||||
|
||||
def test_k1_lio_pcl_admits_endpoint_marking_without_granting_authority() -> None:
|
||||
requirements = SensorAlgorithmRequirements(
|
||||
algorithm_id="endpoint-occupied-marking/v1",
|
||||
accepted_representations=(
|
||||
SensorRepresentationKind.REGISTERED_MAP_INCREMENT,
|
||||
),
|
||||
required_capabilities=frozenset(
|
||||
{
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.MAP_REGISTERED,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
admission = require_sensor_algorithm_admission(
|
||||
K1_LIO_PCL_CAPABILITIES,
|
||||
requirements,
|
||||
)
|
||||
|
||||
assert admission.admitted is True
|
||||
assert admission.reasons == ()
|
||||
assert admission.to_dict()["authority"] == {
|
||||
"compatibility_only": True,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
def test_complete_native_scan_admits_projective_mapper() -> None:
|
||||
profile = SensorRepresentationCapabilities(
|
||||
profile_id="synthetic-native-lidar-capabilities/v1",
|
||||
source_profile_id="synthetic-native-lidar/v1",
|
||||
representation_kind=SensorRepresentationKind.NATIVE_SENSOR_SCAN,
|
||||
coordinate_frame="lidar",
|
||||
source_currentness=SensorSourceCurrentness.CURRENT_OBSERVATION,
|
||||
capabilities=frozenset(
|
||||
{
|
||||
SensorCapability.METRIC_XYZ,
|
||||
SensorCapability.METRIC_INTENSITY,
|
||||
SensorCapability.SENSOR_POSE_AVAILABLE,
|
||||
SensorCapability.PER_POINT_TIME,
|
||||
SensorCapability.RING_OR_CHANNEL,
|
||||
SensorCapability.SEPARATE_IMU,
|
||||
SensorCapability.SHARED_HARDWARE_CLOCK,
|
||||
SensorCapability.NATIVE_RAY_MODEL,
|
||||
SensorCapability.SENSOR_ORIGIN_PER_POINT,
|
||||
SensorCapability.RAY_CLEARING_VALID,
|
||||
SensorCapability.MOTION_COMPENSATION_VALID,
|
||||
SensorCapability.FREE_SPACE_EVIDENCE_VALID,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
assert require_sensor_algorithm_admission(
|
||||
profile,
|
||||
_projective_mapper_requirements(),
|
||||
).admitted
|
||||
|
||||
|
||||
def test_contract_rejects_invented_free_space_and_authority() -> None:
|
||||
with pytest.raises(
|
||||
SensorRepresentationContractError,
|
||||
match="free-space evidence requires admitted ray clearing",
|
||||
):
|
||||
replace(
|
||||
K1_LIO_PCL_CAPABILITIES,
|
||||
capabilities=K1_LIO_PCL_CAPABILITIES.capabilities
|
||||
| {SensorCapability.FREE_SPACE_EVIDENCE_VALID},
|
||||
)
|
||||
|
||||
document = K1_LIO_PCL_CAPABILITIES.to_dict()
|
||||
authority = document["authority"]
|
||||
assert isinstance(authority, dict)
|
||||
authority["navigation_or_safety_accepted"] = True
|
||||
with pytest.raises(
|
||||
SensorRepresentationContractError,
|
||||
match="cannot grant authority",
|
||||
):
|
||||
SensorRepresentationCapabilities.from_dict(document)
|
||||
|
||||
|
||||
def test_algorithm_requirements_are_strict_and_round_trip() -> None:
|
||||
requirements = _projective_mapper_requirements()
|
||||
restored = SensorAlgorithmRequirements.from_dict(requirements.to_dict())
|
||||
|
||||
assert restored == requirements
|
||||
document = requirements.to_dict()
|
||||
document["on_capability_mismatch"] = "degrade"
|
||||
with pytest.raises(
|
||||
SensorRepresentationContractError,
|
||||
match="mismatch must reject",
|
||||
):
|
||||
SensorAlgorithmRequirements.from_dict(document)
|
||||
Reference in New Issue
Block a user