feat(lab): add lazy E40 evidence review
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final, Literal
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
|
||||
E40_OPERATOR_REVIEW_SCHEMA: Final = "missioncore.e40-operator-review/v1"
|
||||
E40_OPERATOR_REVIEW_PROTOCOL: Final = "sealed-error-adjudication/v1"
|
||||
|
||||
E40OperatorVerdict = Literal["confirmed-error", "rejected-error"]
|
||||
|
||||
_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_REVIEW_ID = re.compile(r"^e40-operator-review-[a-f0-9]{64}$")
|
||||
_EVENT_ID = re.compile(r"^e40-operator-verdict-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_REVIEWER_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$")
|
||||
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_VERDICTS: Final = {"confirmed-error", "rejected-error"}
|
||||
_MAX_JSON_BYTES: Final = 2 * 1024 * 1024
|
||||
|
||||
|
||||
class E40OperatorReviewError(RuntimeError):
|
||||
"""Base error for the E40 operator-review lifecycle."""
|
||||
|
||||
|
||||
class E40OperatorReviewConflictError(E40OperatorReviewError):
|
||||
"""The caller attempted to update a stale review revision."""
|
||||
|
||||
|
||||
class E40OperatorReviewValidationError(E40OperatorReviewError):
|
||||
"""The requested review operation violates the frozen protocol."""
|
||||
|
||||
|
||||
class E40OperatorReviewIntegrityError(E40OperatorReviewError):
|
||||
"""The stored review no longer matches its sealed E40 substrate."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E40OperatorReviewSubject:
|
||||
item_id: str
|
||||
sequence: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class E40OperatorReviewSubstrate:
|
||||
result_id: str
|
||||
materialization_id: str
|
||||
case_catalog_sha256: str
|
||||
subjects: tuple[E40OperatorReviewSubject, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
_RESULT_ID.fullmatch(self.result_id) is None
|
||||
or _MATERIALIZATION_ID.fullmatch(self.materialization_id) is None
|
||||
or _SHA256.fullmatch(self.case_catalog_sha256) is None
|
||||
or not self.subjects
|
||||
or len(self.subjects) > 64
|
||||
):
|
||||
raise E40OperatorReviewValidationError(
|
||||
"E40 operator-review source binding is invalid"
|
||||
)
|
||||
if len({subject.item_id for subject in self.subjects}) != len(self.subjects):
|
||||
raise E40OperatorReviewValidationError(
|
||||
"E40 operator-review subjects are not unique"
|
||||
)
|
||||
if any(
|
||||
_ITEM_ID.fullmatch(subject.item_id) is None or subject.sequence < 0
|
||||
for subject in self.subjects
|
||||
):
|
||||
raise E40OperatorReviewValidationError(
|
||||
"E40 operator-review subject is invalid"
|
||||
)
|
||||
|
||||
@property
|
||||
def item_set_sha256(self) -> str:
|
||||
return hashlib.sha256(
|
||||
_canonical_json(
|
||||
[
|
||||
{"item_id": subject.item_id, "sequence": subject.sequence}
|
||||
for subject in self.subjects
|
||||
]
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
def binding(self) -> dict[str, object]:
|
||||
return {
|
||||
"result_id": self.result_id,
|
||||
"materialization_id": self.materialization_id,
|
||||
"case_catalog_sha256": self.case_catalog_sha256,
|
||||
"item_count": len(self.subjects),
|
||||
"item_set_sha256": self.item_set_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _safe_root(root: Path) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if root.is_symlink() or not root.is_dir():
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator-review root is invalid"
|
||||
)
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def _reviewer_id(value: str) -> str:
|
||||
value = value.strip()
|
||||
if _REVIEWER_ID.fullmatch(value) is None:
|
||||
raise E40OperatorReviewValidationError("reviewer_id is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _review_id(
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
) -> str:
|
||||
identity = {
|
||||
"protocol": E40_OPERATOR_REVIEW_PROTOCOL,
|
||||
"source": substrate.binding(),
|
||||
"reviewer_id": reviewer_id,
|
||||
}
|
||||
return f"e40-operator-review-{hashlib.sha256(_canonical_json(identity)).hexdigest()}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_lock(path: Path) -> Iterator[None]:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
except FileExistsError as exc:
|
||||
raise E40OperatorReviewConflictError(
|
||||
"E40 operator review is being updated"
|
||||
) from exc
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class E40OperatorReviewStore:
|
||||
"""Mutable operator verdicts kept separate from immutable E40 evidence."""
|
||||
|
||||
def __init__(self, *, root: Path) -> None:
|
||||
self.root = _safe_root(root)
|
||||
|
||||
def get(
|
||||
self,
|
||||
*,
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
) -> dict[str, object]:
|
||||
reviewer_id = _reviewer_id(reviewer_id)
|
||||
review_id = _review_id(substrate, reviewer_id)
|
||||
path = self.root / f"{review_id}.json"
|
||||
if path.is_symlink():
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator review must not be a symlink"
|
||||
)
|
||||
if not path.exists():
|
||||
return self._empty(
|
||||
review_id=review_id,
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
)
|
||||
return self._read(
|
||||
path=path,
|
||||
review_id=review_id,
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
)
|
||||
|
||||
def record_verdict(
|
||||
self,
|
||||
*,
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
item_id: str,
|
||||
expected_revision: int,
|
||||
idempotency_key: str,
|
||||
verdict: E40OperatorVerdict,
|
||||
) -> dict[str, object]:
|
||||
reviewer_id = _reviewer_id(reviewer_id)
|
||||
if (
|
||||
_ITEM_ID.fullmatch(item_id) is None
|
||||
or item_id not in {subject.item_id for subject in substrate.subjects}
|
||||
):
|
||||
raise E40OperatorReviewValidationError(
|
||||
"item_id is outside the E40 error catalog"
|
||||
)
|
||||
if (
|
||||
_IDEMPOTENCY_KEY.fullmatch(idempotency_key) is None
|
||||
or verdict not in _VERDICTS
|
||||
):
|
||||
raise E40OperatorReviewValidationError(
|
||||
"E40 operator verdict request is invalid"
|
||||
)
|
||||
if expected_revision < 0:
|
||||
raise E40OperatorReviewValidationError(
|
||||
"expected_revision is invalid"
|
||||
)
|
||||
|
||||
review_id = _review_id(substrate, reviewer_id)
|
||||
path = self.root / f"{review_id}.json"
|
||||
lock_path = self.root / ".locks" / f"{review_id}.lock"
|
||||
with _exclusive_lock(lock_path):
|
||||
current = self.get(
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
)
|
||||
decisions = list(current["decisions"]) # type: ignore[arg-type]
|
||||
replay = next(
|
||||
(
|
||||
decision
|
||||
for decision in decisions
|
||||
if decision["idempotency_key"] == idempotency_key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if replay is not None:
|
||||
if (
|
||||
replay["item_id"] != item_id
|
||||
or replay["verdict"] != verdict
|
||||
):
|
||||
raise E40OperatorReviewConflictError(
|
||||
"idempotency key was already used for another verdict"
|
||||
)
|
||||
return current
|
||||
if current["revision"] != expected_revision:
|
||||
raise E40OperatorReviewConflictError(
|
||||
"E40 operator-review revision changed"
|
||||
)
|
||||
|
||||
next_revision = expected_revision + 1
|
||||
decided_at = utc_now_iso()
|
||||
event_identity = {
|
||||
"review_id": review_id,
|
||||
"item_id": item_id,
|
||||
"verdict": verdict,
|
||||
"revision": next_revision,
|
||||
"idempotency_key": idempotency_key,
|
||||
"decided_at_utc": decided_at,
|
||||
}
|
||||
decision = {
|
||||
"item_id": item_id,
|
||||
"verdict": verdict,
|
||||
"revision": next_revision,
|
||||
"idempotency_key": idempotency_key,
|
||||
"decided_at_utc": decided_at,
|
||||
"event_id": (
|
||||
"e40-operator-verdict-"
|
||||
f"{hashlib.sha256(_canonical_json(event_identity)).hexdigest()}"
|
||||
),
|
||||
}
|
||||
decisions = [
|
||||
previous
|
||||
for previous in decisions
|
||||
if previous["item_id"] != item_id
|
||||
]
|
||||
decisions.append(decision)
|
||||
subject_order = {
|
||||
subject.item_id: index
|
||||
for index, subject in enumerate(substrate.subjects)
|
||||
}
|
||||
decisions.sort(key=lambda value: subject_order[str(value["item_id"])])
|
||||
payload = self._payload(
|
||||
review_id=review_id,
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
revision=next_revision,
|
||||
decisions=decisions,
|
||||
updated_at_utc=decided_at,
|
||||
)
|
||||
write_json_atomic(path, payload)
|
||||
path.chmod(0o600)
|
||||
return payload
|
||||
|
||||
def _empty(
|
||||
self,
|
||||
*,
|
||||
review_id: str,
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
) -> dict[str, object]:
|
||||
return self._payload(
|
||||
review_id=review_id,
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
revision=0,
|
||||
decisions=[],
|
||||
updated_at_utc=None,
|
||||
)
|
||||
|
||||
def _payload(
|
||||
self,
|
||||
*,
|
||||
review_id: str,
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
revision: int,
|
||||
decisions: list[dict[str, object]],
|
||||
updated_at_utc: str | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": E40_OPERATOR_REVIEW_SCHEMA,
|
||||
"review_id": review_id,
|
||||
"protocol": E40_OPERATOR_REVIEW_PROTOCOL,
|
||||
"source": substrate.binding(),
|
||||
"reviewer_id": reviewer_id,
|
||||
"revision": revision,
|
||||
"decisions": decisions,
|
||||
"reviewed_item_count": len(decisions),
|
||||
"remaining_item_count": len(substrate.subjects) - len(decisions),
|
||||
"updated_at_utc": updated_at_utc,
|
||||
"access": "review-write",
|
||||
}
|
||||
|
||||
def _read(
|
||||
self,
|
||||
*,
|
||||
path: Path,
|
||||
review_id: str,
|
||||
substrate: E40OperatorReviewSubstrate,
|
||||
reviewer_id: str,
|
||||
) -> dict[str, object]:
|
||||
if (
|
||||
path.is_symlink()
|
||||
or not path.is_file()
|
||||
or not 0 < path.stat().st_size <= _MAX_JSON_BYTES
|
||||
):
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator review is unavailable"
|
||||
)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator review is invalid"
|
||||
) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator review must be an object"
|
||||
)
|
||||
decisions = value.get("decisions")
|
||||
revision = value.get("revision")
|
||||
if (
|
||||
value.get("schema_version") != E40_OPERATOR_REVIEW_SCHEMA
|
||||
or value.get("review_id") != review_id
|
||||
or value.get("protocol") != E40_OPERATOR_REVIEW_PROTOCOL
|
||||
or value.get("source") != substrate.binding()
|
||||
or value.get("reviewer_id") != reviewer_id
|
||||
or value.get("access") != "review-write"
|
||||
or isinstance(revision, bool)
|
||||
or not isinstance(revision, int)
|
||||
or revision < 0
|
||||
or not isinstance(decisions, list)
|
||||
or value.get("reviewed_item_count") != len(decisions)
|
||||
or value.get("remaining_item_count")
|
||||
!= len(substrate.subjects) - len(decisions)
|
||||
):
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator review identity is invalid"
|
||||
)
|
||||
known_items = {subject.item_id for subject in substrate.subjects}
|
||||
item_ids: set[str] = set()
|
||||
idempotency_keys: set[str] = set()
|
||||
for decision in decisions:
|
||||
decided_at = decision.get("decided_at_utc") if isinstance(
|
||||
decision, dict
|
||||
) else None
|
||||
event_identity = {
|
||||
"review_id": review_id,
|
||||
"item_id": decision.get("item_id") if isinstance(
|
||||
decision, dict
|
||||
) else None,
|
||||
"verdict": decision.get("verdict") if isinstance(
|
||||
decision, dict
|
||||
) else None,
|
||||
"revision": decision.get("revision") if isinstance(
|
||||
decision, dict
|
||||
) else None,
|
||||
"idempotency_key": decision.get("idempotency_key") if isinstance(
|
||||
decision, dict
|
||||
) else None,
|
||||
"decided_at_utc": decided_at,
|
||||
}
|
||||
expected_event_id = (
|
||||
"e40-operator-verdict-"
|
||||
f"{hashlib.sha256(_canonical_json(event_identity)).hexdigest()}"
|
||||
)
|
||||
if (
|
||||
not isinstance(decision, dict)
|
||||
or decision.get("item_id") not in known_items
|
||||
or decision.get("item_id") in item_ids
|
||||
or decision.get("verdict") not in _VERDICTS
|
||||
or not isinstance(decision.get("revision"), int)
|
||||
or not 1 <= int(decision["revision"]) <= revision
|
||||
or not isinstance(decision.get("decided_at_utc"), str)
|
||||
or _EVENT_ID.fullmatch(str(decision.get("event_id"))) is None
|
||||
or decision.get("event_id") != expected_event_id
|
||||
or _IDEMPOTENCY_KEY.fullmatch(
|
||||
str(decision.get("idempotency_key"))
|
||||
)
|
||||
is None
|
||||
or decision.get("idempotency_key") in idempotency_keys
|
||||
):
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator verdict is invalid"
|
||||
)
|
||||
item_ids.add(str(decision["item_id"]))
|
||||
idempotency_keys.add(str(decision["idempotency_key"]))
|
||||
if decisions and max(
|
||||
int(decision["revision"]) for decision in decisions
|
||||
) != revision:
|
||||
raise E40OperatorReviewIntegrityError(
|
||||
"E40 operator-review revision is invalid"
|
||||
)
|
||||
return value
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
@@ -58,6 +59,10 @@ from k1link.compute.e40_perception_product_gate import (
|
||||
LABORATORY_ADVANCED_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-advanced-catalog/v1"
|
||||
)
|
||||
LABORATORY_ADVANCED_INDEX_SCHEMA: Final = (
|
||||
"missioncore.laboratory-advanced-index/v1"
|
||||
)
|
||||
_INDEX_DOCUMENT_MAX_BYTES: Final = 64 * 1024
|
||||
|
||||
_E31_RESULT_ID = re.compile(r"^e31-source-qualification-[a-f0-9]{64}$")
|
||||
_E32_RESULT_ID = re.compile(r"^e32-track-geometry-[a-f0-9]{64}$")
|
||||
@@ -71,6 +76,14 @@ _E40_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
_AdvancedIndexSpec = tuple[
|
||||
str,
|
||||
RootProvider,
|
||||
re.Pattern[str],
|
||||
str,
|
||||
str,
|
||||
]
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
@@ -193,6 +206,82 @@ def _candidates(root: Path, pattern: re.Pattern[str]) -> list[Path]:
|
||||
)
|
||||
|
||||
|
||||
def _advanced_index_item(
|
||||
candidate: Path,
|
||||
*,
|
||||
work_id: str,
|
||||
document_name: str,
|
||||
schema_version: str,
|
||||
) -> dict[str, object]:
|
||||
document_path = candidate / document_name
|
||||
if document_path.is_symlink() or not document_path.is_file():
|
||||
raise ValueError("advanced LAB index document is missing")
|
||||
if document_path.stat().st_size > _INDEX_DOCUMENT_MAX_BYTES:
|
||||
raise ValueError("advanced LAB index document is too large")
|
||||
payload = json.loads(document_path.read_text(encoding="utf-8"))
|
||||
document = _object(payload, "advanced LAB index document")
|
||||
if document.get("schema_version") != schema_version:
|
||||
raise ValueError("advanced LAB index schema is invalid")
|
||||
if document.get("result_id") != candidate.name:
|
||||
raise ValueError("advanced LAB index result identity is invalid")
|
||||
identity_sha256 = document.get("identity_sha256")
|
||||
if (
|
||||
not isinstance(identity_sha256, str)
|
||||
or re.fullmatch(r"[a-f0-9]{64}", identity_sha256) is None
|
||||
or not candidate.name.endswith(identity_sha256)
|
||||
):
|
||||
raise ValueError("advanced LAB index digest is invalid")
|
||||
identity = _object(document.get("identity"), "advanced LAB identity")
|
||||
authority = _object(
|
||||
identity.get("authority"),
|
||||
"advanced LAB authority",
|
||||
)
|
||||
if (
|
||||
authority.get("commands_enabled") is not False
|
||||
or authority.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise ValueError("advanced LAB authority is invalid")
|
||||
if document.get("ground_truth") not in (None, False):
|
||||
raise ValueError("advanced LAB ground-truth claim is invalid")
|
||||
created_at_utc = document.get("created_at_utc")
|
||||
if not isinstance(created_at_utc, str) or not created_at_utc.strip():
|
||||
raise ValueError("advanced LAB creation time is invalid")
|
||||
return {
|
||||
"work_id": work_id,
|
||||
"result_id": candidate.name,
|
||||
"created_at_utc": created_at_utc,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _advanced_index(
|
||||
specs: tuple[_AdvancedIndexSpec, ...],
|
||||
) -> dict[str, object]:
|
||||
items: list[dict[str, object]] = []
|
||||
for work_id, provider, pattern, document_name, schema_version in specs:
|
||||
root = _configured_root(provider)
|
||||
if root is None:
|
||||
continue
|
||||
for candidate in _candidates(root, pattern):
|
||||
try:
|
||||
items.append(
|
||||
_advanced_index_item(
|
||||
candidate,
|
||||
work_id=work_id,
|
||||
document_name=document_name,
|
||||
schema_version=schema_version,
|
||||
)
|
||||
)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
continue
|
||||
return {
|
||||
"schema_version": LABORATORY_ADVANCED_INDEX_SCHEMA,
|
||||
"items": items,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{label} is invalid")
|
||||
@@ -739,6 +828,76 @@ def build_advanced_laboratory_router(
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory", tags=["laboratory"])
|
||||
|
||||
@router.get("/advanced-index")
|
||||
def list_advanced_results() -> dict[str, object]:
|
||||
return _advanced_index(
|
||||
(
|
||||
(
|
||||
"e31-source-binding",
|
||||
e31_root_provider,
|
||||
_E31_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e31-source-qualification/v1",
|
||||
),
|
||||
(
|
||||
"e32-track-geometry",
|
||||
e32_root_provider,
|
||||
_E32_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e32-track-geometry-replay/v1",
|
||||
),
|
||||
(
|
||||
"e33-worker-shadow",
|
||||
e33_root_provider,
|
||||
_E33_RESULT_ID,
|
||||
"result.json",
|
||||
"missioncore.e33-worker-shadow-result/v1",
|
||||
),
|
||||
(
|
||||
"e34-temporal-layer",
|
||||
e34_root_provider,
|
||||
_E34_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e34-temporal-occupied-result/v1",
|
||||
),
|
||||
(
|
||||
"e35-degradation-recovery",
|
||||
e35_root_provider,
|
||||
_E35_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e35-degradation-result/v1",
|
||||
),
|
||||
(
|
||||
"e37-ravnoves-acceptance",
|
||||
e37_root_provider,
|
||||
_E37_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e37-acceptance-contract/v1",
|
||||
),
|
||||
(
|
||||
"e38-perception-baseline",
|
||||
e38_root_provider,
|
||||
_E38_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e38-perception-baseline/v1",
|
||||
),
|
||||
(
|
||||
"e39-perception-refinement",
|
||||
e39_root_provider,
|
||||
_E39_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e39-perception-refinement/v1",
|
||||
),
|
||||
(
|
||||
"e40-perception-product-gate",
|
||||
e40_root_provider,
|
||||
_E40_RESULT_ID,
|
||||
"manifest.json",
|
||||
"missioncore.e40-perception-product-gate/v1",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@router.get("/e31/results")
|
||||
def list_e31_results(
|
||||
limit: int = Query(default=1, ge=1, le=10),
|
||||
|
||||
@@ -40,6 +40,7 @@ from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
from k1link.web.e30_review_api import build_e30_review_router
|
||||
from k1link.web.e40_case_review_api import build_e40_case_review_router
|
||||
from k1link.web.environment_api import build_environment_router
|
||||
from k1link.web.laboratory_api import build_laboratory_router
|
||||
from k1link.web.lidar_api import build_lidar_router
|
||||
@@ -614,6 +615,24 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e40_case_review_router(
|
||||
e40_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e40"
|
||||
/ "results"
|
||||
),
|
||||
operator_review_root_provider=lambda: (
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e40"
|
||||
/ "operator-reviews"
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
build_e30_engineering_router(
|
||||
generation_root_provider=lambda: (
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import Path as ApiPath
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.compute.e40_operator_review import (
|
||||
E40OperatorReviewConflictError,
|
||||
E40OperatorReviewIntegrityError,
|
||||
E40OperatorReviewStore,
|
||||
E40OperatorReviewSubject,
|
||||
E40OperatorReviewSubstrate,
|
||||
E40OperatorReviewValidationError,
|
||||
)
|
||||
from k1link.compute.e40_perception_product_gate import (
|
||||
E40_PREDICTION_SCHEMA,
|
||||
E40_PREDICTIONS_NAME,
|
||||
E40PerceptionProductGate,
|
||||
E40PerceptionProductGateError,
|
||||
read_e40_perception_product_gate,
|
||||
)
|
||||
|
||||
LABORATORY_E40_CASE_CATALOG_SCHEMA: Final = (
|
||||
"missioncore.laboratory-e40-case-catalog/v1"
|
||||
)
|
||||
|
||||
_RESULT_ID = re.compile(r"^e40-perception-product-gate-[a-f0-9]{64}$")
|
||||
_MATERIALIZATION_ID = re.compile(r"^e30-materialization-[a-f0-9]{64}$")
|
||||
_ITEM_ID = re.compile(r"^e30-review-item-[a-f0-9]{64}$")
|
||||
_MAX_PREDICTIONS_BYTES: Final = 8 * 1024 * 1024
|
||||
_AUTHORITY: Final = {
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
_DIMENSIONS: Final = (
|
||||
"presence",
|
||||
"geometry_association",
|
||||
"freshness",
|
||||
)
|
||||
_STRATA: Final = {
|
||||
"agree",
|
||||
"camera-only",
|
||||
"conflict",
|
||||
"geometry-only",
|
||||
"unknown",
|
||||
}
|
||||
_SEVERITIES: Final = {"high", "medium", "standard"}
|
||||
_SPLITS: Final = {"development", "validation"}
|
||||
_VALUES: Final = {
|
||||
"presence": {
|
||||
"background-or-noise",
|
||||
"object-present",
|
||||
"occupied-environment",
|
||||
},
|
||||
"geometry_association": {
|
||||
"independent-occupied",
|
||||
"insufficient-support",
|
||||
"object-associated",
|
||||
"rejected-nonobject",
|
||||
"unknown",
|
||||
},
|
||||
"freshness": {"current", "stale", "unavailable"},
|
||||
}
|
||||
_SEVERITY_ORDER: Final = {"high": 0, "medium": 1, "standard": 2}
|
||||
|
||||
RootProvider = Callable[[], Path | None]
|
||||
|
||||
|
||||
class E40OperatorVerdictRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reviewer_id: str = Field(min_length=1, max_length=128)
|
||||
expected_revision: int = Field(ge=0)
|
||||
idempotency_key: str = Field(min_length=1, max_length=128)
|
||||
verdict: Literal["confirmed-error", "rejected-error"]
|
||||
|
||||
|
||||
def _result_signature(root: Path) -> tuple[int, ...]:
|
||||
signature: list[int] = []
|
||||
for path in sorted(root.iterdir(), key=lambda item: item.name):
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
stat = path.stat()
|
||||
signature.extend((stat.st_size, stat.st_mtime_ns))
|
||||
return tuple(signature)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _read_result_cached(
|
||||
root_text: str,
|
||||
signature: tuple[int, ...],
|
||||
) -> E40PerceptionProductGate:
|
||||
del signature
|
||||
return read_e40_perception_product_gate(Path(root_text))
|
||||
|
||||
|
||||
def _configured_root(provider: RootProvider) -> Path | None:
|
||||
root = provider()
|
||||
if root is None:
|
||||
return None
|
||||
resolved = root.resolve()
|
||||
return resolved if resolved.is_dir() else None
|
||||
|
||||
|
||||
def _candidate(root: Path, result_id: str) -> Path:
|
||||
if _RESULT_ID.fullmatch(result_id) is None:
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
candidate = root / result_id
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
return candidate
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise E40PerceptionProductGateError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _nonnegative_integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label} must be a nonnegative integer"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_number(value: object, label: str) -> float:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not 0 <= float(value) <= 1
|
||||
):
|
||||
raise E40PerceptionProductGateError(f"{label} must be within [0, 1]")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _dimension_state(value: object, label: str) -> dict[str, str]:
|
||||
source = _object(value, label)
|
||||
if set(source) != set(_DIMENSIONS):
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label} has incompatible dimensions"
|
||||
)
|
||||
state: dict[str, str] = {}
|
||||
for dimension in _DIMENSIONS:
|
||||
item = source.get(dimension)
|
||||
if not isinstance(item, str) or item not in _VALUES[dimension]:
|
||||
raise E40PerceptionProductGateError(
|
||||
f"{label}.{dimension} is invalid"
|
||||
)
|
||||
state[dimension] = item
|
||||
return state
|
||||
|
||||
|
||||
def _read_predictions(result: E40PerceptionProductGate) -> tuple[dict[str, Any], ...]:
|
||||
path = result.result_root / E40_PREDICTIONS_NAME
|
||||
if (
|
||||
not path.is_file()
|
||||
or path.is_symlink()
|
||||
or path.stat().st_size > _MAX_PREDICTIONS_BYTES
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed predictions are unavailable"
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
sequences: set[int] = set()
|
||||
item_ids: set[str] = set()
|
||||
try:
|
||||
with path.open("r", encoding="utf-8-sig") as stream:
|
||||
for line in stream:
|
||||
row = _object(json.loads(line), E40_PREDICTIONS_NAME)
|
||||
sequence = _nonnegative_integer(
|
||||
row.get("sequence"),
|
||||
"E40 prediction.sequence",
|
||||
)
|
||||
item_id = row.get("item_id")
|
||||
review_key = row.get("review_key")
|
||||
source_frame_index = _nonnegative_integer(
|
||||
row.get("source_frame_index"),
|
||||
"E40 prediction.source_frame_index",
|
||||
)
|
||||
stratum = row.get("source_stratum")
|
||||
severity = row.get("severity")
|
||||
split = row.get("split")
|
||||
if (
|
||||
row.get("schema_version") != E40_PREDICTION_SCHEMA
|
||||
or not isinstance(item_id, str)
|
||||
or _ITEM_ID.fullmatch(item_id) is None
|
||||
or not isinstance(review_key, str)
|
||||
or not review_key
|
||||
or stratum not in _STRATA
|
||||
or severity not in _SEVERITIES
|
||||
or split not in _SPLITS
|
||||
or row.get("scored") is not (split == "validation")
|
||||
or row.get("authority") != _AUTHORITY
|
||||
or sequence in sequences
|
||||
or item_id in item_ids
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 prediction identity is invalid"
|
||||
)
|
||||
prediction = _dimension_state(
|
||||
row.get("prediction"),
|
||||
"E40 prediction.prediction",
|
||||
)
|
||||
reference = _dimension_state(
|
||||
row.get("reference"),
|
||||
"E40 prediction.reference",
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"sequence": sequence,
|
||||
"item_id": item_id,
|
||||
"review_key": review_key,
|
||||
"source_frame_index": source_frame_index,
|
||||
"source_stratum": stratum,
|
||||
"severity": severity,
|
||||
"split": split,
|
||||
"prediction": prediction,
|
||||
"reference": reference,
|
||||
"presence_confidence": _bounded_number(
|
||||
row.get("presence_confidence"),
|
||||
"E40 prediction.presence_confidence",
|
||||
),
|
||||
}
|
||||
)
|
||||
sequences.add(sequence)
|
||||
item_ids.add(item_id)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed predictions are invalid"
|
||||
) from exc
|
||||
validation_total = _object(
|
||||
result.report.get("metrics"),
|
||||
"E40 metrics",
|
||||
).get("validation_items")
|
||||
if (
|
||||
not rows
|
||||
or _nonnegative_integer(validation_total, "E40 validation_items")
|
||||
!= sum(row["split"] == "validation" for row in rows)
|
||||
):
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 sealed validation denominator changed"
|
||||
)
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def _materialization_id(result: E40PerceptionProductGate) -> str:
|
||||
identity = _object(result.manifest.get("identity"), "E40 identity")
|
||||
source = _object(identity.get("source"), "E40 source")
|
||||
value = source.get("materialization_id")
|
||||
if not isinstance(value, str) or _MATERIALIZATION_ID.fullmatch(value) is None:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 materialization binding is invalid"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _case(row: dict[str, Any]) -> dict[str, object]:
|
||||
reference = _object(row["reference"], "E40 reference")
|
||||
prediction = _object(row["prediction"], "E40 prediction")
|
||||
source_stratum = str(row["source_stratum"])
|
||||
mismatched = [
|
||||
dimension
|
||||
for dimension in _DIMENSIONS
|
||||
if reference[dimension] != prediction[dimension]
|
||||
]
|
||||
return {
|
||||
"item_id": row["item_id"],
|
||||
"sequence": row["sequence"],
|
||||
"source_frame_index": row["source_frame_index"],
|
||||
"source_stratum": source_stratum,
|
||||
"severity": row["severity"],
|
||||
"presence_confidence": row["presence_confidence"],
|
||||
"prediction_basis": (
|
||||
"camera-only-softmax"
|
||||
if source_stratum == "camera-only"
|
||||
else "fixed-stratum-policy"
|
||||
),
|
||||
"reference": dict(reference),
|
||||
"prediction": dict(prediction),
|
||||
"mismatched_dimensions": mismatched,
|
||||
"access": "read-only",
|
||||
}
|
||||
|
||||
|
||||
def _load_catalog(
|
||||
root_provider: RootProvider,
|
||||
result_id: str,
|
||||
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||
root = _configured_root(root_provider)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail="E40 result не найден")
|
||||
candidate = _candidate(root, result_id)
|
||||
result = _read_result_cached(
|
||||
str(candidate.resolve()),
|
||||
_result_signature(candidate),
|
||||
)
|
||||
rows = _read_predictions(result)
|
||||
errors = [
|
||||
row
|
||||
for row in rows
|
||||
if row["split"] == "validation"
|
||||
and any(
|
||||
row["reference"][dimension] != row["prediction"][dimension]
|
||||
for dimension in _DIMENSIONS
|
||||
)
|
||||
]
|
||||
errors.sort(
|
||||
key=lambda row: (
|
||||
_SEVERITY_ORDER[str(row["severity"])],
|
||||
int(row["sequence"]),
|
||||
)
|
||||
)
|
||||
if not errors or len(errors) > 64:
|
||||
raise E40PerceptionProductGateError(
|
||||
"E40 error catalog is outside the review boundary"
|
||||
)
|
||||
materialization_id = _materialization_id(result)
|
||||
cases = [_case(row) for row in errors]
|
||||
case_catalog_sha256 = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"result_id": result.result_id,
|
||||
"materialization_id": materialization_id,
|
||||
"items": cases,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
catalog: dict[str, object] = {
|
||||
"schema_version": LABORATORY_E40_CASE_CATALOG_SCHEMA,
|
||||
"result_id": result.result_id,
|
||||
"materialization_id": materialization_id,
|
||||
"items": cases,
|
||||
"total": len(errors),
|
||||
"truncated": False,
|
||||
"access": "read-only",
|
||||
}
|
||||
substrate = E40OperatorReviewSubstrate(
|
||||
result_id=result.result_id,
|
||||
materialization_id=materialization_id,
|
||||
case_catalog_sha256=case_catalog_sha256,
|
||||
subjects=tuple(
|
||||
E40OperatorReviewSubject(
|
||||
item_id=str(row["item_id"]),
|
||||
sequence=int(row["sequence"]),
|
||||
)
|
||||
for row in errors
|
||||
),
|
||||
)
|
||||
return catalog, substrate
|
||||
|
||||
|
||||
def build_e40_case_review_router(
|
||||
*,
|
||||
e40_root_provider: RootProvider = lambda: None,
|
||||
operator_review_root_provider: RootProvider = lambda: None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/laboratory/e40", tags=["laboratory"])
|
||||
|
||||
def catalog_and_source(
|
||||
result_id: str,
|
||||
) -> tuple[dict[str, object], E40OperatorReviewSubstrate]:
|
||||
try:
|
||||
return _load_catalog(e40_root_provider, result_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except (
|
||||
E40PerceptionProductGateError,
|
||||
E40OperatorReviewValidationError,
|
||||
KeyError,
|
||||
OSError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E40 case-review не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
def store() -> E40OperatorReviewStore:
|
||||
root = operator_review_root_provider()
|
||||
if root is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E40 operator review не настроен",
|
||||
)
|
||||
try:
|
||||
return E40OperatorReviewStore(root=root)
|
||||
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="E40 operator review storage недоступен",
|
||||
) from exc
|
||||
|
||||
def invoke(
|
||||
operation: Callable[[], dict[str, object]],
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return operation()
|
||||
except E40OperatorReviewValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except E40OperatorReviewConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except (E40OperatorReviewIntegrityError, OSError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="E40 operator review не прошёл проверку целостности",
|
||||
) from exc
|
||||
|
||||
@router.get("/results/{result_id}/cases")
|
||||
def list_e40_error_cases(
|
||||
result_id: str,
|
||||
limit: int = Query(default=48, ge=1, le=64),
|
||||
) -> dict[str, object]:
|
||||
catalog, _ = catalog_and_source(result_id)
|
||||
items = list(catalog["items"]) # type: ignore[arg-type]
|
||||
return {
|
||||
**catalog,
|
||||
"items": items[:limit],
|
||||
"truncated": len(items) > limit,
|
||||
}
|
||||
|
||||
@router.get("/results/{result_id}/operator-review")
|
||||
def get_operator_review(
|
||||
result_id: str,
|
||||
reviewer_id: str = Query(
|
||||
default="DC",
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
_, substrate = catalog_and_source(result_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.get(
|
||||
substrate=substrate,
|
||||
reviewer_id=reviewer_id,
|
||||
)
|
||||
)
|
||||
|
||||
@router.put(
|
||||
"/results/{result_id}/operator-review/decisions/{item_id}"
|
||||
)
|
||||
def record_operator_verdict(
|
||||
result_id: str,
|
||||
item_id: Annotated[
|
||||
str,
|
||||
ApiPath(pattern=r"^e30-review-item-[a-f0-9]{64}$"),
|
||||
],
|
||||
request: E40OperatorVerdictRequest,
|
||||
) -> dict[str, object]:
|
||||
_, substrate = catalog_and_source(result_id)
|
||||
review_store = store()
|
||||
return invoke(
|
||||
lambda: review_store.record_verdict(
|
||||
substrate=substrate,
|
||||
reviewer_id=request.reviewer_id,
|
||||
item_id=item_id,
|
||||
expected_revision=request.expected_revision,
|
||||
idempotency_key=request.idempotency_key,
|
||||
verdict=request.verdict,
|
||||
)
|
||||
)
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user