refactor(lab): canonize selected evidence reports
This commit is contained in:
@@ -863,6 +863,7 @@ export default function App() {
|
|||||||
onDeleteBegin: releaseRecordedReplayForDelete,
|
onDeleteBegin: releaseRecordedReplayForDelete,
|
||||||
}}
|
}}
|
||||||
onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction}
|
onLaboratoryAnnotationActionChange={laboratoryAnnotation.setAction}
|
||||||
|
onLaboratoryViewActionChange={laboratoryAnnotation.setViewAction}
|
||||||
navigation={{
|
navigation={{
|
||||||
openView,
|
openView,
|
||||||
openSource,
|
openSource,
|
||||||
@@ -1243,7 +1244,6 @@ export default function App() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Window>
|
</Window>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import {
|
import { Select, StatusBadge } from "@nodedc/ui-react";
|
||||||
Select,
|
|
||||||
StatusBadge,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
export interface LaboratoryOption<T extends string> {
|
export interface LaboratoryOption<T extends string> {
|
||||||
id: T;
|
id: T;
|
||||||
label: string;
|
label: string;
|
||||||
|
status?: "progress" | "retained" | "failed" | "unreviewed";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LaboratoryExecutionClass =
|
export type LaboratoryExecutionClass =
|
||||||
@@ -103,6 +101,20 @@ export function LaboratorySelector<T extends string>({
|
|||||||
options={options.map((option) => ({
|
options={options.map((option) => ({
|
||||||
value: option.id,
|
value: option.id,
|
||||||
label: option.label,
|
label: option.label,
|
||||||
|
icon: option.status ? (
|
||||||
|
<span
|
||||||
|
className="laboratory-status-dot"
|
||||||
|
data-status={option.status}
|
||||||
|
aria-label={option.status === "progress"
|
||||||
|
? "Есть подтверждённый прогресс"
|
||||||
|
: option.status === "retained"
|
||||||
|
? "Сохранено для сравнения"
|
||||||
|
: option.status === "failed"
|
||||||
|
? "Gate не пройден"
|
||||||
|
: "Требует классификации"}
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
) : undefined,
|
||||||
}))}
|
}))}
|
||||||
variant="split"
|
variant="split"
|
||||||
menuWidth="anchor"
|
menuWidth="anchor"
|
||||||
|
|||||||
@@ -1,26 +1,41 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { Button, Icon } from "@nodedc/ui-react";
|
import { Button, Icon } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { LaboratoryAnnotationAction } from "../../workspaces/contracts";
|
import type {
|
||||||
|
LaboratoryAnnotationAction,
|
||||||
|
LaboratoryViewAction,
|
||||||
|
} from "../../workspaces/contracts";
|
||||||
|
|
||||||
export function useLaboratoryAnnotationHeader(): {
|
export function useLaboratoryAnnotationHeader(): {
|
||||||
control: ReactNode;
|
control: ReactNode;
|
||||||
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
setAction: (action: LaboratoryAnnotationAction | null) => void;
|
||||||
|
setViewAction: (action: LaboratoryViewAction | null) => void;
|
||||||
} {
|
} {
|
||||||
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
|
||||||
|
const [viewAction, setViewAction] = useState<LaboratoryViewAction | null>(null);
|
||||||
return {
|
return {
|
||||||
setAction,
|
setAction,
|
||||||
control: action ? (
|
setViewAction,
|
||||||
<Button
|
control: action || viewAction ? (
|
||||||
size="compact"
|
<div className="laboratory-header-tools">
|
||||||
shape="pill"
|
{viewAction ? (
|
||||||
variant="accent"
|
<Button shape="pill" variant="secondary" onClick={viewAction.onClick}>
|
||||||
icon={<Icon name="edit" size={16} />}
|
{viewAction.label}
|
||||||
disabled={action.disabled}
|
</Button>
|
||||||
onClick={action.onClick}
|
) : null}
|
||||||
>
|
{action ? (
|
||||||
{action.label}
|
<Button
|
||||||
</Button>
|
size="compact"
|
||||||
|
shape="pill"
|
||||||
|
variant="accent"
|
||||||
|
icon={<Icon name="edit" size={16} />}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
export type LaboratoryEvidenceCompleteness = "recorded" | "not-recorded";
|
||||||
|
export type JsonPrimitive = string | number | boolean | null;
|
||||||
|
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||||
|
|
||||||
|
export interface LaboratoryEvidenceArtifact {
|
||||||
|
kind: string | null;
|
||||||
|
path: string;
|
||||||
|
byteLength: number;
|
||||||
|
sha256: string;
|
||||||
|
schemaVersion: string | null;
|
||||||
|
mediaType: string | null;
|
||||||
|
verified: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryEvidenceReport {
|
||||||
|
schemaVersion: "missioncore.laboratory-evidence-report/v1";
|
||||||
|
workId: string;
|
||||||
|
resultId: string;
|
||||||
|
createdAtUtc: string | null;
|
||||||
|
access: "read-only";
|
||||||
|
proof: {
|
||||||
|
documentSchemaVersion: string;
|
||||||
|
documentSha256: string;
|
||||||
|
identitySha256: string;
|
||||||
|
reportSchemaVersion: string | null;
|
||||||
|
reportSha256: string | null;
|
||||||
|
artifactCount: number;
|
||||||
|
verifiedArtifactCount: number;
|
||||||
|
};
|
||||||
|
completeness: Readonly<Record<string, LaboratoryEvidenceCompleteness>>;
|
||||||
|
identity: Record<string, JsonValue>;
|
||||||
|
source: Record<string, JsonValue> | null;
|
||||||
|
configuration: Record<string, JsonValue> | null;
|
||||||
|
method: Record<string, JsonValue> | null;
|
||||||
|
execution: Record<string, JsonValue> | null;
|
||||||
|
resources: Record<string, JsonValue> | null;
|
||||||
|
metrics: Record<string, JsonValue> | null;
|
||||||
|
gates: Record<string, JsonValue> | null;
|
||||||
|
decision: JsonValue | undefined;
|
||||||
|
limitations: JsonValue | undefined;
|
||||||
|
authority: Record<string, JsonValue> | null;
|
||||||
|
artifacts: readonly LaboratoryEvidenceArtifact[];
|
||||||
|
visualEvidence: Record<string, JsonValue>;
|
||||||
|
rawReport: Record<string, JsonValue>;
|
||||||
|
canonicalJson: Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaboratoryEvidenceReportContractError extends Error {}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableRecord(value: unknown, label: string): Record<string, JsonValue> | null {
|
||||||
|
return value === null ? null : jsonRecord(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRecord(value: unknown, label: string): Record<string, JsonValue> {
|
||||||
|
const document = record(value, label);
|
||||||
|
for (const [key, item] of Object.entries(document)) validateJson(item, `${label}.${key}`);
|
||||||
|
return document as Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateJson(value: unknown, label: string): asserts value is JsonValue {
|
||||||
|
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||||
|
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(`${label}: число не конечно.`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, index) => validateJson(item, `${label}[${index}]`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const document = record(value, label);
|
||||||
|
Object.entries(document).forEach(([key, item]) => validateJson(item, `${label}.${key}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалась строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableText(value: unknown, label: string): string | null {
|
||||||
|
return value === null ? null : text(value, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: unknown, label: string): number {
|
||||||
|
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(`${label}: ожидалось целое число.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArtifact(value: unknown): LaboratoryEvidenceArtifact {
|
||||||
|
const artifact = record(value, "LAB artifact");
|
||||||
|
if (artifact.verified !== true) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError("LAB artifact: хэш не подтверждён.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: nullableText(artifact.kind, "LAB artifact kind"),
|
||||||
|
path: text(artifact.path, "LAB artifact path"),
|
||||||
|
byteLength: integer(artifact.byte_length, "LAB artifact byte_length"),
|
||||||
|
sha256: text(artifact.sha256, "LAB artifact sha256"),
|
||||||
|
schemaVersion: nullableText(artifact.schema_version, "LAB artifact schema_version"),
|
||||||
|
mediaType: nullableText(artifact.media_type, "LAB artifact media_type"),
|
||||||
|
verified: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLaboratoryEvidenceReport(value: unknown): LaboratoryEvidenceReport {
|
||||||
|
const payload = jsonRecord(value, "LAB evidence report") as Record<string, unknown>;
|
||||||
|
if (payload.schema_version !== "missioncore.laboratory-evidence-report/v1") {
|
||||||
|
throw new LaboratoryEvidenceReportContractError("LAB evidence report: неизвестная схема.");
|
||||||
|
}
|
||||||
|
if (payload.access !== "read-only") {
|
||||||
|
throw new LaboratoryEvidenceReportContractError("LAB evidence report: доступ не read-only.");
|
||||||
|
}
|
||||||
|
const proof = record(payload.proof, "LAB evidence proof");
|
||||||
|
const completenessPayload = record(payload.completeness, "LAB evidence completeness");
|
||||||
|
const completeness: Record<string, LaboratoryEvidenceCompleteness> = {};
|
||||||
|
for (const [key, state] of Object.entries(completenessPayload)) {
|
||||||
|
if (state !== "recorded" && state !== "not-recorded") {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(`LAB completeness ${key}: неизвестное значение.`);
|
||||||
|
}
|
||||||
|
completeness[key] = state;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.artifacts)) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError("LAB evidence artifacts: ожидался список.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
schemaVersion: "missioncore.laboratory-evidence-report/v1",
|
||||||
|
workId: text(payload.work_id, "LAB work_id"),
|
||||||
|
resultId: text(payload.result_id, "LAB result_id"),
|
||||||
|
createdAtUtc: nullableText(payload.created_at_utc, "LAB created_at_utc"),
|
||||||
|
access: "read-only",
|
||||||
|
proof: {
|
||||||
|
documentSchemaVersion: text(proof.document_schema_version, "document schema"),
|
||||||
|
documentSha256: text(proof.document_sha256, "document sha256"),
|
||||||
|
identitySha256: text(proof.identity_sha256, "identity sha256"),
|
||||||
|
reportSchemaVersion: nullableText(proof.report_schema_version, "report schema"),
|
||||||
|
reportSha256: nullableText(proof.report_sha256, "report sha256"),
|
||||||
|
artifactCount: integer(proof.artifact_count, "artifact_count"),
|
||||||
|
verifiedArtifactCount: integer(proof.verified_artifact_count, "verified_artifact_count"),
|
||||||
|
},
|
||||||
|
completeness,
|
||||||
|
identity: jsonRecord(payload.identity, "LAB identity"),
|
||||||
|
source: nullableRecord(payload.source, "LAB source"),
|
||||||
|
configuration: nullableRecord(payload.configuration, "LAB configuration"),
|
||||||
|
method: nullableRecord(payload.method, "LAB method"),
|
||||||
|
execution: nullableRecord(payload.execution, "LAB execution"),
|
||||||
|
resources: nullableRecord(payload.resources, "LAB resources"),
|
||||||
|
metrics: nullableRecord(payload.metrics, "LAB metrics"),
|
||||||
|
gates: nullableRecord(payload.gates, "LAB gates"),
|
||||||
|
decision: payload.decision as JsonValue | undefined,
|
||||||
|
limitations: payload.limitations as JsonValue | undefined,
|
||||||
|
authority: nullableRecord(payload.authority, "LAB authority"),
|
||||||
|
artifacts: payload.artifacts.map(parseArtifact),
|
||||||
|
visualEvidence: jsonRecord(payload.visual_evidence, "LAB visual evidence"),
|
||||||
|
rawReport: jsonRecord(payload.raw_report, "LAB raw report"),
|
||||||
|
canonicalJson: payload as Record<string, JsonValue>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLaboratoryEvidenceReport({
|
||||||
|
workId,
|
||||||
|
resultId,
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
workId: string;
|
||||||
|
resultId: string;
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<LaboratoryEvidenceReport> {
|
||||||
|
const response = await fetcher(
|
||||||
|
`/api/v1/laboratory/evidence-reports/${encodeURIComponent(workId)}/${encodeURIComponent(resultId)}`,
|
||||||
|
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError(
|
||||||
|
response.status === 404
|
||||||
|
? "Для этой LAB канонический evidence-report пока не опубликован."
|
||||||
|
: `Evidence-report LAB не прошёл серверную проверку: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const report = parseLaboratoryEvidenceReport(await response.json());
|
||||||
|
if (report.workId !== workId || report.resultId !== resultId) {
|
||||||
|
throw new LaboratoryEvidenceReportContractError("Evidence-report не совпадает с выбранной LAB identity.");
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
export type LaboratoryValueSignal = "progress" | "retained" | "failed";
|
||||||
|
export type LaboratoryValueLifecycle = "current" | "legacy";
|
||||||
|
export type LaboratoryVisualEvidence = "available" | "partial" | "missing";
|
||||||
|
|
||||||
|
export interface LaboratoryValueReviewEntry {
|
||||||
|
catalogId: string;
|
||||||
|
evidenceId: string;
|
||||||
|
signal: LaboratoryValueSignal;
|
||||||
|
lifecycle: LaboratoryValueLifecycle;
|
||||||
|
visualEvidence: LaboratoryVisualEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryValueReviewIndex {
|
||||||
|
reviewedAtUtc: string;
|
||||||
|
items: readonly LaboratoryValueReviewEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LaboratoryValueReviewContractError extends Error {}
|
||||||
|
|
||||||
|
const ENTRY_KEYS = [
|
||||||
|
"access",
|
||||||
|
"catalog_id",
|
||||||
|
"evidence_id",
|
||||||
|
"lifecycle",
|
||||||
|
"signal",
|
||||||
|
"visual_evidence",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function objectValue(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
throw new LaboratoryValueReviewContractError(`${label}: ожидался объект.`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
expected: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
if (actual.join("\0") !== [...expected].sort().join("\0")) {
|
||||||
|
throw new LaboratoryValueReviewContractError(`${label}: нарушен состав полей.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textValue(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string" || !value.trim() || value !== value.trim()) {
|
||||||
|
throw new LaboratoryValueReviewContractError(`${label}: ожидалась непустая строка.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntry(value: unknown): LaboratoryValueReviewEntry {
|
||||||
|
const item = objectValue(value, "LAB value-review item");
|
||||||
|
exactKeys(item, ENTRY_KEYS, "LAB value-review item");
|
||||||
|
if (item.access !== "read-only") {
|
||||||
|
throw new LaboratoryValueReviewContractError("LAB value-review item: доступ не read-only.");
|
||||||
|
}
|
||||||
|
if (!(["progress", "retained", "failed"] as const).includes(
|
||||||
|
item.signal as LaboratoryValueSignal,
|
||||||
|
)) {
|
||||||
|
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный signal.");
|
||||||
|
}
|
||||||
|
if (!(["current", "legacy"] as const).includes(
|
||||||
|
item.lifecycle as LaboratoryValueLifecycle,
|
||||||
|
)) {
|
||||||
|
throw new LaboratoryValueReviewContractError("LAB value-review item: неизвестный lifecycle.");
|
||||||
|
}
|
||||||
|
if (!(["available", "partial", "missing"] as const).includes(
|
||||||
|
item.visual_evidence as LaboratoryVisualEvidence,
|
||||||
|
)) {
|
||||||
|
throw new LaboratoryValueReviewContractError(
|
||||||
|
"LAB value-review item: неизвестный visual_evidence.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
catalogId: textValue(item.catalog_id, "LAB value-review catalog_id"),
|
||||||
|
evidenceId: textValue(item.evidence_id, "LAB value-review evidence_id"),
|
||||||
|
signal: item.signal as LaboratoryValueSignal,
|
||||||
|
lifecycle: item.lifecycle as LaboratoryValueLifecycle,
|
||||||
|
visualEvidence: item.visual_evidence as LaboratoryVisualEvidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLaboratoryValueReviewIndex({
|
||||||
|
fetcher = fetch,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
fetcher?: typeof fetch;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
} = {}): Promise<LaboratoryValueReviewIndex> {
|
||||||
|
const response = await fetcher("/api/v1/laboratory/value-review-index", {
|
||||||
|
method: "GET",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new LaboratoryValueReviewContractError(
|
||||||
|
`Value-review индекс LAB недоступен: HTTP ${response.status}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = objectValue(await response.json(), "LAB value-review index");
|
||||||
|
exactKeys(
|
||||||
|
payload,
|
||||||
|
["access", "items", "reviewed_at_utc", "schema_version"],
|
||||||
|
"LAB value-review index",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
payload.schema_version !== "missioncore.laboratory-value-review-index/v1"
|
||||||
|
|| payload.access !== "read-only"
|
||||||
|
|| !Array.isArray(payload.items)
|
||||||
|
|| payload.items.length > 128
|
||||||
|
) {
|
||||||
|
throw new LaboratoryValueReviewContractError(
|
||||||
|
"LAB value-review index: нарушен контракт.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = payload.items.map(parseEntry);
|
||||||
|
if (new Set(items.map((item) => item.catalogId)).size !== items.length) {
|
||||||
|
throw new LaboratoryValueReviewContractError(
|
||||||
|
"LAB value-review index: catalog_id продублирован.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewedAtUtc: textValue(payload.reviewed_at_utc, "LAB value-review reviewed_at_utc"),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
@import "./styles/l3-pointpillars-visual-audit.css";
|
@import "./styles/l3-pointpillars-visual-audit.css";
|
||||||
@import "./styles/l34-annotation.css";
|
@import "./styles/l34-annotation.css";
|
||||||
@import "./styles/laboratory-reporting.css";
|
@import "./styles/laboratory-reporting.css";
|
||||||
|
@import "./styles/laboratory-evidence-report.css";
|
||||||
@import "./styles/e34-temporal-layer.css";
|
@import "./styles/e34-temporal-layer.css";
|
||||||
@import "./styles/e35-degradation-recovery.css";
|
@import "./styles/e35-degradation-recovery.css";
|
||||||
@import "./styles/e30-human-review.css";
|
@import "./styles/e30-human-review.css";
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
.laboratory-header-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-header-tools > .nodedc-button:first-child {
|
||||||
|
min-width: 6.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
flex: 0 0 0.48rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
box-shadow: 0 0 0 0.08rem rgb(255 255 255 / 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-status-dot[data-status="progress"] {
|
||||||
|
color: rgb(var(--nodedc-success-rgb));
|
||||||
|
box-shadow: 0 0 0.38rem rgb(var(--nodedc-success-rgb) / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-status-dot[data-status="retained"] {
|
||||||
|
color: rgb(var(--nodedc-warning-rgb));
|
||||||
|
box-shadow: 0 0 0.38rem rgb(var(--nodedc-warning-rgb) / 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-status-dot[data-status="failed"],
|
||||||
|
.laboratory-status-dot[data-status="unreviewed"] {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__header,
|
||||||
|
.laboratory-evidence-report__integrity,
|
||||||
|
.laboratory-evidence-report__section {
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 0.025);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__header,
|
||||||
|
.laboratory-evidence-report__integrity > header,
|
||||||
|
.laboratory-evidence-report__artifact-list article > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report h2,
|
||||||
|
.laboratory-evidence-report h3,
|
||||||
|
.laboratory-evidence-report p,
|
||||||
|
.laboratory-evidence-report dl,
|
||||||
|
.laboratory-evidence-report dd,
|
||||||
|
.laboratory-evidence-report ol {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report h2 {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report h3 {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__header p {
|
||||||
|
margin-top: 0.32rem;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__identity {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.42rem;
|
||||||
|
margin-top: 0.8rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__identity > div,
|
||||||
|
.laboratory-evidence-report__completeness > div {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
background: rgb(255 255 255 / 0.028);
|
||||||
|
padding: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report dt {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.52rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__identity dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-family: var(--nodedc-font-family-mono, monospace);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__completeness {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.45rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__completeness dd {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__completeness [data-state="recorded"] dd {
|
||||||
|
color: rgb(var(--nodedc-success-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__completeness [data-state="not-recorded"] dd {
|
||||||
|
color: rgb(var(--nodedc-warning-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__section {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__section > header {
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__tree {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__tree > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(7.5rem, 0.38fr) minmax(0, 1fr);
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 0.04);
|
||||||
|
padding-top: 0.34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__tree > div:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__tree[data-depth]:not([data-depth="0"]) > div {
|
||||||
|
grid-template-columns: minmax(6rem, 0.32fr) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__tree dd,
|
||||||
|
.laboratory-evidence-report__value,
|
||||||
|
.laboratory-evidence-report__array {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.59rem;
|
||||||
|
line-height: 1.48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__array {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__missing {
|
||||||
|
color: rgb(var(--nodedc-warning-rgb));
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list article {
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgb(255 255 255 / 0.028);
|
||||||
|
padding: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 0.34rem 0 !important;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list dl > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem minmax(0, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__artifact-list dd {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-family: var(--nodedc-font-family-mono, monospace);
|
||||||
|
font-size: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__canonical-json pre {
|
||||||
|
max-height: 34rem;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
background: rgb(0 0 0 / 0.24);
|
||||||
|
padding: 0.8rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-family: var(--nodedc-font-family-mono, monospace);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
background: rgb(var(--nodedc-warning-rgb) / 0.08);
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__notice strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__notice p {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--nodedc-text-secondary);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.laboratory-evidence-report__completeness {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.laboratory-evidence-report__header,
|
||||||
|
.laboratory-evidence-report__integrity > header {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__grid,
|
||||||
|
.laboratory-evidence-report__artifact-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.laboratory-evidence-report__identity,
|
||||||
|
.laboratory-evidence-report__completeness {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ export interface LaboratoryAnnotationAction {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LaboratoryViewAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkspaceRendererProps {
|
export interface WorkspaceRendererProps {
|
||||||
definition: WorkspaceDefinition;
|
definition: WorkspaceDefinition;
|
||||||
state: MissionRuntimeState | null;
|
state: MissionRuntimeState | null;
|
||||||
@@ -65,4 +70,5 @@ export interface WorkspaceRendererProps {
|
|||||||
onLaboratoryAnnotationActionChange: (
|
onLaboratoryAnnotationActionChange: (
|
||||||
action: LaboratoryAnnotationAction | null,
|
action: LaboratoryAnnotationAction | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
onLaboratoryViewActionChange: (action: LaboratoryViewAction | null) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import {
|
|||||||
LaboratorySelector,
|
LaboratorySelector,
|
||||||
LaboratorySummary,
|
LaboratorySummary,
|
||||||
LaboratoryWorkTemplate,
|
LaboratoryWorkTemplate,
|
||||||
type LaboratoryMethod,
|
|
||||||
type LaboratoryMethodComponent,
|
|
||||||
} from "../../components/laboratory/LaboratoryPresentation";
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||||
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
import { useObservationSessions } from "../../core/observation/useObservationSessions";
|
||||||
@@ -26,9 +24,7 @@ import {
|
|||||||
fetchE30ReviewCatalog,
|
fetchE30ReviewCatalog,
|
||||||
type E30ReviewResult,
|
type E30ReviewResult,
|
||||||
} from "../../core/laboratory/e30Review";
|
} from "../../core/laboratory/e30Review";
|
||||||
import {
|
import { type AdvancedLaboratoryResults } from "../../core/laboratory/advancedResults";
|
||||||
type AdvancedLaboratoryResults,
|
|
||||||
} from "../../core/laboratory/advancedResults";
|
|
||||||
import {
|
import {
|
||||||
fetchLidarLocalSurfaces,
|
fetchLidarLocalSurfaces,
|
||||||
type LidarLocalSurfaceModel,
|
type LidarLocalSurfaceModel,
|
||||||
@@ -42,11 +38,15 @@ import {
|
|||||||
advancedLaboratorySourceSession,
|
advancedLaboratorySourceSession,
|
||||||
isAdvancedLaboratoryWorkId,
|
isAdvancedLaboratoryWorkId,
|
||||||
} from "./AdvancedLaboratoryResult";
|
} from "./AdvancedLaboratoryResult";
|
||||||
|
import { LaboratoryEvidenceReportView } from "./LaboratoryEvidenceReportView";
|
||||||
import {
|
import {
|
||||||
e28LaboratoryBrief, e29LaboratoryBrief,
|
e28LaboratoryBrief, e29LaboratoryBrief,
|
||||||
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
e30LaboratoryBrief, PUBLISHED_LABORATORY_BRIEF,
|
||||||
} from "./laboratoryArchiveBriefs";
|
} from "./laboratoryArchiveBriefs";
|
||||||
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
import { useAdvancedLaboratoryCatalog } from "./useAdvancedLaboratoryCatalog";
|
||||||
|
import { useLaboratoryValueReviewIndex } from "./useLaboratoryValueReviewIndex";
|
||||||
|
import { useLaboratoryEvidenceReport } from "./useLaboratoryEvidenceReport";
|
||||||
|
import { useLaboratoryViewMode } from "./useLaboratoryViewMode";
|
||||||
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
import { useL34AnnotationCapability } from "./annotation/useL34AnnotationCapability";
|
||||||
import {
|
import {
|
||||||
buildLaboratoryCatalog,
|
buildLaboratoryCatalog,
|
||||||
@@ -54,6 +54,16 @@ import {
|
|||||||
experimentOptionsForProfile,
|
experimentOptionsForProfile,
|
||||||
workOptionsForExperiment,
|
workOptionsForExperiment,
|
||||||
} from "./laboratoryArchiveProfiles";
|
} from "./laboratoryArchiveProfiles";
|
||||||
|
import {
|
||||||
|
experimentOptionsWithSignals,
|
||||||
|
profileOptionsWithSignals,
|
||||||
|
projectLaboratoryValueReviews,
|
||||||
|
workOptionsWithSignals,
|
||||||
|
} from "./laboratoryValueReviewProjection";
|
||||||
|
import {
|
||||||
|
digestFromContentId,
|
||||||
|
publishedLaboratoryMethod,
|
||||||
|
} from "./publishedLaboratoryMethod";
|
||||||
import type {
|
import type {
|
||||||
LaboratoryCatalogSeed,
|
LaboratoryCatalogSeed,
|
||||||
LaboratoryExperimentId,
|
LaboratoryExperimentId,
|
||||||
@@ -64,98 +74,6 @@ type LaboratoryWorkspaceProps = WorkspaceRendererProps & {
|
|||||||
SpatialView: ComponentType<WorkspaceRendererProps>;
|
SpatialView: ComponentType<WorkspaceRendererProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
function formatSeconds(value: number): string {
|
||||||
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
return `${value.toLocaleString("ru-RU", { maximumFractionDigits: 3 })} с`;
|
||||||
}
|
}
|
||||||
@@ -550,6 +468,10 @@ function PublishedLaboratoryResult({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||||
|
const [viewMode] = useLaboratoryViewMode(
|
||||||
|
props.onLaboratoryViewActionChange,
|
||||||
|
);
|
||||||
|
const laboratoryValueReview = useLaboratoryValueReviewIndex();
|
||||||
const [profileId, setProfileId] = useState<LaboratoryProfileId>(
|
const [profileId, setProfileId] = useState<LaboratoryProfileId>(
|
||||||
"rig-right-yolox-lidar-range-v1",
|
"rig-right-yolox-lidar-range-v1",
|
||||||
);
|
);
|
||||||
@@ -602,7 +524,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
const advancedResults: AdvancedLaboratoryResults = advanced.results;
|
||||||
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: workId, l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
const annotationWorkspace = useL34AnnotationCapability({ selectedWorkId: viewMode === "laboratory" ? workId : "", l34Result: advancedResults.l34, l34dResult: advancedResults.l34d, l34eResult: advancedResults.l34e, e46Result: advancedResults.e46, e46aResult: advancedResults.e46a, onActionChange: props.onLaboratoryAnnotationActionChange });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setEvidenceLoading(true);
|
setEvidenceLoading(true);
|
||||||
@@ -651,6 +573,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
items.push({
|
items.push({
|
||||||
id: "e28-local-surface",
|
id: "e28-local-surface",
|
||||||
createdAtUtc: e28Model.createdAtUtc ?? "",
|
createdAtUtc: e28Model.createdAtUtc ?? "",
|
||||||
|
evidenceId: e28Model.modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -660,12 +583,14 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
items.push({
|
items.push({
|
||||||
id: "e29-camera-geometry",
|
id: "e29-camera-geometry",
|
||||||
createdAtUtc: e29Result.createdAtUtc ?? "",
|
createdAtUtc: e29Result.createdAtUtc ?? "",
|
||||||
|
evidenceId: e29Result.resultId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
if (e30Result && sourceSessions.has(e30Result.sourceSessionId)) {
|
||||||
items.push({
|
items.push({
|
||||||
id: "e30-evidence-review",
|
id: "e30-evidence-review",
|
||||||
createdAtUtc: e30Result.createdAtUtc ?? "",
|
createdAtUtc: e30Result.createdAtUtc ?? "",
|
||||||
|
evidenceId: e30Result.resultId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items.filter(({ createdAtUtc }) => createdAtUtc.trim());
|
return items.filter(({ createdAtUtc }) => createdAtUtc.trim());
|
||||||
@@ -684,18 +609,33 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
}),
|
}),
|
||||||
[advanced.index, knownWorks, publishedWorks, rigLabel],
|
[advanced.index, knownWorks, publishedWorks, rigLabel],
|
||||||
);
|
);
|
||||||
|
const valueReviews = useMemo(() => projectLaboratoryValueReviews({
|
||||||
|
catalog,
|
||||||
|
index: laboratoryValueReview.index,
|
||||||
|
publishedWorks,
|
||||||
|
}), [catalog, laboratoryValueReview.index, publishedWorks]);
|
||||||
const profiles = useMemo(
|
const profiles = useMemo(
|
||||||
() => buildLaboratoryProfiles(catalog),
|
() => profileOptionsWithSignals(buildLaboratoryProfiles(catalog), catalog, valueReviews),
|
||||||
[catalog],
|
[catalog, valueReviews],
|
||||||
);
|
);
|
||||||
const experimentOptions = useMemo(
|
const experimentOptions = useMemo(
|
||||||
() => experimentOptionsForProfile(profileId, catalog),
|
() => experimentOptionsWithSignals(
|
||||||
[catalog, profileId],
|
experimentOptionsForProfile(profileId, catalog), profileId, catalog, valueReviews,
|
||||||
|
),
|
||||||
|
[catalog, profileId, valueReviews],
|
||||||
);
|
);
|
||||||
const workOptions = useMemo(
|
const workOptions = useMemo(
|
||||||
() => workOptionsForExperiment(profileId, experimentId, catalog),
|
() => workOptionsWithSignals(
|
||||||
[catalog, experimentId, profileId],
|
workOptionsForExperiment(profileId, experimentId, catalog), valueReviews,
|
||||||
|
),
|
||||||
|
[catalog, experimentId, profileId, valueReviews],
|
||||||
);
|
);
|
||||||
|
const selectedCatalog = catalog.find((entry) => entry.id === workId) ?? null;
|
||||||
|
const evidenceReport = useLaboratoryEvidenceReport({
|
||||||
|
workId,
|
||||||
|
resultId: selectedCatalog?.evidenceId ?? "",
|
||||||
|
enabled: viewMode === "report" && selectedCatalog !== null,
|
||||||
|
});
|
||||||
const selectedSessionId = workId.startsWith("session:")
|
const selectedSessionId = workId.startsWith("session:")
|
||||||
? workId.slice("session:".length)
|
? workId.slice("session:".length)
|
||||||
: null;
|
: null;
|
||||||
@@ -828,6 +768,17 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
|||||||
|| props.observationLayout.maximizedFloatingSourceId,
|
|| props.observationLayout.maximizedFloatingSourceId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (viewMode === "report" && selectedCatalog) {
|
||||||
|
return (
|
||||||
|
<LaboratoryEvidenceReportView
|
||||||
|
catalog={selectedCatalog}
|
||||||
|
report={evidenceReport.report}
|
||||||
|
loading={evidenceReport.loading}
|
||||||
|
error={evidenceReport.error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="lab-archive-workspace"
|
className="lab-archive-workspace"
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { Icon, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
JsonValue,
|
||||||
|
LaboratoryEvidenceReport,
|
||||||
|
} from "../../core/laboratory/evidenceReport";
|
||||||
|
import {
|
||||||
|
laboratoryTimestamp,
|
||||||
|
type LaboratoryCatalogEntry,
|
||||||
|
} from "./laboratoryArchiveProfiles";
|
||||||
|
|
||||||
|
const FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
acceptance: "Приёмка",
|
||||||
|
accepted: "Принято",
|
||||||
|
architecture: "Архитектура",
|
||||||
|
authority: "Полномочия",
|
||||||
|
byte_length: "Размер",
|
||||||
|
calibration_model: "Модель калибровки",
|
||||||
|
calibration_sha256: "SHA калибровки",
|
||||||
|
camera_source_id: "Камера",
|
||||||
|
checks: "Проверки",
|
||||||
|
commands_enabled: "Команды разрешены",
|
||||||
|
completeness: "Полнота",
|
||||||
|
config_sha256: "SHA конфига",
|
||||||
|
container_image: "Образ контейнера",
|
||||||
|
core_capacity_fps: "Вычислительная ёмкость, FPS",
|
||||||
|
core_path_p95_ms: "Core path p95, мс",
|
||||||
|
created_at_utc: "Создано UTC",
|
||||||
|
decision: "Решение",
|
||||||
|
detector: "Детектор",
|
||||||
|
execution_class: "Класс исполнения",
|
||||||
|
frame_count: "Кадры",
|
||||||
|
failed_frame_count: "Ошибки кадров",
|
||||||
|
gpu_memory_used_mib: "GPU memory, MiB",
|
||||||
|
gpu_name: "GPU",
|
||||||
|
gpu_power_watts: "GPU power, W",
|
||||||
|
gpu_temperature_celsius: "GPU temperature, °C",
|
||||||
|
gpu_utilization_percent: "GPU utilization, %",
|
||||||
|
ground_truth: "Ground truth",
|
||||||
|
identity_sha256: "SHA identity",
|
||||||
|
limitations: "Ограничения",
|
||||||
|
metrics: "Метрики",
|
||||||
|
model_sha256: "SHA модели",
|
||||||
|
navigation_or_safety_accepted: "Допуск navigation/safety",
|
||||||
|
next_action: "Следующее действие",
|
||||||
|
pipeline_id: "Pipeline",
|
||||||
|
preprocessing_contract: "Preprocessing contract",
|
||||||
|
profile_sha256: "SHA профиля",
|
||||||
|
provider_promoted: "Provider promoted",
|
||||||
|
report_sha256: "SHA отчёта",
|
||||||
|
resolution: "Разрешение",
|
||||||
|
resources: "Ресурсы",
|
||||||
|
result_id: "Result identity",
|
||||||
|
runtime: "Runtime",
|
||||||
|
schema_version: "Версия схемы",
|
||||||
|
session_id: "Сессия",
|
||||||
|
source: "Источник",
|
||||||
|
configuration: "Конфигурация",
|
||||||
|
status: "Статус",
|
||||||
|
stream_sha256: "SHA потока",
|
||||||
|
worker_host: "Worker host",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPLETENESS_LABELS: Readonly<Record<string, string>> = {
|
||||||
|
identity: "Identity",
|
||||||
|
source: "Источник",
|
||||||
|
method: "Метод и модули",
|
||||||
|
execution: "Runtime / worker",
|
||||||
|
resources: "Нагрузка",
|
||||||
|
metrics: "Метрики",
|
||||||
|
gates: "Acceptance gates",
|
||||||
|
decision: "Решение",
|
||||||
|
limitations: "Ограничения",
|
||||||
|
authority: "Полномочия",
|
||||||
|
artifacts: "Артефакты",
|
||||||
|
visual_evidence: "Визуал",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldLabel(value: string): string {
|
||||||
|
return FIELD_LABELS[value] ?? value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function primitive(value: string | number | boolean | null): string {
|
||||||
|
if (value === null) return "Не зафиксировано";
|
||||||
|
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value.toLocaleString("ru-RU", { maximumFractionDigits: 6 });
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvidenceValue({ value, depth = 0 }: { value: JsonValue; depth?: number }) {
|
||||||
|
if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
|
||||||
|
return <span className="laboratory-evidence-report__value">{primitive(value as string | number | boolean | null)}</span>;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (!value.length) return <span className="laboratory-evidence-report__missing">Пустой список</span>;
|
||||||
|
return (
|
||||||
|
<ol className="laboratory-evidence-report__array">
|
||||||
|
{value.map((item, index) => (
|
||||||
|
<li key={index}><EvidenceValue value={item} depth={depth + 1} /></li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<dl className="laboratory-evidence-report__tree" data-depth={depth}>
|
||||||
|
{Object.entries(value).map(([key, item]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<dt>{fieldLabel(key)}</dt>
|
||||||
|
<dd><EvidenceValue value={item} depth={depth + 1} /></dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportSection({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
value: JsonValue | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="laboratory-evidence-report__section">
|
||||||
|
<header>
|
||||||
|
<span className="section-eyebrow">{eyebrow}</span>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</header>
|
||||||
|
{value === null || value === undefined ? (
|
||||||
|
<p className="laboratory-evidence-report__missing">
|
||||||
|
Не зафиксировано в immutable evidence этой лабораторной работы.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<EvidenceValue value={value} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingReport({ catalog }: { catalog: LaboratoryCatalogEntry }) {
|
||||||
|
return (
|
||||||
|
<div className="laboratory-result-pending" role="status">
|
||||||
|
<span className="busy-indicator" aria-hidden="true" />
|
||||||
|
<strong>Проверяем доказательства {catalog.variantName}</strong>
|
||||||
|
<p>Сверяем identity, manifest и SHA-256 каждого опубликованного артефакта.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LaboratoryEvidenceReportView({
|
||||||
|
catalog,
|
||||||
|
report,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
catalog: LaboratoryCatalogEntry;
|
||||||
|
report: LaboratoryEvidenceReport | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
if (loading) return <LoadingReport catalog={catalog} />;
|
||||||
|
if (!report) {
|
||||||
|
return (
|
||||||
|
<section className="laboratory-evidence-report laboratory-evidence-report--unavailable">
|
||||||
|
<header className="laboratory-evidence-report__header">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ОТЧЁТ ВЫБРАННОЙ LAB · EVIDENCE IDENTITY</span>
|
||||||
|
<h2>{catalog.variantName}</h2>
|
||||||
|
<p>{catalog.evidenceId}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone="warning">Неполный evidence contract</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<div className="laboratory-evidence-report__notice">
|
||||||
|
<Icon name="database" size={20} />
|
||||||
|
<div>
|
||||||
|
<strong>Канонический доказательный JSON не опубликован</strong>
|
||||||
|
<p>{error ?? "Для этой legacy LAB доступен визуал, но нет полного manifest/report контракта."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="laboratory-evidence-report__identity">
|
||||||
|
<div><dt>LAB</dt><dd>{catalog.id}</dd></div>
|
||||||
|
<div><dt>Evidence identity</dt><dd>{catalog.evidenceId}</dd></div>
|
||||||
|
<div><dt>Дата</dt><dd>{laboratoryTimestamp(catalog.createdAtUtc)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorded = Object.values(report.completeness).filter((value) => value === "recorded").length;
|
||||||
|
const total = Object.keys(report.completeness).length;
|
||||||
|
return (
|
||||||
|
<div className="laboratory-evidence-report">
|
||||||
|
<header className="laboratory-evidence-report__header">
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ОТЧЁТ ВЫБРАННОЙ LAB · IMMUTABLE EVIDENCE</span>
|
||||||
|
<h2>{catalog.variantName}</h2>
|
||||||
|
<p>{catalog.profileName} · {laboratoryTimestamp(catalog.createdAtUtc)}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={recorded === total ? "success" : "warning"}>
|
||||||
|
{recorded}/{total} доказательных разделов
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="laboratory-evidence-report__integrity">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<span className="section-eyebrow">ЦЕЛОСТНОСТЬ И ПРОИСХОЖДЕНИЕ</span>
|
||||||
|
<h3>Отчёт собран из проверенного manifest, а не из UI-копирайта</h3>
|
||||||
|
</div>
|
||||||
|
<StatusBadge tone={report.proof.artifactCount === report.proof.verifiedArtifactCount ? "success" : "warning"}>
|
||||||
|
SHA-256 {report.proof.verifiedArtifactCount}/{report.proof.artifactCount}
|
||||||
|
</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<dl className="laboratory-evidence-report__identity">
|
||||||
|
<div><dt>LAB work ID</dt><dd>{report.workId}</dd></div>
|
||||||
|
<div><dt>Result identity</dt><dd>{report.resultId}</dd></div>
|
||||||
|
<div><dt>Identity SHA-256</dt><dd>{report.proof.identitySha256}</dd></div>
|
||||||
|
<div><dt>Report SHA-256</dt><dd>{report.proof.reportSha256 ?? "Отдельный report artifact не зафиксирован"}</dd></div>
|
||||||
|
<div><dt>Manifest/document SHA-256</dt><dd>{report.proof.documentSha256}</dd></div>
|
||||||
|
<div><dt>Schema</dt><dd>{report.proof.reportSchemaVersion ?? report.proof.documentSchemaVersion}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<dl className="laboratory-evidence-report__completeness">
|
||||||
|
{Object.entries(report.completeness).map(([key, state]) => (
|
||||||
|
<div key={key} data-state={state}>
|
||||||
|
<dt>{COMPLETENESS_LABELS[key] ?? fieldLabel(key)}</dt>
|
||||||
|
<dd>{state === "recorded" ? "Зафиксировано" : "Не зафиксировано"}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="laboratory-evidence-report__grid">
|
||||||
|
<ReportSection eyebrow="SOURCE CONTRACT" title="Источник, калибровка и preprocessing" value={report.source} />
|
||||||
|
<ReportSection eyebrow="RUN CONFIGURATION" title="Профиль, параметры и пороги запуска" value={report.configuration} />
|
||||||
|
<ReportSection eyebrow="METHOD CONTRACT" title="Модули, модели, алгоритмы и их identity" value={report.method} />
|
||||||
|
<ReportSection eyebrow="EXECUTION" title="Worker, runtime и фактическое исполнение" value={report.execution} />
|
||||||
|
<ReportSection eyebrow="RESOURCE TELEMETRY" title="Нагрузка CPU / RAM / GPU" value={report.resources} />
|
||||||
|
<ReportSection eyebrow="MEASUREMENTS" title="Измеренные показатели" value={report.metrics} />
|
||||||
|
<ReportSection eyebrow="ACCEPTANCE" title="Пороги, проверки и результат gate" value={report.gates} />
|
||||||
|
<ReportSection eyebrow="DECISION" title="Решение, границы вывода и следующий шаг" value={report.decision} />
|
||||||
|
<ReportSection eyebrow="LIMITATIONS" title="Что эта LAB не доказывает" value={report.limitations} />
|
||||||
|
<ReportSection eyebrow="AUTHORITY" title="Сохранённые запреты и полномочия" value={report.authority} />
|
||||||
|
<ReportSection
|
||||||
|
eyebrow="VISUAL EVIDENCE"
|
||||||
|
title="Визуальная проверка и связанные артефакты"
|
||||||
|
value={report.completeness.visual_evidence === "recorded" ? report.visualEvidence : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="laboratory-evidence-report__section laboratory-evidence-report__artifacts">
|
||||||
|
<header>
|
||||||
|
<span className="section-eyebrow">VERIFIED ARTIFACTS</span>
|
||||||
|
<h3>Файлы доказательства, размер и полный SHA-256</h3>
|
||||||
|
</header>
|
||||||
|
{report.artifacts.length ? (
|
||||||
|
<div className="laboratory-evidence-report__artifact-list">
|
||||||
|
{report.artifacts.map((artifact) => (
|
||||||
|
<article key={artifact.path}>
|
||||||
|
<header>
|
||||||
|
<strong>{artifact.kind ?? "artifact"}</strong>
|
||||||
|
<StatusBadge tone="success">SHA verified</StatusBadge>
|
||||||
|
</header>
|
||||||
|
<p>{artifact.path}</p>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Размер</dt><dd>{artifact.byteLength.toLocaleString("ru-RU")} байт</dd></div>
|
||||||
|
<div><dt>SHA-256</dt><dd>{artifact.sha256}</dd></div>
|
||||||
|
<div><dt>Schema / media</dt><dd>{artifact.schemaVersion ?? artifact.mediaType ?? "Не размечено"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p className="laboratory-evidence-report__missing">Artifact manifest не зафиксирован.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="laboratory-evidence-report__section laboratory-evidence-report__canonical-json">
|
||||||
|
<header>
|
||||||
|
<span className="section-eyebrow">CANONICAL JSON · READ-ONLY</span>
|
||||||
|
<h3>Полный нормализованный evidence-report без потери исходных полей</h3>
|
||||||
|
</header>
|
||||||
|
<pre>{JSON.stringify(report.canonicalJson, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,11 +31,13 @@ export type LaboratoryWorkId =
|
|||||||
export interface LaboratoryCatalogSeed {
|
export interface LaboratoryCatalogSeed {
|
||||||
id: LaboratoryWorkId;
|
id: LaboratoryWorkId;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
|
evidenceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LaboratoryCatalogEntry {
|
export interface LaboratoryCatalogEntry {
|
||||||
id: LaboratoryWorkId;
|
id: LaboratoryWorkId;
|
||||||
createdAtUtc: string;
|
createdAtUtc: string;
|
||||||
|
evidenceId: string;
|
||||||
profileId: LaboratoryProfileId;
|
profileId: LaboratoryProfileId;
|
||||||
profileName: string;
|
profileName: string;
|
||||||
experimentId: LaboratoryExperimentId;
|
experimentId: LaboratoryExperimentId;
|
||||||
@@ -369,18 +371,23 @@ export function buildLaboratoryCatalog({
|
|||||||
advancedIndex: readonly AdvancedLaboratoryIndexItem[];
|
advancedIndex: readonly AdvancedLaboratoryIndexItem[];
|
||||||
publishedWorks: readonly ObservationSessionSummary[];
|
publishedWorks: readonly ObservationSessionSummary[];
|
||||||
}): readonly LaboratoryCatalogEntry[] {
|
}): readonly LaboratoryCatalogEntry[] {
|
||||||
const seeded = new Map<LaboratoryWorkId, string>();
|
const seeded = new Map<LaboratoryWorkId, { createdAtUtc: string; evidenceId: string }>();
|
||||||
for (const work of knownWorks) seeded.set(work.id, work.createdAtUtc);
|
for (const work of knownWorks) {
|
||||||
for (const work of advancedIndex) seeded.set(work.workId, work.createdAtUtc);
|
seeded.set(work.id, { createdAtUtc: work.createdAtUtc, evidenceId: work.evidenceId });
|
||||||
|
}
|
||||||
|
for (const work of advancedIndex) {
|
||||||
|
seeded.set(work.workId, { createdAtUtc: work.createdAtUtc, evidenceId: work.resultId });
|
||||||
|
}
|
||||||
|
|
||||||
const entries: LaboratoryCatalogEntry[] = [];
|
const entries: LaboratoryCatalogEntry[] = [];
|
||||||
for (const [id, createdAtUtc] of seeded) {
|
for (const [id, identity] of seeded) {
|
||||||
if (id.startsWith("session:")) continue;
|
if (id.startsWith("session:")) continue;
|
||||||
const definition = KNOWN_WORKS[id as Exclude<LaboratoryWorkId, `session:${string}`>];
|
const definition = KNOWN_WORKS[id as Exclude<LaboratoryWorkId, `session:${string}`>];
|
||||||
if (!definition) continue;
|
if (!definition) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
id,
|
id,
|
||||||
createdAtUtc,
|
createdAtUtc: identity.createdAtUtc,
|
||||||
|
evidenceId: identity.evidenceId,
|
||||||
profileId: definition.profileId,
|
profileId: definition.profileId,
|
||||||
profileName: definition.profileName(rigLabel),
|
profileName: definition.profileName(rigLabel),
|
||||||
experimentId: definition.experimentId,
|
experimentId: definition.experimentId,
|
||||||
@@ -396,6 +403,7 @@ export function buildLaboratoryCatalog({
|
|||||||
entries.push({
|
entries.push({
|
||||||
id: `session:${session.id}`,
|
id: `session:${session.id}`,
|
||||||
createdAtUtc: session.lab?.runCreatedAtUtc ?? session.startedAtUtc,
|
createdAtUtc: session.lab?.runCreatedAtUtc ?? session.startedAtUtc,
|
||||||
|
evidenceId: session.lab?.sourceResultId ?? session.lab?.resultId ?? session.id,
|
||||||
profileId,
|
profileId,
|
||||||
profileName: `${rig(rigLabel)} RIGHT · ${pipelineName}`,
|
profileName: `${rig(rigLabel)} RIGHT · ${pipelineName}`,
|
||||||
experimentId: `${profileId}:ravnoves00`,
|
experimentId: `${profileId}:ravnoves00`,
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { LaboratoryOption } from "../../components/laboratory/LaboratoryPresentation";
|
||||||
|
import type {
|
||||||
|
LaboratoryValueReviewEntry,
|
||||||
|
LaboratoryValueReviewIndex,
|
||||||
|
LaboratoryValueLifecycle,
|
||||||
|
LaboratoryValueSignal,
|
||||||
|
LaboratoryVisualEvidence,
|
||||||
|
} from "../../core/laboratory/valueReviewIndex";
|
||||||
|
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||||
|
import type {
|
||||||
|
LaboratoryCatalogEntry,
|
||||||
|
LaboratoryExperimentId,
|
||||||
|
LaboratoryProfileId,
|
||||||
|
LaboratoryWorkId,
|
||||||
|
} from "./laboratoryArchiveProfiles";
|
||||||
|
|
||||||
|
export type ProjectedLaboratorySignal = LaboratoryValueSignal | "unreviewed";
|
||||||
|
|
||||||
|
export interface ProjectedLaboratoryValueReview {
|
||||||
|
catalog: LaboratoryCatalogEntry;
|
||||||
|
signal: ProjectedLaboratorySignal;
|
||||||
|
lifecycle: LaboratoryValueLifecycle;
|
||||||
|
visualEvidence: LaboratoryVisualEvidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacySessionReview(
|
||||||
|
catalog: LaboratoryCatalogEntry,
|
||||||
|
session: ObservationSessionSummary,
|
||||||
|
): ProjectedLaboratoryValueReview {
|
||||||
|
const passed = session.lab?.provenance.benchmark_passed;
|
||||||
|
const signal: LaboratoryValueSignal = passed === true
|
||||||
|
? "progress"
|
||||||
|
: passed === false
|
||||||
|
? "failed"
|
||||||
|
: "retained";
|
||||||
|
return {
|
||||||
|
catalog,
|
||||||
|
signal,
|
||||||
|
lifecycle: "legacy",
|
||||||
|
visualEvidence: "available",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewedValue(
|
||||||
|
catalog: LaboratoryCatalogEntry,
|
||||||
|
review: LaboratoryValueReviewEntry,
|
||||||
|
): ProjectedLaboratoryValueReview {
|
||||||
|
return {
|
||||||
|
catalog,
|
||||||
|
signal: review.signal,
|
||||||
|
lifecycle: review.lifecycle,
|
||||||
|
visualEvidence: review.visualEvidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectLaboratoryValueReviews({
|
||||||
|
catalog,
|
||||||
|
index,
|
||||||
|
publishedWorks,
|
||||||
|
}: {
|
||||||
|
catalog: readonly LaboratoryCatalogEntry[];
|
||||||
|
index: LaboratoryValueReviewIndex | null;
|
||||||
|
publishedWorks: readonly ObservationSessionSummary[];
|
||||||
|
}): readonly ProjectedLaboratoryValueReview[] {
|
||||||
|
const reviewed = new Map(index?.items.map((item) => [item.catalogId, item]));
|
||||||
|
const sessions = new Map(publishedWorks.map((session) => [session.id, session]));
|
||||||
|
return catalog.map((entry) => {
|
||||||
|
const review = reviewed.get(entry.id);
|
||||||
|
if (review?.evidenceId === entry.evidenceId) return reviewedValue(entry, review);
|
||||||
|
if (entry.id.startsWith("session:")) {
|
||||||
|
const session = sessions.get(entry.id.slice("session:".length));
|
||||||
|
if (session) return legacySessionReview(entry, session);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
catalog: entry,
|
||||||
|
signal: "unreviewed",
|
||||||
|
lifecycle: "current",
|
||||||
|
visualEvidence: "partial",
|
||||||
|
} satisfies ProjectedLaboratoryValueReview;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMap(
|
||||||
|
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||||
|
): ReadonlyMap<LaboratoryWorkId, ProjectedLaboratorySignal> {
|
||||||
|
return new Map(reviews.map((review) => [review.catalog.id, review.signal]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileOptionsWithSignals(
|
||||||
|
options: readonly LaboratoryOption<LaboratoryProfileId>[],
|
||||||
|
catalog: readonly LaboratoryCatalogEntry[],
|
||||||
|
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||||
|
): readonly LaboratoryOption<LaboratoryProfileId>[] {
|
||||||
|
const signals = statusMap(reviews);
|
||||||
|
return options.map((option) => {
|
||||||
|
const latest = catalog.find((entry) => entry.profileId === option.id);
|
||||||
|
return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function experimentOptionsWithSignals(
|
||||||
|
options: readonly LaboratoryOption<LaboratoryExperimentId>[],
|
||||||
|
profileId: LaboratoryProfileId,
|
||||||
|
catalog: readonly LaboratoryCatalogEntry[],
|
||||||
|
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||||
|
): readonly LaboratoryOption<LaboratoryExperimentId>[] {
|
||||||
|
const signals = statusMap(reviews);
|
||||||
|
return options.map((option) => {
|
||||||
|
const latest = catalog.find((entry) => (
|
||||||
|
entry.profileId === profileId && entry.experimentId === option.id
|
||||||
|
));
|
||||||
|
return { ...option, status: latest ? signals.get(latest.id) ?? "unreviewed" : "unreviewed" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function workOptionsWithSignals(
|
||||||
|
options: readonly LaboratoryOption<LaboratoryWorkId>[],
|
||||||
|
reviews: readonly ProjectedLaboratoryValueReview[],
|
||||||
|
): readonly LaboratoryOption<LaboratoryWorkId>[] {
|
||||||
|
const signals = statusMap(reviews);
|
||||||
|
return options.map((option) => ({
|
||||||
|
...option,
|
||||||
|
status: signals.get(option.id) ?? "unreviewed",
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type {
|
||||||
|
LaboratoryMethod,
|
||||||
|
LaboratoryMethodComponent,
|
||||||
|
} from "../../components/laboratory/LaboratoryPresentation";
|
||||||
|
import type { ObservationSessionSummary } from "../../core/observation/sessionArchive";
|
||||||
|
|
||||||
|
export function digestFromContentId(value: string | null | undefined): string | null {
|
||||||
|
const digest = value?.split("-").at(-1) ?? "";
|
||||||
|
return /^[a-f0-9]{64}$/.test(digest) ? digest : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchLaboratoryEvidenceReport,
|
||||||
|
type LaboratoryEvidenceReport,
|
||||||
|
} from "../../core/laboratory/evidenceReport";
|
||||||
|
|
||||||
|
export function useLaboratoryEvidenceReport({
|
||||||
|
workId,
|
||||||
|
resultId,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
workId: string;
|
||||||
|
resultId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}): {
|
||||||
|
report: LaboratoryEvidenceReport | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
const [report, setReport] = useState<LaboratoryEvidenceReport | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setReport(null);
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void fetchLaboratoryEvidenceReport({ workId, resultId, signal: controller.signal })
|
||||||
|
.then(setReport)
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(caught instanceof Error ? caught.message : "Evidence-report LAB недоступен.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [enabled, resultId, workId]);
|
||||||
|
|
||||||
|
return { report, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchLaboratoryValueReviewIndex,
|
||||||
|
type LaboratoryValueReviewIndex,
|
||||||
|
} from "../../core/laboratory/valueReviewIndex";
|
||||||
|
|
||||||
|
export function useLaboratoryValueReviewIndex(): {
|
||||||
|
index: LaboratoryValueReviewIndex | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
} {
|
||||||
|
const [index, setIndex] = useState<LaboratoryValueReviewIndex | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
void fetchLaboratoryValueReviewIndex({ signal: controller.signal })
|
||||||
|
.then(setIndex)
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setIndex(null);
|
||||||
|
setError(caught instanceof Error ? caught.message : "Value-review индекс LAB недоступен.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { index, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { LaboratoryViewAction } from "../contracts";
|
||||||
|
|
||||||
|
export type LaboratoryViewMode = "laboratory" | "report";
|
||||||
|
|
||||||
|
export function useLaboratoryViewMode(
|
||||||
|
onActionChange: (action: LaboratoryViewAction | null) => void,
|
||||||
|
): [LaboratoryViewMode, (next: LaboratoryViewMode) => void] {
|
||||||
|
const [mode, setMode] = useState<LaboratoryViewMode>("laboratory");
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setMode((current) => current === "laboratory" ? "report" : "laboratory");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActionChange({
|
||||||
|
label: mode === "laboratory" ? "Отчёт" : "Лабораторные контуры",
|
||||||
|
onClick: toggle,
|
||||||
|
});
|
||||||
|
return () => onActionChange(null);
|
||||||
|
}, [mode, onActionChange, toggle]);
|
||||||
|
|
||||||
|
return [mode, setMode];
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let fetchLaboratoryValueReviewIndex;
|
||||||
|
let fetchLaboratoryEvidenceReport;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = await createServer({ server: { middlewareMode: true }, appType: "custom" });
|
||||||
|
({ fetchLaboratoryValueReviewIndex } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/valueReviewIndex.ts",
|
||||||
|
));
|
||||||
|
({ fetchLaboratoryEvidenceReport } = await server.ssrLoadModule(
|
||||||
|
"/src/core/laboratory/evidenceReport.ts",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await server?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function payload(overrides = {}) {
|
||||||
|
return {
|
||||||
|
schema_version: "missioncore.laboratory-value-review-index/v1",
|
||||||
|
reviewed_at_utc: "2026-08-05T08:30:00Z",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
catalog_id: "e46j-raw-fisheye-realtime",
|
||||||
|
evidence_id: `e46j-raw-fisheye-realtime-${"a".repeat(64)}`,
|
||||||
|
signal: "progress",
|
||||||
|
lifecycle: "current",
|
||||||
|
visual_evidence: "available",
|
||||||
|
access: "read-only",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
access: "read-only",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("LAB value-review index preserves the reviewed evidence identity", async () => {
|
||||||
|
const index = await fetchLaboratoryValueReviewIndex({
|
||||||
|
fetcher: async () => new Response(JSON.stringify(payload()), { status: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(index.items[0].catalogId, "e46j-raw-fisheye-realtime");
|
||||||
|
assert.match(index.items[0].evidenceId, /^e46j-raw-fisheye-realtime-[a-f0-9]{64}$/);
|
||||||
|
assert.equal(index.items[0].signal, "progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LAB value-review index rejects extra fields instead of trusting presentation data", async () => {
|
||||||
|
const document = payload();
|
||||||
|
document.items[0] = { ...document.items[0], renderer: "local" };
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
fetchLaboratoryValueReviewIndex({
|
||||||
|
fetcher: async () => new Response(JSON.stringify(document), { status: 200 }),
|
||||||
|
}),
|
||||||
|
/состав полей/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selected LAB evidence report preserves proof, telemetry and canonical JSON", async () => {
|
||||||
|
const workId = "e46j-raw-fisheye-realtime";
|
||||||
|
const resultId = `e46j-raw-fisheye-realtime-${"b".repeat(64)}`;
|
||||||
|
const report = await fetchLaboratoryEvidenceReport({
|
||||||
|
workId,
|
||||||
|
resultId,
|
||||||
|
fetcher: async () => new Response(JSON.stringify({
|
||||||
|
schema_version: "missioncore.laboratory-evidence-report/v1",
|
||||||
|
work_id: workId,
|
||||||
|
result_id: resultId,
|
||||||
|
created_at_utc: "2026-08-04T19:37:51.841Z",
|
||||||
|
access: "read-only",
|
||||||
|
proof: {
|
||||||
|
document_schema_version: "missioncore.e46j-result/v1",
|
||||||
|
document_sha256: "c".repeat(64),
|
||||||
|
identity_sha256: "b".repeat(64),
|
||||||
|
report_schema_version: "missioncore.e46j-report/v1",
|
||||||
|
report_sha256: "d".repeat(64),
|
||||||
|
artifact_count: 1,
|
||||||
|
verified_artifact_count: 1,
|
||||||
|
},
|
||||||
|
completeness: {
|
||||||
|
identity: "recorded",
|
||||||
|
source: "recorded",
|
||||||
|
configuration: "recorded",
|
||||||
|
method: "recorded",
|
||||||
|
execution: "recorded",
|
||||||
|
resources: "recorded",
|
||||||
|
metrics: "recorded",
|
||||||
|
gates: "recorded",
|
||||||
|
decision: "recorded",
|
||||||
|
limitations: "recorded",
|
||||||
|
authority: "recorded",
|
||||||
|
artifacts: "recorded",
|
||||||
|
visual_evidence: "recorded",
|
||||||
|
},
|
||||||
|
identity: { profile_sha256: "e".repeat(64) },
|
||||||
|
source: { frame_count: 4489, stream_sha256: "f".repeat(64) },
|
||||||
|
configuration: { detector: { config_sha256: "8".repeat(64) } },
|
||||||
|
method: { components: [{ kind: "model", identity_sha256: "a".repeat(64) }] },
|
||||||
|
execution: { worker_host: "worker-006", gpu_name: "RTX 4090" },
|
||||||
|
resources: { gpu_utilization_percent: { p95: 49 } },
|
||||||
|
metrics: { core_capacity_fps: 47.84, core_path_p95_ms: 25.35 },
|
||||||
|
gates: { passed: true },
|
||||||
|
decision: { provider_promoted: false },
|
||||||
|
limitations: ["No temporal identity."],
|
||||||
|
authority: { commands_enabled: false },
|
||||||
|
artifacts: [{
|
||||||
|
kind: "visual-overlay-video",
|
||||||
|
path: "overlay.mp4",
|
||||||
|
byte_length: 150563706,
|
||||||
|
sha256: "9".repeat(64),
|
||||||
|
schema_version: null,
|
||||||
|
media_type: "video/mp4",
|
||||||
|
verified: true,
|
||||||
|
}],
|
||||||
|
visual_evidence: { review: { completed: true }, artifacts: [] },
|
||||||
|
raw_report: { schema_version: "missioncore.e46j-report/v1", metrics: { frame_count: 4489 } },
|
||||||
|
}), { status: 200 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(report.workId, workId);
|
||||||
|
assert.equal(report.resultId, resultId);
|
||||||
|
assert.equal(report.resources.gpu_utilization_percent.p95, 49);
|
||||||
|
assert.equal(report.artifacts[0].verified, true);
|
||||||
|
assert.equal(report.canonicalJson.raw_report.metrics.frame_count, 4489);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LAB report UI uses the panel header contract and canonical controls", async () => {
|
||||||
|
const root = new URL("../src/", import.meta.url);
|
||||||
|
const [workspace, header, report, presentation, styles] = await Promise.all([
|
||||||
|
readFile(new URL("workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", root), "utf8"),
|
||||||
|
readFile(new URL("components/laboratory/useLaboratoryAnnotationHeader.tsx", root), "utf8"),
|
||||||
|
readFile(new URL("workspaces/laboratory/LaboratoryEvidenceReportView.tsx", root), "utf8"),
|
||||||
|
readFile(new URL("components/laboratory/LaboratoryPresentation.tsx", root), "utf8"),
|
||||||
|
readFile(new URL("styles/laboratory-evidence-report.css", root), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(workspace, /useLaboratoryViewMode/);
|
||||||
|
assert.match(workspace, /<LaboratoryEvidenceReportView/);
|
||||||
|
assert.match(workspace, /selectedCatalog/);
|
||||||
|
assert.match(header, /Отчёт|viewAction\.label/);
|
||||||
|
assert.match(header, /<Button/);
|
||||||
|
assert.doesNotMatch(header, /size="compact"[^>]*viewAction/);
|
||||||
|
assert.match(report, /CANONICAL JSON/);
|
||||||
|
assert.match(report, /verifiedArtifactCount/);
|
||||||
|
assert.doesNotMatch(report, /Открыть LAB/);
|
||||||
|
assert.match(presentation, /className="laboratory-status-dot"/);
|
||||||
|
assert.doesNotMatch(presentation, /<Icon name="circle"/);
|
||||||
|
assert.match(styles, /background: currentcolor/);
|
||||||
|
assert.doesNotMatch(styles, /#[a-f0-9]{3,8}/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.laboratory-value-review-registry/v1",
|
||||||
|
"reviewed_at_utc": "2026-08-05T08:30:00Z",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"catalog_id": "e28-local-surface",
|
||||||
|
"evidence_id": "k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e29-camera-geometry",
|
||||||
|
"evidence_id": "e29-camera-geometry-421a9d930638bef12cd5eb10979a477917fa4a389e655ed95f73ba4bd62e13dc",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e30-evidence-review",
|
||||||
|
"evidence_id": "e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e31-source-binding",
|
||||||
|
"evidence_id": "e31-source-qualification-b2460a5eb143688c7eea6821b2277e13aea79868abe81d83f7e78548c119159a",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e32-track-geometry",
|
||||||
|
"evidence_id": "e32-track-geometry-a14ca0e7fb3850ca0dfa3c41634e1b490a2d58ab74d101afc6d6921fbdb0e6fd",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e33-worker-shadow",
|
||||||
|
"evidence_id": "e33-worker-shadow-05cc0bb264410fd49536df90e94067ac39731aff0322a8873700d40008a8bb3a",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e34-temporal-layer",
|
||||||
|
"evidence_id": "e34-temporal-occupied-8d9abb3f2cc072cfdbb16cc4e55798e05c35a0abe0b8f691096770e091573a73",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e35-degradation-recovery",
|
||||||
|
"evidence_id": "e35-degradation-recovery-82bdbd5c5bfde6d932737f077153c3a8472c993c343fcbe8a207c39bfa2a6288",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e37-ravnoves-acceptance",
|
||||||
|
"evidence_id": "e37-ravnoves-acceptance-01b1efd586f747341c712d82f0907b39436a6f91ae92b1dfae987eca05fd8344",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e38-perception-baseline",
|
||||||
|
"evidence_id": "e38-perception-baseline-a272f82988cd9a7e071fad94c3e9fb49daf804fdcca523f853445fd3113a62b1",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e39-perception-refinement",
|
||||||
|
"evidence_id": "e39-perception-refinement-2fd253940c9d9a2fd3b3237f3f0932f81a9f69741935d793771243d5779af464",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e40-perception-product-gate",
|
||||||
|
"evidence_id": "e40-perception-product-gate-e96eec9fd68c3ffaaee898d46285dd329191267200011680f084c75095b92e9a",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l3-pointpillars-visual-audit",
|
||||||
|
"evidence_id": "l3-pointpillars-visual-audit-35a5bfef788e40080cac1b8ca82ba65176f158326fa2fc4570b522c97d6a2f8d",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l31-pointpillars-ravnoves",
|
||||||
|
"evidence_id": "l31-pointpillars-ravnoves-80a9715f64ea397222fbcfd700803f9152009e3751caf87e2e9dc5ec6fc01b72",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l32-pointpillars-camera-review",
|
||||||
|
"evidence_id": "l32-pointpillars-camera-review-40eca128ea9525e8e8c22bd3e981e40d5b66d2869c2e92636ddbf707ae44127a",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l33-camera-first-detector-review",
|
||||||
|
"evidence_id": "l33-camera-first-detector-review-2458f6214a731c06bfb44c6e44d4a32ee10389359debdc412c321220a9cdc010",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "legacy",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46-detector-truth-island",
|
||||||
|
"evidence_id": "e46-detector-truth-island-d8ab2745679636dce374b720b562fce05d6d0a26be3eac88650224d7aa92267d",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46a-ai-engineering-preannotation",
|
||||||
|
"evidence_id": "e46a-ai-engineering-preannotation-37cab05e1168cd6004202b890f2c7a877ab02d036afd0891a11a4df61d4d26bf",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46b-temporal-motion",
|
||||||
|
"evidence_id": "e46b-temporal-motion-756a677b184e91ba98fef1c8480aff423190dba8c384937c38b2ad1c2098962d",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46c-full-replay-world-tracks",
|
||||||
|
"evidence_id": "e46c-full-replay-world-tracks-98ca4aeb9839082be64c1ce375ea773cff290e08cf085d8d24bb29e11d6fba8d",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46d-temporal-failure-audit",
|
||||||
|
"evidence_id": "e46d-temporal-failure-audit-593871e10d971fab885205239b849cbba54d6648fae9d0e9eca1510886b31107",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46e-ready-stack",
|
||||||
|
"evidence_id": "e46e-ready-stack-d51fd744a86b0effa8685c7aa86d14dfd1b12e97bc8d68d0f53f467237b976bf",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46f-dashcam-bakeoff",
|
||||||
|
"evidence_id": "e46f-dashcam-bakeoff-2b888a784ba06d4565d34a91fef58af1fc9090ed9dc298ad318010ff4da64507",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46g-rectified-detector-bakeoff",
|
||||||
|
"evidence_id": "e46g-rectified-detector-bakeoff-9c4eb44cbb61199db0967bd9048712a0964e2dbf587651c9e0c6d808967675c6",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46h-full-rectified-front-replay",
|
||||||
|
"evidence_id": "e46h-full-rectified-front-replay-43f9d97c06387ffa3b7aa656c48c3cb475be5c40de1211853ae8cb3f5ff2e28f",
|
||||||
|
"signal": "failed",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46i-grounding-dino-full-replay",
|
||||||
|
"evidence_id": "e46i-grounding-dino-full-replay-b3a6779db3e460625bd8510dc311f0ea8d2975c73e8216534d8f168187119a06",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "e46j-raw-fisheye-realtime",
|
||||||
|
"evidence_id": "e46j-raw-fisheye-realtime-7119ce4344438eaa0e748db65aa044e9f9f4a0a226e5eea037a7180d0bc7ace7",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34-right-yolox-truth-island-freeze",
|
||||||
|
"evidence_id": "l34-right-yolox-truth-island-freeze-5175a03144978b25130019da6d37bceb8c6ed6aa3d0d3a4d2df4483e1e27ae76",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34a-assisted-yolox-error-audit",
|
||||||
|
"evidence_id": "l34a-assisted-yolox-error-audit-c8870828634a90ad5a5b02e5ef63189b319e61e74264f3a0e7d74a1d28693a5d",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34b-nested-box-consolidation-shadow",
|
||||||
|
"evidence_id": "l34b-nested-box-consolidation-shadow-2de7fe44c919964fc5c63666bf8374916b301dbefc08c6daea442824f759eb5b",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34c-tile-seam-stitch-shadow",
|
||||||
|
"evidence_id": "l34c-tile-seam-stitch-shadow-3253b7661e11d0a688e1c6fa200fa6d0eafd9679a0c61c4b1200009c0629696a",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34d-cumulative-postprocessing-candidate",
|
||||||
|
"evidence_id": "l34d-cumulative-postprocessing-candidate-7b549cf2c949625f0c643889ae4a1b99953cf36ffae80697a561ee722392ee95",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34e-self-review-diagnostic",
|
||||||
|
"evidence_id": "l34e-self-review-diagnostic-71fb2882d84b71f891ac381d4e31570eebb42ffa3b3fe3802ed0b56cd3b8202a",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalog_id": "l34f-adjudicated-reference",
|
||||||
|
"evidence_id": "l34f-adjudicated-reference-fe2964ea46fab02fd750ef30302a2b12e4c822a2407ba1137022a3ae26e15e5f",
|
||||||
|
"signal": "retained",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ GUI for every LAB run. The product surface remains compact, readable,
|
|||||||
predictable, and based on the approved NODE.DC Design Guideline.
|
predictable, and based on the approved NODE.DC Design Guideline.
|
||||||
|
|
||||||
This document governs Control Station product UI, laboratory summaries,
|
This document governs Control Station product UI, laboratory summaries,
|
||||||
evidence viewers, and the boundary with complete engineering reports in Ops.
|
evidence viewers, selected-LAB evidence reports, and the boundary with
|
||||||
|
implementation history and architecture narratives in Ops.
|
||||||
It complements `docs/15_LABORATORY_RUN_CANON.md`, which governs publication and
|
It complements `docs/15_LABORATORY_RUN_CANON.md`, which governs publication and
|
||||||
provenance. New non-LAB surfaces and their placement are governed by
|
provenance. New non-LAB surfaces and their placement are governed by
|
||||||
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
`docs/19_PRODUCT_SURFACE_EXTENSION_PROTOCOL.md`.
|
||||||
@@ -95,6 +96,48 @@ to these components and must not reproduce their classes or DOM structure.
|
|||||||
Changing this anatomy is a template-version decision, not a LAB-specific
|
Changing this anatomy is a template-version decision, not a LAB-specific
|
||||||
layout edit.
|
layout edit.
|
||||||
|
|
||||||
|
### Selected-LAB evidence report mode
|
||||||
|
|
||||||
|
The LAB content window has two views of the same selected immutable run: the
|
||||||
|
individual LAB template and its complete evidence report. A canonical text `Button` in
|
||||||
|
`ApplicationPanel.headerTools` switches between `Отчёт` and `Лабораторные
|
||||||
|
контуры`; this is not a new root navigation item and it preserves the selected
|
||||||
|
LAB. The control uses the default canonical height so it aligns with the panel
|
||||||
|
actions.
|
||||||
|
|
||||||
|
`missioncore.laboratory-evidence-report/v1` is generated from the selected
|
||||||
|
run's verified document and artifact manifest. It must expose, without dropping
|
||||||
|
the raw report:
|
||||||
|
|
||||||
|
- exact work/result identity, schema versions, document/report SHA-256;
|
||||||
|
- source and preprocessing contract;
|
||||||
|
- run configuration, method, modules, models, algorithms, and their identities;
|
||||||
|
- actual worker/runtime and resource telemetry when recorded;
|
||||||
|
- measured metrics, thresholds, checks, decision, limitations, and retained authority;
|
||||||
|
- every verified artifact with role, byte length, SHA-256, and media/schema metadata;
|
||||||
|
- an explicit `recorded` or `not-recorded` completeness state for every section.
|
||||||
|
|
||||||
|
The UI must never fill a missing field from review copy or visual inference.
|
||||||
|
Artifact hash or identity failure closes the report rather than presenting a
|
||||||
|
partial success.
|
||||||
|
|
||||||
|
The separate value-review index is not rendered as the report. It binds an
|
||||||
|
operator classification to exact immutable evidence identity and carries two
|
||||||
|
independent dimensions:
|
||||||
|
|
||||||
|
- signal: `progress`, `retained`, or `failed` for the declared LAB question;
|
||||||
|
- lifecycle: `current` or `legacy` for code and architecture treatment.
|
||||||
|
|
||||||
|
Green means the bounded LAB question produced confirmed progress, yellow means
|
||||||
|
the evidence is retained for comparison, and gray means a gate failed or a new
|
||||||
|
identity is not yet reviewed. None of these colors grants production,
|
||||||
|
navigation, or safety authority. A new result identity never inherits the
|
||||||
|
classification of the previous result automatically.
|
||||||
|
|
||||||
|
The same signal is projected into catalog selectors with a small filled status
|
||||||
|
lamp using the established status colors. Useful legacy evidence remains readable while its experiment
|
||||||
|
implementation may be removed from the product core.
|
||||||
|
|
||||||
### Canonical summary content
|
### Canonical summary content
|
||||||
|
|
||||||
The summary must let an operator understand the evidence before opening the
|
The summary must let an operator understand the evidence before opening the
|
||||||
@@ -188,13 +231,15 @@ Do not render:
|
|||||||
- a separate layout because one LAB has a different algorithm.
|
- a separate layout because one LAB has a different algorithm.
|
||||||
|
|
||||||
If information is necessary only for development or governance, place it in
|
If information is necessary only for development or governance, place it in
|
||||||
Ops, a report, an ADR, a runbook, or developer tooling.
|
Ops, an ADR, a runbook, or developer tooling. Evidence needed to verify the
|
||||||
|
selected LAB belongs in its product evidence report.
|
||||||
|
|
||||||
## Ops engineering report
|
## Ops engineering report
|
||||||
|
|
||||||
The complete report for a LAB or architecture milestone lives in the Mission
|
The complete implementation history and architecture narrative for a LAB or
|
||||||
Core Ops project. Keep the issue description concise and place the report in
|
milestone lives in the Mission Core Ops project. It links the canonical product
|
||||||
titled structured blocks. Use this canonical order:
|
evidence report instead of retyping its metrics. Keep the issue description
|
||||||
|
concise and place the narrative in titled structured blocks. Use this order:
|
||||||
|
|
||||||
1. Objective and architecture stage.
|
1. Objective and architecture stage.
|
||||||
2. Decision question and hypothesis.
|
2. Decision question and hypothesis.
|
||||||
@@ -209,8 +254,10 @@ titled structured blocks. Use this canonical order:
|
|||||||
11. Next stage and authority that remains forbidden.
|
11. Next stage and authority that remains forbidden.
|
||||||
12. Acceptance checker with short verifiable items.
|
12. Acceptance checker with short verifiable items.
|
||||||
|
|
||||||
The product summary is a projection of this report, never a second independent
|
The product summary and selected-LAB evidence report are projections of
|
||||||
narrative.
|
immutable runtime evidence, never a second independent narrative. Ops adds
|
||||||
|
engineering context, ownership, implementation history, and future work; it is
|
||||||
|
not a substitute for source/runtime/metric/artifact proof.
|
||||||
|
|
||||||
## Review gate before A3
|
## Review gate before A3
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR 0037: Identity-bound laboratory value-review index
|
||||||
|
|
||||||
|
Date: 2026-08-05
|
||||||
|
Status: accepted and implemented; product presentation amended by ADR 0038
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Mission Core had immutable LAB artifacts and increasingly complete visual
|
||||||
|
result pages, but no reviewed cross-LAB classification. A chronological list
|
||||||
|
could not distinguish a useful negative result, a reusable architectural gain,
|
||||||
|
an intermediate comparison, and an unreviewed replacement result. Encoding
|
||||||
|
those distinctions in renderer branches would make the UI another source of
|
||||||
|
truth and would let stale conclusions survive a new run.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`config/laboratory-value-review.json` is a strict versioned review registry. Each
|
||||||
|
entry references a catalog id and the exact evidence identity it evaluates,
|
||||||
|
then records only:
|
||||||
|
|
||||||
|
- `progress`, `retained`, or `failed` for the bounded LAB question;
|
||||||
|
- `current` or `legacy` for architecture lifecycle;
|
||||||
|
- visual-evidence availability;
|
||||||
|
|
||||||
|
It intentionally contains no finding, decision, limitation, metric, or report
|
||||||
|
copy. Those facts belong to immutable evidence and ADR 0038.
|
||||||
|
|
||||||
|
The backend parses the file fail-closed and exposes a read-only value-review index.
|
||||||
|
The frontend joins it to the live evidence catalog only when `evidence_id`
|
||||||
|
matches. A replacement result therefore becomes `unreviewed`; it cannot inherit
|
||||||
|
a green or yellow state from the previous artifact. Historical published
|
||||||
|
sessions use their immutable provenance and explicit benchmark result as a
|
||||||
|
bounded legacy fallback.
|
||||||
|
|
||||||
|
The registry is a classification input for selector status lamps and lifecycle
|
||||||
|
review. It is not the engineering report and is not rendered by the `Отчёт`
|
||||||
|
toggle. ADR 0038 assigns that toggle to the selected immutable LAB evidence.
|
||||||
|
|
||||||
|
## Classification semantics
|
||||||
|
|
||||||
|
- `progress` means the experiment achieved a declared bounded objective or
|
||||||
|
established a reusable contract. It does not mean production acceptance.
|
||||||
|
- `retained` means the evidence remains useful for comparison, diagnosis, or a
|
||||||
|
later review even though it is not a promoted result.
|
||||||
|
- `failed` means an explicit gate or candidate objective did not pass. A failed
|
||||||
|
result may still be valuable regression evidence.
|
||||||
|
- `legacy` permits removal of experiment-specific implementation while keeping
|
||||||
|
immutable evidence, report metadata, and a compatible renderer/read model.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Value-review copy is reviewable configuration rather than JSX or inferred UI text.
|
||||||
|
- New identities fail visibly into an unreviewed state.
|
||||||
|
- Valuable legacy experiments no longer justify retaining their orchestration
|
||||||
|
code in Mission Core.
|
||||||
|
- The value-review index does not execute experiments and does not solve the future
|
||||||
|
provider/graph/run configuration contract.
|
||||||
|
- Full implementation history remains in Ops. Verifiable source, runtime,
|
||||||
|
telemetry, metrics, gates, artifacts, and hashes belong to the selected LAB
|
||||||
|
evidence report defined by ADR 0038.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR 0038: Selected immutable LAB evidence report
|
||||||
|
|
||||||
|
Date: 2026-08-05
|
||||||
|
Status: accepted and implemented
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The first `Отчёт` implementation rendered one cross-LAB list with three pieces
|
||||||
|
of reviewed prose per run. It did not answer the operator's actual question:
|
||||||
|
what exact source, configuration, modules, runtime, hardware load, measurements,
|
||||||
|
gates, artifacts, and limitations prove the currently selected LAB result.
|
||||||
|
Most of those facts already existed in immutable manifests and report artifacts,
|
||||||
|
but the UI discarded them.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`Отчёт` is the evidence report of the currently selected immutable LAB identity.
|
||||||
|
It never changes selection and never aggregates other runs.
|
||||||
|
|
||||||
|
The backend exposes
|
||||||
|
`GET /api/v1/laboratory/evidence-reports/{work_id}/{result_id}` using
|
||||||
|
`missioncore.laboratory-evidence-report/v1`. Before projection it verifies:
|
||||||
|
|
||||||
|
- the registered work ID, result ID pattern, document schema, and exact result ID;
|
||||||
|
- the canonical identity SHA-256 and its binding to the result ID;
|
||||||
|
- normalized artifact paths with no traversal or symlink;
|
||||||
|
- byte length and SHA-256 of every declared artifact;
|
||||||
|
- the selected JSON report and runtime artifact from those verified descriptors.
|
||||||
|
|
||||||
|
The response preserves the raw report and normalizes source, configuration,
|
||||||
|
method, execution, resource telemetry, metrics, gates, decision, limitations,
|
||||||
|
authority, artifacts, and visual evidence. Each section is marked `recorded` or
|
||||||
|
`not-recorded`. Absence is not inferred as success, failure, or not-applicable.
|
||||||
|
|
||||||
|
The value-review registry from ADR 0037 remains a separate identity-bound input
|
||||||
|
for filled selector status lamps. It cannot supply missing evidence fields.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The operator can audit one LAB without reading source code.
|
||||||
|
- A green review lamp cannot hide missing telemetry or artifacts.
|
||||||
|
- Older runs expose their actual gaps instead of receiving fabricated modern
|
||||||
|
fields.
|
||||||
|
- Artifact tampering closes the report with a verification error.
|
||||||
|
- New LAB implementations must publish the canonical evidence dimensions if
|
||||||
|
they want a complete report; UI copy cannot compensate for a weak payload.
|
||||||
|
- Ops remains the place for implementation history, ownership, and planned work,
|
||||||
|
while product evidence remains machine-verifiable and identity-bound.
|
||||||
@@ -6,10 +6,32 @@ from k1link.laboratory.evidence_registry import (
|
|||||||
LaboratoryEvidenceRegistry,
|
LaboratoryEvidenceRegistry,
|
||||||
LaboratoryRegistryError,
|
LaboratoryRegistryError,
|
||||||
)
|
)
|
||||||
|
from k1link.laboratory.evidence_report import (
|
||||||
|
LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||||
|
LaboratoryEvidenceReportError,
|
||||||
|
LaboratoryEvidenceReportNotFound,
|
||||||
|
LaboratoryEvidenceReportService,
|
||||||
|
)
|
||||||
|
from k1link.laboratory.value_review_registry import (
|
||||||
|
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||||
|
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA,
|
||||||
|
LaboratoryValueReviewEntry,
|
||||||
|
LaboratoryValueReviewRegistry,
|
||||||
|
LaboratoryValueReviewRegistryError,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA",
|
"LABORATORY_EVIDENCE_DEFINITION_SCHEMA",
|
||||||
|
"LABORATORY_EVIDENCE_REPORT_SCHEMA",
|
||||||
"LaboratoryEvidenceDefinition",
|
"LaboratoryEvidenceDefinition",
|
||||||
"LaboratoryEvidenceRegistry",
|
"LaboratoryEvidenceRegistry",
|
||||||
|
"LaboratoryEvidenceReportError",
|
||||||
|
"LaboratoryEvidenceReportNotFound",
|
||||||
|
"LaboratoryEvidenceReportService",
|
||||||
"LaboratoryRegistryError",
|
"LaboratoryRegistryError",
|
||||||
|
"LABORATORY_VALUE_REVIEW_INDEX_SCHEMA",
|
||||||
|
"LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA",
|
||||||
|
"LaboratoryValueReviewEntry",
|
||||||
|
"LaboratoryValueReviewRegistry",
|
||||||
|
"LaboratoryValueReviewRegistryError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from k1link.laboratory.evidence_registry import (
|
||||||
|
LaboratoryEvidenceDefinition,
|
||||||
|
LaboratoryEvidenceRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
LABORATORY_EVIDENCE_REPORT_SCHEMA: Final = "missioncore.laboratory-evidence-report/v1"
|
||||||
|
_DOCUMENT_MAX_BYTES: Final = 1024 * 1024
|
||||||
|
_ARTIFACT_LIMIT: Final = 256
|
||||||
|
_HASH_CHUNK_BYTES: Final = 1024 * 1024
|
||||||
|
|
||||||
|
RuntimeRootProvider = Callable[[], Path | None]
|
||||||
|
|
||||||
|
|
||||||
|
class LaboratoryEvidenceReportError(ValueError):
|
||||||
|
"""Raised when immutable LAB evidence cannot be verified or projected."""
|
||||||
|
|
||||||
|
|
||||||
|
class LaboratoryEvidenceReportNotFound(LaboratoryEvidenceReportError):
|
||||||
|
"""Raised when the requested evidence identity is not available."""
|
||||||
|
|
||||||
|
|
||||||
|
class LaboratoryEvidenceReportService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
registry: LaboratoryEvidenceRegistry,
|
||||||
|
runtime_root_provider: RuntimeRootProvider,
|
||||||
|
) -> None:
|
||||||
|
self._definitions = {
|
||||||
|
definition.work_id: definition for definition in registry.definitions
|
||||||
|
}
|
||||||
|
self._runtime_root_provider = runtime_root_provider
|
||||||
|
|
||||||
|
def read(self, work_id: str, result_id: str) -> dict[str, object]:
|
||||||
|
definition = self._definitions.get(work_id)
|
||||||
|
if definition is None or definition.result_id_pattern.fullmatch(result_id) is None:
|
||||||
|
raise LaboratoryEvidenceReportNotFound("LAB evidence identity is unknown")
|
||||||
|
result_root = self._result_root(definition, result_id)
|
||||||
|
document_path = _safe_file(result_root, definition.document_name)
|
||||||
|
document_bytes = _read_bounded(document_path, _DOCUMENT_MAX_BYTES, "LAB document")
|
||||||
|
document = _json_object(document_bytes, "LAB document")
|
||||||
|
_validate_document(document, definition, result_id)
|
||||||
|
|
||||||
|
identity = _object_or_none(document.get("identity"))
|
||||||
|
identity_sha256 = document.get("identity_sha256")
|
||||||
|
if identity is None or not isinstance(identity_sha256, str):
|
||||||
|
raise LaboratoryEvidenceReportError("LAB identity proof is missing")
|
||||||
|
actual_identity_sha256 = _canonical_sha256(identity)
|
||||||
|
if actual_identity_sha256 != identity_sha256 or not result_id.endswith(identity_sha256):
|
||||||
|
raise LaboratoryEvidenceReportError("LAB identity proof is invalid")
|
||||||
|
|
||||||
|
artifacts = _verified_artifacts(result_root, document.get("artifacts"))
|
||||||
|
report_descriptor = _report_descriptor(artifacts)
|
||||||
|
report = (
|
||||||
|
_read_json_artifact(result_root, report_descriptor, "LAB report")
|
||||||
|
if report_descriptor is not None
|
||||||
|
else document
|
||||||
|
)
|
||||||
|
runtime_descriptor = _runtime_descriptor(artifacts)
|
||||||
|
runtime = (
|
||||||
|
_read_json_artifact(result_root, runtime_descriptor, "LAB runtime")
|
||||||
|
if runtime_descriptor is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
source = _first_object(
|
||||||
|
report.get("source"),
|
||||||
|
identity.get("source"),
|
||||||
|
_nested(identity, "profile", "source"),
|
||||||
|
) or _source_projection(report, identity)
|
||||||
|
configuration = _configuration_projection(report, identity)
|
||||||
|
method = _first_object(
|
||||||
|
report.get("method"),
|
||||||
|
identity.get("method"),
|
||||||
|
identity.get("profile"),
|
||||||
|
)
|
||||||
|
execution = _first_object(
|
||||||
|
report.get("execution"),
|
||||||
|
runtime,
|
||||||
|
identity.get("execution"),
|
||||||
|
identity.get("worker"),
|
||||||
|
report.get("worker"),
|
||||||
|
)
|
||||||
|
metrics = _first_object(report.get("metrics"), _nested(runtime, "metrics"))
|
||||||
|
resources = _first_object(
|
||||||
|
_nested(report, "metrics", "resources"),
|
||||||
|
_nested(runtime, "metrics", "resources"),
|
||||||
|
_nested(runtime, "metrics", "gpu"),
|
||||||
|
)
|
||||||
|
gates = _first_object(
|
||||||
|
report.get("acceptance"),
|
||||||
|
report.get("quality_gate"),
|
||||||
|
report.get("acceptance_requirements"),
|
||||||
|
_nested(runtime, "acceptance"),
|
||||||
|
)
|
||||||
|
decision = _json_value_or_none(report.get("decision"))
|
||||||
|
limitations = _json_value_or_none(report.get("limitations"))
|
||||||
|
authority = _first_object(
|
||||||
|
report.get("authority"),
|
||||||
|
identity.get("authority"),
|
||||||
|
document.get("authority"),
|
||||||
|
)
|
||||||
|
visual_review = _first_object(
|
||||||
|
report.get("visual_review"),
|
||||||
|
report.get("visual_evidence"),
|
||||||
|
)
|
||||||
|
visual_artifacts = [
|
||||||
|
artifact
|
||||||
|
for artifact in artifacts
|
||||||
|
if _is_visual_artifact(artifact)
|
||||||
|
]
|
||||||
|
completeness_values: dict[str, object | None] = {
|
||||||
|
"identity": identity,
|
||||||
|
"source": source,
|
||||||
|
"configuration": configuration,
|
||||||
|
"method": method,
|
||||||
|
"execution": execution,
|
||||||
|
"resources": resources,
|
||||||
|
"metrics": metrics,
|
||||||
|
"gates": gates,
|
||||||
|
"decision": decision,
|
||||||
|
"limitations": limitations,
|
||||||
|
"authority": authority,
|
||||||
|
"artifacts": artifacts or None,
|
||||||
|
"visual_evidence": visual_review or (visual_artifacts or None),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_EVIDENCE_REPORT_SCHEMA,
|
||||||
|
"work_id": work_id,
|
||||||
|
"result_id": result_id,
|
||||||
|
"created_at_utc": _optional_text(document.get("created_at_utc")),
|
||||||
|
"access": "read-only",
|
||||||
|
"proof": {
|
||||||
|
"document_schema_version": document.get("schema_version"),
|
||||||
|
"document_sha256": hashlib.sha256(document_bytes).hexdigest(),
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"report_schema_version": report.get("schema_version"),
|
||||||
|
"report_sha256": (
|
||||||
|
report_descriptor["sha256"] if report_descriptor is not None else None
|
||||||
|
),
|
||||||
|
"artifact_count": len(artifacts),
|
||||||
|
"verified_artifact_count": len(artifacts),
|
||||||
|
},
|
||||||
|
"completeness": {
|
||||||
|
key: "recorded" if value is not None else "not-recorded"
|
||||||
|
for key, value in completeness_values.items()
|
||||||
|
},
|
||||||
|
"identity": identity,
|
||||||
|
"source": source,
|
||||||
|
"configuration": configuration,
|
||||||
|
"method": method,
|
||||||
|
"execution": execution,
|
||||||
|
"resources": resources,
|
||||||
|
"metrics": metrics,
|
||||||
|
"gates": gates,
|
||||||
|
"decision": decision,
|
||||||
|
"limitations": limitations,
|
||||||
|
"authority": authority,
|
||||||
|
"artifacts": artifacts,
|
||||||
|
"visual_evidence": {
|
||||||
|
"review": visual_review,
|
||||||
|
"artifacts": visual_artifacts,
|
||||||
|
},
|
||||||
|
"raw_report": report,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _result_root(
|
||||||
|
self,
|
||||||
|
definition: LaboratoryEvidenceDefinition,
|
||||||
|
result_id: str,
|
||||||
|
) -> Path:
|
||||||
|
configured = self._runtime_root_provider()
|
||||||
|
if configured is None:
|
||||||
|
raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable")
|
||||||
|
runtime_root = configured.expanduser().absolute()
|
||||||
|
if runtime_root.is_symlink():
|
||||||
|
raise LaboratoryEvidenceReportError("LAB runtime root must not be a symlink")
|
||||||
|
try:
|
||||||
|
runtime_root = runtime_root.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LaboratoryEvidenceReportNotFound("LAB runtime root is unavailable") from exc
|
||||||
|
candidate = definition.result_root(runtime_root) / result_id
|
||||||
|
if candidate.is_symlink():
|
||||||
|
raise LaboratoryEvidenceReportError("LAB result must not be a symlink")
|
||||||
|
try:
|
||||||
|
result_root = candidate.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LaboratoryEvidenceReportNotFound("LAB evidence result is unavailable") from exc
|
||||||
|
if not result_root.is_dir() or not result_root.is_relative_to(runtime_root):
|
||||||
|
raise LaboratoryEvidenceReportError("LAB evidence result path is invalid")
|
||||||
|
return result_root
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_document(
|
||||||
|
document: dict[str, Any],
|
||||||
|
definition: LaboratoryEvidenceDefinition,
|
||||||
|
result_id: str,
|
||||||
|
) -> None:
|
||||||
|
if document.get("schema_version") != definition.result_schema_version:
|
||||||
|
raise LaboratoryEvidenceReportError("LAB document schema is invalid")
|
||||||
|
if document.get("result_id") != result_id:
|
||||||
|
raise LaboratoryEvidenceReportError("LAB result identity is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _verified_artifacts(result_root: Path, value: object) -> list[dict[str, object]]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list) or len(value) > _ARTIFACT_LIMIT:
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact manifest is invalid")
|
||||||
|
verified: list[dict[str, object]] = []
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
descriptor = _object_or_none(item)
|
||||||
|
if descriptor is None:
|
||||||
|
raise LaboratoryEvidenceReportError(f"LAB artifact {index} is invalid")
|
||||||
|
path_value = descriptor.get("path")
|
||||||
|
byte_length = descriptor.get("byte_length")
|
||||||
|
sha256 = descriptor.get("sha256")
|
||||||
|
if (
|
||||||
|
not isinstance(path_value, str)
|
||||||
|
or not isinstance(byte_length, int)
|
||||||
|
or isinstance(byte_length, bool)
|
||||||
|
or byte_length < 0
|
||||||
|
or not isinstance(sha256, str)
|
||||||
|
or len(sha256) != 64
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof is invalid")
|
||||||
|
path = _safe_file(result_root, path_value)
|
||||||
|
if path.stat().st_size != byte_length or _file_sha256(path) != sha256:
|
||||||
|
raise LaboratoryEvidenceReportError(f"LAB artifact {index} proof does not match")
|
||||||
|
verified.append(
|
||||||
|
{
|
||||||
|
"kind": _optional_text(descriptor.get("role") or descriptor.get("kind")),
|
||||||
|
"path": path_value,
|
||||||
|
"byte_length": byte_length,
|
||||||
|
"sha256": sha256,
|
||||||
|
"schema_version": _optional_text(descriptor.get("schema_version")),
|
||||||
|
"media_type": _optional_text(descriptor.get("media_type")),
|
||||||
|
"verified": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return verified
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_file(root: Path, relative: str) -> Path:
|
||||||
|
if not isinstance(relative, str) or "\\" in relative:
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact path is invalid")
|
||||||
|
posix = PurePosixPath(relative)
|
||||||
|
if (
|
||||||
|
posix.is_absolute()
|
||||||
|
or not posix.parts
|
||||||
|
or str(posix) != relative
|
||||||
|
or any(part in {"", ".", ".."} for part in posix.parts)
|
||||||
|
):
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact path is invalid")
|
||||||
|
candidate = root.joinpath(*posix.parts)
|
||||||
|
current = root
|
||||||
|
for part in posix.parts:
|
||||||
|
current = current / part
|
||||||
|
if current.is_symlink():
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact must not use symlinks")
|
||||||
|
try:
|
||||||
|
resolved = candidate.resolve(strict=True)
|
||||||
|
except OSError as exc:
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact is missing") from exc
|
||||||
|
if not resolved.is_file() or not resolved.is_relative_to(root):
|
||||||
|
raise LaboratoryEvidenceReportError("LAB artifact path escaped its result")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounded(path: Path, maximum: int, label: str) -> bytes:
|
||||||
|
if path.stat().st_size > maximum:
|
||||||
|
raise LaboratoryEvidenceReportError(f"{label} is too large")
|
||||||
|
try:
|
||||||
|
return path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
raise LaboratoryEvidenceReportError(f"{label} is unreadable") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _json_object(value: bytes, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
document = json.loads(value)
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise LaboratoryEvidenceReportError(f"{label} is invalid JSON") from exc
|
||||||
|
if not isinstance(document, dict) or not all(isinstance(key, str) for key in document):
|
||||||
|
raise LaboratoryEvidenceReportError(f"{label} must be an object")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json_artifact(
|
||||||
|
root: Path,
|
||||||
|
descriptor: dict[str, object],
|
||||||
|
label: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
path_value = descriptor["path"]
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
raise LaboratoryEvidenceReportError(f"{label} path is invalid")
|
||||||
|
encoded = _read_bounded(
|
||||||
|
_safe_file(root, path_value),
|
||||||
|
_DOCUMENT_MAX_BYTES,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
return _json_object(encoded, label)
|
||||||
|
|
||||||
|
|
||||||
|
def _report_descriptor(
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
artifact
|
||||||
|
for artifact in artifacts
|
||||||
|
if "report" in str(artifact.get("kind") or "").lower()
|
||||||
|
and str(artifact.get("path") or "").lower().endswith(".json")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_descriptor(
|
||||||
|
artifacts: list[dict[str, object]],
|
||||||
|
) -> dict[str, object] | None:
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
artifact
|
||||||
|
for artifact in artifacts
|
||||||
|
if "runtime" in str(artifact.get("kind") or "").lower()
|
||||||
|
and str(artifact.get("path") or "").lower().endswith(".json")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_visual_artifact(artifact: dict[str, object]) -> bool:
|
||||||
|
kind = str(artifact.get("kind") or "").lower()
|
||||||
|
media_type = str(artifact.get("media_type") or "").lower()
|
||||||
|
suffix = Path(str(artifact.get("path") or "")).suffix.lower()
|
||||||
|
return (
|
||||||
|
any(token in kind for token in ("visual", "video", "overlay", "contact-sheet", "image"))
|
||||||
|
or media_type.startswith(("image/", "video/"))
|
||||||
|
or suffix in {".png", ".jpg", ".jpeg", ".webp", ".mp4", ".webm"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sha256(value: object) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _file_sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _object_or_none(value: object) -> dict[str, Any] | None:
|
||||||
|
if isinstance(value, dict) and all(isinstance(key, str) for key in value):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_object(*values: object) -> dict[str, Any] | None:
|
||||||
|
for value in values:
|
||||||
|
document = _object_or_none(value)
|
||||||
|
if document is not None:
|
||||||
|
return document
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _nested(value: object, *keys: str) -> object:
|
||||||
|
current = value
|
||||||
|
for key in keys:
|
||||||
|
document = _object_or_none(current)
|
||||||
|
if document is None:
|
||||||
|
return None
|
||||||
|
current = document.get(key)
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _json_value_or_none(value: object) -> object | None:
|
||||||
|
return value if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _source_projection(*documents: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
keys = {
|
||||||
|
"camera_source_id",
|
||||||
|
"frame_count",
|
||||||
|
"route_frame_count",
|
||||||
|
"session_id",
|
||||||
|
"source_display_name",
|
||||||
|
"source_id",
|
||||||
|
"source_result_id",
|
||||||
|
"source_session_id",
|
||||||
|
"source_sha256",
|
||||||
|
"stream_sha256",
|
||||||
|
"upstream",
|
||||||
|
}
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for document in documents:
|
||||||
|
for key, value in document.items():
|
||||||
|
if key in keys or key.endswith("_source"):
|
||||||
|
result.setdefault(key, value)
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_projection(
|
||||||
|
report: dict[str, Any],
|
||||||
|
identity: dict[str, Any],
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
keys = {
|
||||||
|
"analysis_profile",
|
||||||
|
"configuration",
|
||||||
|
"detection",
|
||||||
|
"detector",
|
||||||
|
"parameters",
|
||||||
|
"preprocessing",
|
||||||
|
"profile",
|
||||||
|
"valid_fov",
|
||||||
|
}
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for document in (report, identity):
|
||||||
|
for key, value in document.items():
|
||||||
|
if key in keys:
|
||||||
|
result.setdefault(key, value)
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and value.strip() else None
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, Literal, cast
|
||||||
|
|
||||||
|
LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA: Final = (
|
||||||
|
"missioncore.laboratory-value-review-registry/v1"
|
||||||
|
)
|
||||||
|
LABORATORY_VALUE_REVIEW_INDEX_SCHEMA: Final = "missioncore.laboratory-value-review-index/v1"
|
||||||
|
_REGISTRY_MAX_BYTES: Final = 128 * 1024
|
||||||
|
_CATALOG_ID = re.compile(r"^(?:[a-z][a-z0-9-]{2,95}|session:[A-Za-z0-9._:-]{3,128})$")
|
||||||
|
_EVIDENCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,191}$")
|
||||||
|
_ROOT_KEYS: Final = frozenset({"schema_version", "reviewed_at_utc", "entries"})
|
||||||
|
_ENTRY_KEYS: Final = frozenset(
|
||||||
|
{"catalog_id", "evidence_id", "signal", "lifecycle", "visual_evidence"}
|
||||||
|
)
|
||||||
|
|
||||||
|
LaboratoryValueSignal = Literal["progress", "retained", "failed"]
|
||||||
|
LaboratoryValueLifecycle = Literal["current", "legacy"]
|
||||||
|
LaboratoryVisualEvidence = Literal["available", "partial", "missing"]
|
||||||
|
|
||||||
|
|
||||||
|
class LaboratoryValueReviewRegistryError(ValueError):
|
||||||
|
"""Raised when reviewed LAB value metadata is unsafe or ambiguous."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LaboratoryValueReviewEntry:
|
||||||
|
catalog_id: str
|
||||||
|
evidence_id: str
|
||||||
|
signal: LaboratoryValueSignal
|
||||||
|
lifecycle: LaboratoryValueLifecycle
|
||||||
|
visual_evidence: LaboratoryVisualEvidence
|
||||||
|
|
||||||
|
def as_payload(self) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"catalog_id": self.catalog_id,
|
||||||
|
"evidence_id": self.evidence_id,
|
||||||
|
"signal": self.signal,
|
||||||
|
"lifecycle": self.lifecycle,
|
||||||
|
"visual_evidence": self.visual_evidence,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LaboratoryValueReviewRegistry:
|
||||||
|
reviewed_at_utc: str
|
||||||
|
entries: tuple[LaboratoryValueReviewEntry, ...]
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
_text(self.reviewed_at_utc, "reviewed_at_utc", maximum=64)
|
||||||
|
if not isinstance(self.entries, tuple) or not all(
|
||||||
|
isinstance(entry, LaboratoryValueReviewEntry) for entry in self.entries
|
||||||
|
):
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
"LAB value-review entries must be an immutable tuple"
|
||||||
|
)
|
||||||
|
catalog_ids = [entry.catalog_id for entry in self.entries]
|
||||||
|
if len(catalog_ids) != len(set(catalog_ids)):
|
||||||
|
raise LaboratoryValueReviewRegistryError("duplicate LAB value-review catalog_id")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, path: Path) -> LaboratoryValueReviewRegistry:
|
||||||
|
candidate = path.expanduser().absolute()
|
||||||
|
if candidate.is_symlink() or not candidate.is_file():
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
"LAB value-review registry must be a regular file"
|
||||||
|
)
|
||||||
|
if candidate.stat().st_size > _REGISTRY_MAX_BYTES:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review registry is too large")
|
||||||
|
try:
|
||||||
|
payload: object = json.loads(candidate.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
"LAB value-review registry is unreadable"
|
||||||
|
) from exc
|
||||||
|
document = _object(payload, "LAB value-review registry")
|
||||||
|
_exact_keys(document, _ROOT_KEYS, "LAB value-review registry")
|
||||||
|
if document["schema_version"] != LABORATORY_VALUE_REVIEW_REGISTRY_SCHEMA:
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
"LAB value-review registry schema is invalid"
|
||||||
|
)
|
||||||
|
entries = document["entries"]
|
||||||
|
if not isinstance(entries, list) or len(entries) > 128:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review entries are invalid")
|
||||||
|
return cls(
|
||||||
|
reviewed_at_utc=_text(document["reviewed_at_utc"], "reviewed_at_utc", maximum=64),
|
||||||
|
entries=tuple(_entry(item, index) for index, item in enumerate(entries)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_payload(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": LABORATORY_VALUE_REVIEW_INDEX_SCHEMA,
|
||||||
|
"reviewed_at_utc": self.reviewed_at_utc,
|
||||||
|
"items": [entry.as_payload() for entry in self.entries],
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(value: object, index: int) -> LaboratoryValueReviewEntry:
|
||||||
|
document = _object(value, f"LAB value-review entry {index}")
|
||||||
|
_exact_keys(document, _ENTRY_KEYS, f"LAB value-review entry {index}")
|
||||||
|
catalog_id = _text(document["catalog_id"], "catalog_id", maximum=160)
|
||||||
|
evidence_id = _text(document["evidence_id"], "evidence_id", maximum=192)
|
||||||
|
if _CATALOG_ID.fullmatch(catalog_id) is None:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review catalog_id is invalid")
|
||||||
|
if _EVIDENCE_ID.fullmatch(evidence_id) is None:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review evidence_id is invalid")
|
||||||
|
signal = document["signal"]
|
||||||
|
lifecycle = document["lifecycle"]
|
||||||
|
visual_evidence = document["visual_evidence"]
|
||||||
|
if signal not in {"progress", "retained", "failed"}:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review signal is invalid")
|
||||||
|
if lifecycle not in {"current", "legacy"}:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review lifecycle is invalid")
|
||||||
|
if visual_evidence not in {"available", "partial", "missing"}:
|
||||||
|
raise LaboratoryValueReviewRegistryError("LAB value-review visual_evidence is invalid")
|
||||||
|
return LaboratoryValueReviewEntry(
|
||||||
|
catalog_id=catalog_id,
|
||||||
|
evidence_id=evidence_id,
|
||||||
|
signal=cast(LaboratoryValueSignal, signal),
|
||||||
|
lifecycle=cast(LaboratoryValueLifecycle, lifecycle),
|
||||||
|
visual_evidence=cast(LaboratoryVisualEvidence, visual_evidence),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: object, label: str) -> dict[str, object]:
|
||||||
|
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||||
|
raise LaboratoryValueReviewRegistryError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_keys(document: dict[str, object], expected: frozenset[str], label: str) -> None:
|
||||||
|
actual = frozenset(document)
|
||||||
|
if actual != expected:
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
f"{label} keys are invalid; missing={sorted(expected - actual)}, "
|
||||||
|
f"unexpected={sorted(actual - expected)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object, label: str, *, maximum: int = 768) -> str:
|
||||||
|
if (
|
||||||
|
not isinstance(value, str)
|
||||||
|
or not value.strip()
|
||||||
|
or value != value.strip()
|
||||||
|
or len(value) > maximum
|
||||||
|
):
|
||||||
|
raise LaboratoryValueReviewRegistryError(
|
||||||
|
f"{label} must be a bounded trimmed string"
|
||||||
|
)
|
||||||
|
return value
|
||||||
@@ -57,6 +57,7 @@ from k1link.compute.e40_perception_product_gate import (
|
|||||||
)
|
)
|
||||||
from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry
|
from k1link.laboratory import LaboratoryEvidenceDefinition, LaboratoryEvidenceRegistry
|
||||||
from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity
|
from k1link.web.l3_pointpillars_visual_api import latest_l3_visual_identity
|
||||||
|
from k1link.web.l31_pointpillars_ravnoves_api import latest_l31_identity
|
||||||
from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity
|
from k1link.web.l32_pointpillars_camera_review_api import latest_l32_identity
|
||||||
from k1link.web.l33_camera_first_detector_review_api import latest_l33_identity
|
from k1link.web.l33_camera_first_detector_review_api import latest_l33_identity
|
||||||
|
|
||||||
@@ -917,6 +918,15 @@ def build_advanced_laboratory_router(
|
|||||||
"access": "read-only",
|
"access": "read-only",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
l31_identity = latest_l31_identity(l31_ravnoves_root_provider)
|
||||||
|
if l31_identity is not None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"work_id": "l31-pointpillars-ravnoves",
|
||||||
|
**l31_identity,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
)
|
||||||
l32_identity = latest_l32_identity(l32_camera_review_root_provider)
|
l32_identity = latest_l32_identity(l32_camera_review_root_provider)
|
||||||
if l32_identity is not None:
|
if l32_identity is not None:
|
||||||
items.append(
|
items.append(
|
||||||
|
|||||||
+19
-1
@@ -23,7 +23,11 @@ from k1link.compute import (
|
|||||||
RecordedPerceptionOverlayMux,
|
RecordedPerceptionOverlayMux,
|
||||||
RecordedPerceptionOverlayStore,
|
RecordedPerceptionOverlayStore,
|
||||||
)
|
)
|
||||||
from k1link.laboratory import LaboratoryEvidenceRegistry
|
from k1link.laboratory import (
|
||||||
|
LaboratoryEvidenceRegistry,
|
||||||
|
LaboratoryEvidenceReportService,
|
||||||
|
LaboratoryValueReviewRegistry,
|
||||||
|
)
|
||||||
from k1link.sessions import (
|
from k1link.sessions import (
|
||||||
MaterializedRecording,
|
MaterializedRecording,
|
||||||
RecordedMediaInspector,
|
RecordedMediaInspector,
|
||||||
@@ -104,6 +108,7 @@ from k1link.web.l34e_self_review_diagnostic_api import (
|
|||||||
)
|
)
|
||||||
from k1link.web.l34f_adjudication_api import build_l34f_adjudication_router
|
from k1link.web.l34f_adjudication_api import build_l34f_adjudication_router
|
||||||
from k1link.web.laboratory_api import build_laboratory_router
|
from k1link.web.laboratory_api import build_laboratory_router
|
||||||
|
from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||||
from k1link.web.lidar_api import build_lidar_router
|
from k1link.web.lidar_api import build_lidar_router
|
||||||
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
from k1link.web.lidar_local_surface_service import K1LocalSurfaceReadService
|
||||||
from k1link.web.map_api import (
|
from k1link.web.map_api import (
|
||||||
@@ -137,6 +142,13 @@ INVALID_REQUEST_DETAIL = "Некорректные параметры запро
|
|||||||
LABORATORY_EVIDENCE_REGISTRY = LaboratoryEvidenceRegistry.from_directory(
|
LABORATORY_EVIDENCE_REGISTRY = LaboratoryEvidenceRegistry.from_directory(
|
||||||
REPOSITORY_ROOT / "config" / "laboratories"
|
REPOSITORY_ROOT / "config" / "laboratories"
|
||||||
)
|
)
|
||||||
|
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
|
||||||
|
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
|
||||||
|
)
|
||||||
|
LABORATORY_EVIDENCE_REPORTS = LaboratoryEvidenceReportService(
|
||||||
|
LABORATORY_EVIDENCE_REGISTRY,
|
||||||
|
lambda: REPOSITORY_ROOT / ".runtime" / "compute-experiments",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_media_tool(name: str) -> Path | None:
|
def _resolve_media_tool(name: str) -> Path | None:
|
||||||
@@ -559,6 +571,12 @@ app.include_router(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
build_laboratory_report_router(
|
||||||
|
LABORATORY_VALUE_REVIEW_REGISTRY,
|
||||||
|
LABORATORY_EVIDENCE_REPORTS,
|
||||||
|
)
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
build_advanced_laboratory_router(
|
build_advanced_laboratory_router(
|
||||||
evidence_registry=LABORATORY_EVIDENCE_REGISTRY,
|
evidence_registry=LABORATORY_EVIDENCE_REGISTRY,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from k1link.laboratory import (
|
||||||
|
LaboratoryEvidenceReportError,
|
||||||
|
LaboratoryEvidenceReportNotFound,
|
||||||
|
LaboratoryEvidenceReportService,
|
||||||
|
LaboratoryValueReviewRegistry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_laboratory_report_router(
|
||||||
|
registry: LaboratoryValueReviewRegistry,
|
||||||
|
evidence_reports: LaboratoryEvidenceReportService | None = None,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||||
|
|
||||||
|
@router.get("/value-review-index")
|
||||||
|
def get_value_review_index() -> dict[str, object]:
|
||||||
|
return registry.as_payload()
|
||||||
|
|
||||||
|
@router.get("/evidence-reports/{work_id}/{result_id}")
|
||||||
|
def get_evidence_report(work_id: str, result_id: str) -> dict[str, object]:
|
||||||
|
if evidence_reports is None:
|
||||||
|
raise HTTPException(status_code=404, detail="LAB evidence report is unavailable")
|
||||||
|
try:
|
||||||
|
return evidence_reports.read(work_id, result_id)
|
||||||
|
except LaboratoryEvidenceReportNotFound as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except LaboratoryEvidenceReportError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -74,6 +74,30 @@ def test_advanced_index_is_empty_when_not_configured() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_advanced_index_includes_valid_l31_identity(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
identity = {
|
||||||
|
"result_id": f"l31-pointpillars-ravnoves-{'a' * 64}",
|
||||||
|
"created_at_utc": "2026-07-31T10:56:45.861Z",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(advanced_api, "latest_l31_identity", lambda _provider: identity)
|
||||||
|
router = build_advanced_laboratory_router(
|
||||||
|
l31_ravnoves_root_provider=lambda: tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
index = _endpoint(router, "/api/v1/laboratory/advanced-index")()
|
||||||
|
|
||||||
|
assert index["items"] == [ # type: ignore[index]
|
||||||
|
{
|
||||||
|
"work_id": "l31-pointpillars-ravnoves",
|
||||||
|
**identity,
|
||||||
|
"access": "read-only",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_registry_index_does_not_follow_a_runtime_symlink(tmp_path: Path) -> None:
|
def test_registry_index_does_not_follow_a_runtime_symlink(tmp_path: Path) -> None:
|
||||||
actual = tmp_path / "actual"
|
actual = tmp_path / "actual"
|
||||||
actual.mkdir()
|
actual.mkdir()
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.laboratory import (
|
||||||
|
LaboratoryEvidenceRegistry,
|
||||||
|
LaboratoryEvidenceReportError,
|
||||||
|
LaboratoryEvidenceReportNotFound,
|
||||||
|
LaboratoryEvidenceReportService,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_sha256(value: object) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_json(path: Path, value: object) -> bytes:
|
||||||
|
encoded = json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
path.write_bytes(encoded)
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def _service(tmp_path: Path) -> tuple[LaboratoryEvidenceReportService, str, Path]:
|
||||||
|
definitions = tmp_path / "definitions"
|
||||||
|
definitions.mkdir()
|
||||||
|
_write_json(
|
||||||
|
definitions / "e99-proof.json",
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.laboratory-evidence-definition/v1",
|
||||||
|
"work_id": "e99-proof",
|
||||||
|
"evidence": {
|
||||||
|
"runtime_relative_root": "e99/results",
|
||||||
|
"result_id_prefix": "e99-proof",
|
||||||
|
"document_name": "manifest.json",
|
||||||
|
"schema_version": "missioncore.e99-result/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
runtime = tmp_path / "runtime"
|
||||||
|
results = runtime / "e99" / "results"
|
||||||
|
results.mkdir(parents=True)
|
||||||
|
source = {"session_id": "immutable-source", "stream_sha256": "a" * 64}
|
||||||
|
model = {"name": "detector", "sha256": "b" * 64}
|
||||||
|
identity = {
|
||||||
|
"schema_version": "missioncore.e99-result/v1",
|
||||||
|
"profile": {
|
||||||
|
"source": source,
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
|
"worker": {"node": "worker-006", "container": "ndc-worker@sha256:proof"},
|
||||||
|
"authority": {"commands_enabled": False, "navigation_or_safety_accepted": False},
|
||||||
|
}
|
||||||
|
identity_sha256 = _canonical_sha256(identity)
|
||||||
|
result_id = f"e99-proof-{identity_sha256}"
|
||||||
|
result_root = results / result_id
|
||||||
|
result_root.mkdir()
|
||||||
|
report = {
|
||||||
|
"schema_version": "missioncore.e99-report/v1",
|
||||||
|
"source": source,
|
||||||
|
"method": {"components": [model]},
|
||||||
|
"metrics": {
|
||||||
|
"frames": 100,
|
||||||
|
"resources": {"gpu_utilization_p95_percent": 48.0, "rss_p95_mib": 91.0},
|
||||||
|
},
|
||||||
|
"acceptance": {"passed": True, "checks": {"frame_coverage": True}},
|
||||||
|
"decision": {"promoted": False, "next_action": "temporal gate"},
|
||||||
|
"limitations": ["No independent ground truth."],
|
||||||
|
"authority": identity["authority"],
|
||||||
|
}
|
||||||
|
report_bytes = _write_json(result_root / "report.json", report)
|
||||||
|
visual_bytes = b"visual-proof"
|
||||||
|
(result_root / "visual.png").write_bytes(visual_bytes)
|
||||||
|
manifest = {
|
||||||
|
"schema_version": "missioncore.e99-result/v1",
|
||||||
|
"result_id": result_id,
|
||||||
|
"identity_sha256": identity_sha256,
|
||||||
|
"identity": identity,
|
||||||
|
"created_at_utc": "2026-08-05T09:00:00Z",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"role": "engineering-report",
|
||||||
|
"path": "report.json",
|
||||||
|
"byte_length": len(report_bytes),
|
||||||
|
"sha256": hashlib.sha256(report_bytes).hexdigest(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "visual-contact-sheet",
|
||||||
|
"path": "visual.png",
|
||||||
|
"byte_length": len(visual_bytes),
|
||||||
|
"sha256": hashlib.sha256(visual_bytes).hexdigest(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
_write_json(result_root / "manifest.json", manifest)
|
||||||
|
registry = LaboratoryEvidenceRegistry.from_directory(definitions)
|
||||||
|
return LaboratoryEvidenceReportService(registry, lambda: runtime), result_id, result_root
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_report_projects_verified_canonical_evidence(tmp_path: Path) -> None:
|
||||||
|
service, result_id, _ = _service(tmp_path)
|
||||||
|
|
||||||
|
report = service.read("e99-proof", result_id)
|
||||||
|
|
||||||
|
assert report["schema_version"] == "missioncore.laboratory-evidence-report/v1"
|
||||||
|
assert report["result_id"] == result_id
|
||||||
|
assert report["source"]["session_id"] == "immutable-source" # type: ignore[index]
|
||||||
|
assert report["configuration"]["profile"]["model"]["name"] == "detector" # type: ignore[index]
|
||||||
|
assert report["resources"]["gpu_utilization_p95_percent"] == 48.0 # type: ignore[index]
|
||||||
|
assert report["proof"]["verified_artifact_count"] == 2 # type: ignore[index]
|
||||||
|
assert report["completeness"]["visual_evidence"] == "recorded" # type: ignore[index]
|
||||||
|
assert report["completeness"]["execution"] == "recorded" # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_report_rejects_tampered_artifact(tmp_path: Path) -> None:
|
||||||
|
service, result_id, result_root = _service(tmp_path)
|
||||||
|
(result_root / "visual.png").write_bytes(b"tampered")
|
||||||
|
|
||||||
|
with pytest.raises(LaboratoryEvidenceReportError, match="does not match"):
|
||||||
|
service.read("e99-proof", result_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_report_rejects_unknown_identity(tmp_path: Path) -> None:
|
||||||
|
service, _, _ = _service(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(LaboratoryEvidenceReportNotFound, match="unavailable"):
|
||||||
|
service.read("e99-proof", f"e99-proof-{'f' * 64}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_report_marks_missing_fields_without_inventing_them(tmp_path: Path) -> None:
|
||||||
|
service, result_id, result_root = _service(tmp_path)
|
||||||
|
report_path = result_root / "report.json"
|
||||||
|
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||||
|
report.pop("metrics")
|
||||||
|
report.pop("limitations")
|
||||||
|
report_bytes = _write_json(report_path, report)
|
||||||
|
manifest_path = result_root / "manifest.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
manifest["artifacts"][0]["byte_length"] = len(report_bytes)
|
||||||
|
manifest["artifacts"][0]["sha256"] = hashlib.sha256(report_bytes).hexdigest()
|
||||||
|
_write_json(manifest_path, manifest)
|
||||||
|
|
||||||
|
evidence = service.read("e99-proof", result_id)
|
||||||
|
|
||||||
|
assert evidence["metrics"] is None
|
||||||
|
assert evidence["limitations"] is None
|
||||||
|
assert evidence["completeness"]["metrics"] == "not-recorded" # type: ignore[index]
|
||||||
|
assert evidence["completeness"]["limitations"] == "not-recorded" # type: ignore[index]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
|
||||||
|
from k1link.laboratory import (
|
||||||
|
LaboratoryValueReviewRegistry,
|
||||||
|
LaboratoryValueReviewRegistryError,
|
||||||
|
)
|
||||||
|
from k1link.web.laboratory_report_api import build_laboratory_report_router
|
||||||
|
|
||||||
|
|
||||||
|
def _document() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": "missioncore.laboratory-value-review-registry/v1",
|
||||||
|
"reviewed_at_utc": "2026-08-05T08:30:00Z",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"catalog_id": "e46j-raw-fisheye-realtime",
|
||||||
|
"evidence_id": f"e46j-raw-fisheye-realtime-{'a' * 64}",
|
||||||
|
"signal": "progress",
|
||||||
|
"lifecycle": "current",
|
||||||
|
"visual_evidence": "available",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, document: dict[str, object]) -> None:
|
||||||
|
path.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_review_registry_is_strict_and_projects_read_only_index(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "laboratory-value-review.json"
|
||||||
|
_write(path, _document())
|
||||||
|
|
||||||
|
registry = LaboratoryValueReviewRegistry.from_file(path)
|
||||||
|
router = build_laboratory_report_router(registry)
|
||||||
|
route = next(
|
||||||
|
route
|
||||||
|
for route in router.routes
|
||||||
|
if isinstance(route, APIRoute)
|
||||||
|
and route.path == "/api/v1/laboratory/value-review-index"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = route.endpoint()
|
||||||
|
assert payload["schema_version"] == "missioncore.laboratory-value-review-index/v1"
|
||||||
|
assert payload["items"][0]["signal"] == "progress"
|
||||||
|
assert payload["items"][0]["access"] == "read-only"
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_review_registry_rejects_unknown_keys(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "laboratory-value-review.json"
|
||||||
|
document = _document()
|
||||||
|
document["renderer"] = "local"
|
||||||
|
_write(path, document)
|
||||||
|
|
||||||
|
with pytest.raises(LaboratoryValueReviewRegistryError, match="unexpected"):
|
||||||
|
LaboratoryValueReviewRegistry.from_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_review_registry_rejects_duplicate_catalog_ids(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "laboratory-value-review.json"
|
||||||
|
document = _document()
|
||||||
|
entries = document["entries"]
|
||||||
|
assert isinstance(entries, list)
|
||||||
|
entries.append({**entries[0], "evidence_id": f"duplicate-{'b' * 64}"})
|
||||||
|
_write(path, document)
|
||||||
|
|
||||||
|
with pytest.raises(LaboratoryValueReviewRegistryError, match="duplicate"):
|
||||||
|
LaboratoryValueReviewRegistry.from_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_value_review_registry_covers_reviewed_laboratory_families() -> None:
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
registry = LaboratoryValueReviewRegistry.from_file(
|
||||||
|
root / "config" / "laboratory-value-review.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(registry.entries) == 34
|
||||||
|
assert {entry.catalog_id for entry in registry.entries} >= {
|
||||||
|
"e28-local-surface",
|
||||||
|
"e46d-temporal-failure-audit",
|
||||||
|
"e46j-raw-fisheye-realtime",
|
||||||
|
"l31-pointpillars-ravnoves",
|
||||||
|
"l34f-adjudicated-reference",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user