345 lines
10 KiB
Python
345 lines
10 KiB
Python
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"
|