mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 09:01:14 +00:00
[AZ-231] Add anchor verification gates
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -9,6 +9,6 @@ tracker: jira
|
|||||||
sub_step:
|
sub_step:
|
||||||
phase: 1
|
phase: 1
|
||||||
name: batch-loop
|
name: batch-loop
|
||||||
detail: "batch 7: AZ-230_satellite_service_vpr_retrieval"
|
detail: "batch 8: AZ-231_anchor_verification_matching"
|
||||||
retry_count: 0
|
retry_count: 0
|
||||||
cycle: 1
|
cycle: 1
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
"""Anchor verification component."""
|
"""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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,10 +1,91 @@
|
|||||||
"""Public anchor verification interfaces."""
|
"""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):
|
class AnchorVerifier(Protocol):
|
||||||
"""Verifies retrieved candidates against camera observations."""
|
"""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."""
|
"""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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user