From 7819ae7a38338c1b2e0c7a20c1451b6205be9be2 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Sun, 3 May 2026 19:02:13 +0300 Subject: [PATCH] [AZ-231] Add anchor verification gates Co-authored-by: Cursor --- .../AZ-231_anchor_verification_matching.md | 0 .../batch_08_cycle1_report.md | 35 ++++++++ .../reviews/batch_08_review.md | 53 +++++++++++ _docs/_autodev_state.md | 2 +- src/anchor_verification/__init__.py | 23 +++++ src/anchor_verification/interfaces.py | 85 +++++++++++++++++- src/anchor_verification/types.py | 57 +++++++++++- tests/unit/test_anchor_verification.py | 87 +++++++++++++++++++ 8 files changed, 336 insertions(+), 6 deletions(-) rename _docs/02_tasks/{todo => done}/AZ-231_anchor_verification_matching.md (100%) create mode 100644 _docs/03_implementation/batch_08_cycle1_report.md create mode 100644 _docs/03_implementation/reviews/batch_08_review.md create mode 100644 tests/unit/test_anchor_verification.py diff --git a/_docs/02_tasks/todo/AZ-231_anchor_verification_matching.md b/_docs/02_tasks/done/AZ-231_anchor_verification_matching.md similarity index 100% rename from _docs/02_tasks/todo/AZ-231_anchor_verification_matching.md rename to _docs/02_tasks/done/AZ-231_anchor_verification_matching.md diff --git a/_docs/03_implementation/batch_08_cycle1_report.md b/_docs/03_implementation/batch_08_cycle1_report.md new file mode 100644 index 0000000..b178261 --- /dev/null +++ b/_docs/03_implementation/batch_08_cycle1_report.md @@ -0,0 +1,35 @@ +# Batch Report + +**Batch**: 8 +**Tasks**: AZ-231_anchor_verification_matching +**Date**: 2026-05-03 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|----------------|-------|-------------|--------| +| AZ-231_anchor_verification_matching | Done | 4 files | Pass | 3/3 ACs covered | None | + +## AC Test Coverage: All covered + +| AC Ref | Coverage | +|--------|----------| +| AZ-231 AC-1 | `test_candidate_verification_emits_acceptance_evidence` verifies accepted decisions include MRE, inliers, homography, and reason metadata. | +| AZ-231 AC-2 | `test_unsafe_candidate_is_rejected_with_reason` verifies unsafe/stale candidates are rejected without estimated pose. | +| AZ-231 AC-3 | `test_matcher_benchmark_reports_profile_runtime_and_quality_metrics` verifies matcher profile runtime and quality metrics are reportable. | + +## Code Review Verdict: PASS + +Review report: `_docs/03_implementation/reviews/batch_08_review.md` + +## Auto-Fix Attempts: 0 + +## Stuck Agents: None + +## Verification + +- `.venv/bin/python -m black --check src tests e2e/replay` passed. +- `.venv/bin/python -m ruff check src tests e2e/replay` passed. +- `.venv/bin/python -m pytest` passed: 45 tests. + +## Next Batch: AZ-232_safety_anchor_state_machine diff --git a/_docs/03_implementation/reviews/batch_08_review.md b/_docs/03_implementation/reviews/batch_08_review.md new file mode 100644 index 0000000..0c66639 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_08_review.md @@ -0,0 +1,53 @@ +# Code Review Report + +**Batch**: AZ-231_anchor_verification_matching +**Date**: 2026-05-03 +**Verdict**: PASS + +## Findings + +No findings. + +## Review Scope + +- Task spec: + - `_docs/02_tasks/todo/AZ-231_anchor_verification_matching.md` +- Changed files: + - `src/anchor_verification/__init__.py` + - `src/anchor_verification/interfaces.py` + - `src/anchor_verification/types.py` + - `tests/unit/test_anchor_verification.py` + +## Phase Notes + +### Spec Compliance + +- AZ-231 AC-1 is covered by `test_candidate_verification_emits_acceptance_evidence`. +- AZ-231 AC-2 is covered by `test_unsafe_candidate_is_rejected_with_reason`. +- AZ-231 AC-3 is covered by `test_matcher_benchmark_reports_profile_runtime_and_quality_metrics`. + +### Code Quality + +The implementation keeps evidence/result models in `types.py`, gate behavior in `interfaces.py`, and public exports in `__init__.py`. The benchmark path computes each verification result once and reports runtime/quality metrics per matcher profile. + +### Security Quick-Scan + +No network calls, shell execution, dynamic code execution, hardcoded secrets, or credential logging were introduced. + +### Performance Scan + +Anchor verification is request/trigger oriented and does not add a per-frame learned matcher loop. Benchmark reporting is bounded by the provided evidence tuple. + +### Cross-Task Consistency + +The verifier consumes `VprCandidate` outputs from Satellite Service and emits shared `AnchorDecision` DTOs for the later safety wrapper task. + +### Architecture Compliance + +Imports respect `_docs/02_document/module-layout.md`: Anchor Verification imports shared contracts only and does not reach into Satellite Service or Tile Manager internals. + +## Verification + +- `.venv/bin/python -m black --check src tests e2e/replay` +- `.venv/bin/python -m ruff check src tests e2e/replay` +- `.venv/bin/python -m pytest` diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index e50e958..ff38d26 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -9,6 +9,6 @@ tracker: jira sub_step: phase: 1 name: batch-loop - detail: "batch 7: AZ-230_satellite_service_vpr_retrieval" + detail: "batch 8: AZ-231_anchor_verification_matching" retry_count: 0 cycle: 1 diff --git a/src/anchor_verification/__init__.py b/src/anchor_verification/__init__.py index 5205f32..4d8792c 100644 --- a/src/anchor_verification/__init__.py +++ b/src/anchor_verification/__init__.py @@ -1 +1,24 @@ """Anchor verification component.""" + +from .interfaces import AnchorVerifier, GeometryGatedAnchorVerifier +from .types import ( + AnchorFrame, + AnchorVerificationResult, + GeometryGateConfig, + MatchEvidence, + MatcherBenchmarkReport, + MatcherBenchmarkResult, + MatcherProfile, +) + +__all__ = [ + "AnchorFrame", + "AnchorVerificationResult", + "AnchorVerifier", + "GeometryGateConfig", + "GeometryGatedAnchorVerifier", + "MatchEvidence", + "MatcherBenchmarkReport", + "MatcherBenchmarkResult", + "MatcherProfile", +] diff --git a/src/anchor_verification/interfaces.py b/src/anchor_verification/interfaces.py index 89032c7..49b20a0 100644 --- a/src/anchor_verification/interfaces.py +++ b/src/anchor_verification/interfaces.py @@ -1,10 +1,91 @@ """Public anchor verification interfaces.""" -from typing import Any, Protocol +from typing import Protocol + +from shared.contracts import AnchorDecision + +from .types import ( + AnchorFrame, + AnchorVerificationResult, + GeometryGateConfig, + MatchEvidence, + MatcherBenchmarkReport, + MatcherBenchmarkResult, +) class AnchorVerifier(Protocol): """Verifies retrieved candidates against camera observations.""" - def verify(self, frame: Any, candidate: Any) -> Any: + def verify(self, frame: AnchorFrame, evidence: MatchEvidence) -> AnchorVerificationResult: """Return an anchor decision for one candidate.""" + + +class GeometryGatedAnchorVerifier: + """Converts matcher evidence into accepted/rejected anchor decisions.""" + + def __init__(self, gates: GeometryGateConfig | None = None) -> None: + self._gates = gates or GeometryGateConfig() + + def verify(self, frame: AnchorFrame, evidence: MatchEvidence) -> AnchorVerificationResult: + accepted, reason = self._classify(frame, evidence) + decision = AnchorDecision( + candidate_id=evidence.candidate.chunk_id, + accepted=accepted, + estimated_pose=self._estimated_pose(evidence) if accepted else None, + inliers=evidence.inliers, + mean_reprojection_error_px=evidence.mean_reprojection_error_px, + rejection_reason=None if accepted else reason, + ) + return AnchorVerificationResult( + decision=decision, + matcher_profile=evidence.matcher_profile, + reason=reason, + homography=evidence.homography, + freshness_status=evidence.candidate.freshness_status, + ) + + def benchmark( + self, frame: AnchorFrame, evidences: tuple[MatchEvidence, ...] + ) -> MatcherBenchmarkReport: + results: list[MatcherBenchmarkResult] = [] + for evidence in evidences: + verification = self.verify(frame, evidence) + results.append( + MatcherBenchmarkResult( + matcher_profile=evidence.matcher_profile, + runtime_ms=evidence.runtime_ms, + inliers=evidence.inliers, + mean_reprojection_error_px=evidence.mean_reprojection_error_px, + accepted=verification.decision.accepted, + reason=verification.reason, + ) + ) + return MatcherBenchmarkReport( + results=tuple(results), + ) + + def _classify(self, frame: AnchorFrame, evidence: MatchEvidence) -> tuple[bool, str]: + if not frame.usable_for_anchor: + return False, "frame_not_usable" + if evidence.candidate.freshness_status != "fresh" or not evidence.provenance_trusted: + return False, "stale_or_untrusted_provenance" + if evidence.homography is None: + return False, "geometry_failure" + if evidence.inliers < self._gates.min_inliers: + return False, "low_inliers" + if evidence.mean_reprojection_error_px > self._gates.max_mean_reprojection_error_px: + return False, "high_mre" + return True, "accepted_geometry" + + def _estimated_pose(self, evidence: MatchEvidence) -> dict[str, float]: + footprint = evidence.candidate.footprint + min_lat = footprint.get("min_lat", 0.0) + max_lat = footprint.get("max_lat", min_lat) + min_lon = footprint.get("min_lon", 0.0) + max_lon = footprint.get("max_lon", min_lon) + return { + "latitude_deg": (min_lat + max_lat) / 2.0, + "longitude_deg": (min_lon + max_lon) / 2.0, + "mean_reprojection_error_px": evidence.mean_reprojection_error_px, + } diff --git a/src/anchor_verification/types.py b/src/anchor_verification/types.py index 6d2e069..14e5a96 100644 --- a/src/anchor_verification/types.py +++ b/src/anchor_verification/types.py @@ -1,5 +1,56 @@ -"""Public anchor verification type aliases.""" +"""Public anchor verification models.""" -from typing import Any +from typing import Literal -AnchorDecisionLike = Any +from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt + +from shared.contracts import AnchorDecision, VprCandidate + + +class AnchorVerificationModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +MatcherProfile = Literal["aliked_lightglue", "disk_lightglue", "sift_orb"] + + +class AnchorFrame(AnchorVerificationModel): + frame_id: str = Field(min_length=1) + image_ref: str = Field(min_length=1) + usable_for_anchor: bool = True + + +class GeometryGateConfig(AnchorVerificationModel): + min_inliers: NonNegativeInt = 20 + max_mean_reprojection_error_px: NonNegativeFloat = 3.0 + + +class MatchEvidence(AnchorVerificationModel): + candidate: VprCandidate + matcher_profile: MatcherProfile + inliers: NonNegativeInt + mean_reprojection_error_px: NonNegativeFloat + homography: dict[str, float] | None = None + runtime_ms: NonNegativeFloat + provenance_trusted: bool = True + + +class AnchorVerificationResult(AnchorVerificationModel): + decision: AnchorDecision + matcher_profile: MatcherProfile + reason: str = Field(min_length=1) + homography: dict[str, float] | None = None + freshness_status: Literal["fresh", "stale", "rejected"] + + +class MatcherBenchmarkResult(AnchorVerificationModel): + matcher_profile: MatcherProfile + runtime_ms: NonNegativeFloat + inliers: NonNegativeInt + mean_reprojection_error_px: NonNegativeFloat + accepted: bool + reason: str = Field(min_length=1) + + +class MatcherBenchmarkReport(AnchorVerificationModel): + results: tuple[MatcherBenchmarkResult, ...] = Field(min_length=1) diff --git a/tests/unit/test_anchor_verification.py b/tests/unit/test_anchor_verification.py new file mode 100644 index 0000000..913f159 --- /dev/null +++ b/tests/unit/test_anchor_verification.py @@ -0,0 +1,87 @@ +from anchor_verification import AnchorFrame, GeometryGatedAnchorVerifier, MatchEvidence +from shared.contracts import VprCandidate + + +def _candidate(freshness_status: str = "fresh") -> VprCandidate: + return VprCandidate( + chunk_id="chunk-1", + tile_id="tile-1", + score=0.91, + footprint={"min_lat": 49.0, "max_lat": 49.2, "min_lon": 36.0, "max_lon": 36.2}, + freshness_status=freshness_status, + ) + + +def _evidence(**overrides: object) -> MatchEvidence: + payload: dict[str, object] = { + "candidate": _candidate(), + "matcher_profile": "aliked_lightglue", + "inliers": 48, + "mean_reprojection_error_px": 1.4, + "homography": {"h00": 1.0, "h11": 1.0, "h22": 1.0}, + "runtime_ms": 72.5, + "provenance_trusted": True, + } + payload.update(overrides) + return MatchEvidence.model_validate(payload) + + +def test_candidate_verification_emits_acceptance_evidence() -> None: + # Arrange + verifier = GeometryGatedAnchorVerifier() + frame = AnchorFrame(frame_id="frame-1", image_ref="replay/frame-1.jpg") + + # Act + result = verifier.verify(frame, _evidence()) + + # Assert + assert result.decision.accepted is True + assert result.decision.inliers == 48 + assert result.decision.mean_reprojection_error_px == 1.4 + assert result.reason == "accepted_geometry" + assert result.homography == {"h00": 1.0, "h11": 1.0, "h22": 1.0} + + +def test_unsafe_candidate_is_rejected_with_reason() -> None: + # Arrange + verifier = GeometryGatedAnchorVerifier() + frame = AnchorFrame(frame_id="frame-1", image_ref="replay/frame-1.jpg") + evidence = _evidence( + candidate=_candidate(freshness_status="stale"), + inliers=6, + mean_reprojection_error_px=8.0, + ) + + # Act + result = verifier.verify(frame, evidence) + + # Assert + assert result.decision.accepted is False + assert result.decision.estimated_pose is None + assert result.decision.rejection_reason == "stale_or_untrusted_provenance" + assert result.reason == "stale_or_untrusted_provenance" + + +def test_matcher_benchmark_reports_profile_runtime_and_quality_metrics() -> None: + # Arrange + verifier = GeometryGatedAnchorVerifier() + frame = AnchorFrame(frame_id="frame-1", image_ref="replay/frame-1.jpg") + + # Act + report = verifier.benchmark( + frame, + ( + _evidence(matcher_profile="aliked_lightglue", runtime_ms=72.5), + _evidence(matcher_profile="sift_orb", inliers=12, runtime_ms=18.0), + ), + ) + + # Assert + assert [result.matcher_profile for result in report.results] == [ + "aliked_lightglue", + "sift_orb", + ] + assert report.results[0].accepted is True + assert report.results[0].runtime_ms == 72.5 + assert report.results[1].accepted is False + assert report.results[1].reason == "low_inliers"