feat(lab): complete E30 evidence review gate
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter
|
||||
|
||||
from k1link.compute.e30_human_review import (
|
||||
E30HumanReviewConflictError,
|
||||
E30HumanReviewIntegrityError,
|
||||
E30HumanReviewStore,
|
||||
E30HumanReviewValidationError,
|
||||
E30ReviewSubject,
|
||||
E30ReviewSubstrate,
|
||||
)
|
||||
from k1link.web import e30_human_review_api
|
||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Any:
|
||||
for route in router.routes:
|
||||
if (
|
||||
getattr(route, "path", None) == path
|
||||
and method in getattr(route, "methods", set())
|
||||
):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} endpoint {path} not found")
|
||||
|
||||
|
||||
def _substrate() -> E30ReviewSubstrate:
|
||||
return E30ReviewSubstrate(
|
||||
materialization_id=f"e30-materialization-{'a' * 64}",
|
||||
materialization_identity_sha256="a" * 64,
|
||||
review_pack_id=f"e30-review-pack-{'b' * 64}",
|
||||
review_items_sha256="c" * 64,
|
||||
reason_taxonomy=(),
|
||||
subjects=(
|
||||
E30ReviewSubject(
|
||||
item_id=f"e30-review-item-{'d' * 64}",
|
||||
sequence=0,
|
||||
source_stratum="geometry-only",
|
||||
),
|
||||
E30ReviewSubject(
|
||||
item_id=f"e30-review-item-{'e' * 64}",
|
||||
sequence=1,
|
||||
source_stratum="unknown",
|
||||
),
|
||||
),
|
||||
engineering_generation_id=f"e30-engineering-generation-{'f' * 64}",
|
||||
)
|
||||
|
||||
|
||||
def _store(tmp_path: Path) -> E30HumanReviewStore:
|
||||
return E30HumanReviewStore(
|
||||
draft_root=tmp_path / "drafts",
|
||||
generation_root=tmp_path / "generations",
|
||||
)
|
||||
|
||||
|
||||
def test_exception_review_is_generation_bound_and_resumable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
|
||||
created = store.create_or_resume(substrate=substrate, reviewer_id="DC")
|
||||
resumed = store.create_or_resume(substrate=substrate, reviewer_id="DC")
|
||||
|
||||
assert resumed["draft_id"] == created["draft_id"]
|
||||
assert resumed["engineering_generation_id"] == (
|
||||
substrate.engineering_generation_id
|
||||
)
|
||||
assert resumed["item_count"] == 2
|
||||
assert resumed["reviewed_item_count"] == 0
|
||||
|
||||
|
||||
def test_decisions_are_append_only_idempotent_and_superseding(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
subject = substrate.subjects[0]
|
||||
|
||||
first = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
replay = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
changed = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=subject.item_id,
|
||||
expected_revision=1,
|
||||
idempotency_key="decision-002",
|
||||
disposition="background-or-noise",
|
||||
notes="Static façade points.",
|
||||
)
|
||||
|
||||
assert first["revision"] == replay["revision"] == 1
|
||||
assert changed["revision"] == 2
|
||||
assert changed["reviewed_item_count"] == 1
|
||||
assert changed["disposition_distribution"] == {
|
||||
"background-or-noise": 1
|
||||
}
|
||||
assert len(
|
||||
(
|
||||
tmp_path / "drafts" / draft_id / "events.jsonl"
|
||||
).read_text().splitlines()
|
||||
) == 2
|
||||
|
||||
|
||||
def test_decision_rejects_unknown_disposition(tmp_path: Path) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
|
||||
with pytest.raises(E30HumanReviewValidationError):
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-invalid",
|
||||
disposition="invented", # type: ignore[arg-type]
|
||||
notes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_finalization_requires_every_exception_and_freezes_generation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
first = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
with pytest.raises(E30HumanReviewConflictError, match="coverage is incomplete"):
|
||||
store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=int(first["revision"]),
|
||||
)
|
||||
|
||||
complete = store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[1].item_id,
|
||||
expected_revision=1,
|
||||
idempotency_key="decision-002",
|
||||
disposition="insufficient-evidence",
|
||||
notes="Occluded.",
|
||||
)
|
||||
generation = store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=int(complete["revision"]),
|
||||
)
|
||||
|
||||
assert generation["human_review_complete"] is True
|
||||
assert generation["lab_published"] is False
|
||||
assert generation["engineering_generation_id"] == (
|
||||
substrate.engineering_generation_id
|
||||
)
|
||||
assert generation["disposition_distribution"] == {
|
||||
"insufficient-evidence": 1,
|
||||
"object-present": 1,
|
||||
}
|
||||
generation_id = str(generation["generation_id"])
|
||||
decision_rows = [
|
||||
json.loads(line)
|
||||
for line in (
|
||||
tmp_path
|
||||
/ "generations"
|
||||
/ generation_id
|
||||
/ "review-decisions.jsonl"
|
||||
).read_text().splitlines()
|
||||
]
|
||||
assert [row["disposition"] for row in decision_rows] == [
|
||||
"object-present",
|
||||
"insufficient-evidence",
|
||||
]
|
||||
|
||||
repeated = store.finalize(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
expected_revision=2,
|
||||
)
|
||||
assert repeated["generation_id"] == generation_id
|
||||
with pytest.raises(E30HumanReviewConflictError, match="finalized"):
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=2,
|
||||
idempotency_key="decision-after-finalize",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
|
||||
|
||||
def test_event_and_generation_tampering_fail_closed(tmp_path: Path) -> None:
|
||||
substrate = _substrate()
|
||||
store = _store(tmp_path)
|
||||
draft_id = str(
|
||||
store.create_or_resume(substrate=substrate, reviewer_id="DC")["draft_id"]
|
||||
)
|
||||
store.record_decision(
|
||||
draft_id=draft_id,
|
||||
substrate=substrate,
|
||||
item_id=substrate.subjects[0].item_id,
|
||||
expected_revision=0,
|
||||
idempotency_key="decision-001",
|
||||
disposition="object-present",
|
||||
notes=None,
|
||||
)
|
||||
events_path = tmp_path / "drafts" / draft_id / "events.jsonl"
|
||||
event = json.loads(events_path.read_text())
|
||||
event["disposition"] = "background-or-noise"
|
||||
events_path.write_text(json.dumps(event) + "\n")
|
||||
|
||||
with pytest.raises(E30HumanReviewIntegrityError):
|
||||
store.get(draft_id=draft_id, substrate=substrate)
|
||||
|
||||
|
||||
def test_http_lifecycle_is_bound_to_the_engineering_exception_queue(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source_substrate = _substrate()
|
||||
generation_id = str(source_substrate.engineering_generation_id)
|
||||
monkeypatch.setattr(
|
||||
e30_human_review_api,
|
||||
"load_verified_e30_review",
|
||||
lambda **_: ({}, (), source_substrate),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
e30_human_review_api,
|
||||
"load_verified_e30_engineering_generation",
|
||||
lambda **_: (
|
||||
{
|
||||
"human_exceptions": [
|
||||
{"item_id": subject.item_id}
|
||||
for subject in source_substrate.subjects
|
||||
]
|
||||
},
|
||||
(),
|
||||
),
|
||||
)
|
||||
router = build_e30_human_review_router(
|
||||
materialization_root_provider=lambda: tmp_path / "materializations",
|
||||
review_pack_root_provider=lambda: tmp_path / "review-packs",
|
||||
engineering_generation_root_provider=lambda: (
|
||||
tmp_path / "engineering-generations"
|
||||
),
|
||||
draft_root_provider=lambda: tmp_path / "drafts",
|
||||
generation_root_provider=lambda: tmp_path / "generations",
|
||||
)
|
||||
create = _endpoint(
|
||||
router,
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review",
|
||||
"POST",
|
||||
)
|
||||
decide = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review/"
|
||||
"{draft_id}/decisions/{item_id}"
|
||||
),
|
||||
"PUT",
|
||||
)
|
||||
finalize = _endpoint(
|
||||
router,
|
||||
(
|
||||
"/api/v1/laboratory/e30/reviews/{result_id}/human-review/"
|
||||
"{draft_id}/finalize"
|
||||
),
|
||||
"POST",
|
||||
)
|
||||
|
||||
created = create(
|
||||
result_id=source_substrate.materialization_id,
|
||||
request=e30_human_review_api.E30HumanReviewCreateRequest(
|
||||
reviewer_id="DC",
|
||||
engineering_generation_id=generation_id,
|
||||
),
|
||||
)
|
||||
draft = created
|
||||
for revision, subject in enumerate(source_substrate.subjects):
|
||||
draft = decide(
|
||||
result_id=source_substrate.materialization_id,
|
||||
draft_id=draft["draft_id"],
|
||||
item_id=subject.item_id,
|
||||
engineering_generation_id=generation_id,
|
||||
request=e30_human_review_api.E30HumanReviewDecisionRequest(
|
||||
expected_revision=revision,
|
||||
idempotency_key=f"http-{revision}",
|
||||
disposition=(
|
||||
"object-present"
|
||||
if revision == 0
|
||||
else "insufficient-evidence"
|
||||
),
|
||||
notes=None,
|
||||
),
|
||||
)
|
||||
|
||||
finalized = finalize(
|
||||
result_id=source_substrate.materialization_id,
|
||||
draft_id=draft["draft_id"],
|
||||
engineering_generation_id=generation_id,
|
||||
request=e30_human_review_api.E30HumanReviewFinalizeRequest(
|
||||
expected_revision=draft["revision"],
|
||||
confirm_generation=True,
|
||||
),
|
||||
)
|
||||
assert finalized["draft"]["state"] == "finalized"
|
||||
Reference in New Issue
Block a user