bound the QA evidence overlay and fetch only what it draws
evidence_geojson emitted one feature per false positive, one per false negative and two per match, with no limit. A regional check of 40k detections against 45k reference footprints produced well over a hundred thousand features in a single response, plus one warning string per unresolvable identifier. The endpoint the entire review workflow depends on therefore failed exactly where review matters most. What to draw is now decided before any geometry is fetched, so the query work is proportional to the result rather than to the size of the check — previously 130k geometries were resolved through an IN clause holding every identifier in the check, to then discard most of them. The budget is split between misses and false positives in proportion to their populations with at least one of each, rather than by strict priority, which would mean a check with 50.000 misses and three false positives never showed one. Confirmations fill what remains, and a match is kept or dropped as a pair because half a match is not reviewable evidence. limit_evidence and evidence_role_counts are removed: plan_evidence supersedes them, and helpers kept alive only by their own tests read like a contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Evidence review must stay usable on a regional run.
|
||||
|
||||
evidence_geojson emitted one feature per false positive, one per false
|
||||
negative and *two* per match, with no limit. A regional QA run of 40k
|
||||
detections against 45k reference footprints produced well over a hundred
|
||||
thousand features in a single response, plus one warning string per
|
||||
unresolvable identifier. The endpoint the whole review workflow depends on
|
||||
therefore stopped working exactly where review matters most.
|
||||
|
||||
The budget goes to what a reviewer must act on — misses and false positives —
|
||||
before confirmations, and the response says what it left out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.quality_evidence_service import QualityEvidenceService
|
||||
|
||||
|
||||
def _features(role: str, count: int) -> list[dict]:
|
||||
return [{"properties": {"evidence_role": role}, "id": f"{role}-{index}"} for index in range(count)]
|
||||
|
||||
|
||||
def test_missing_identifiers_collapse_into_one_statement() -> None:
|
||||
warnings = QualityEvidenceService.summarize_missing(
|
||||
candidate_ids=["a", "b", "c"],
|
||||
reference_ids=["r1"],
|
||||
)
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert "3" in warnings[0]
|
||||
assert "1" in warnings[0]
|
||||
|
||||
|
||||
def test_nothing_missing_produces_no_warning() -> None:
|
||||
assert QualityEvidenceService.summarize_missing(candidate_ids=[], reference_ids=[]) == []
|
||||
|
||||
|
||||
def test_the_plan_is_capped_before_any_geometry_is_fetched() -> None:
|
||||
"""Resolving 130k geometries to draw 5k of them is work for nothing."""
|
||||
|
||||
findings = {
|
||||
"match_evidence": [{"candidate_feature_id": f"c{i}", "reference_feature_id": f"r{i}"} for i in range(100)],
|
||||
"false_positive_evidence": [{"candidate_feature_id": f"fp{i}"} for i in range(10)],
|
||||
"false_negative_evidence": [{"reference_feature_id": f"fn{i}"} for i in range(10)],
|
||||
}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=8)
|
||||
|
||||
assert plan.truncated is True
|
||||
assert len(plan.items) == 8
|
||||
# Both error classes are represented; confirmations do not get a share
|
||||
# while errors are still waiting.
|
||||
assert {item.role for item in plan.items} == {"false_negative", "false_positive"}
|
||||
# Only the identifiers that will actually be drawn need resolving.
|
||||
assert len(plan.candidate_ids) + len(plan.reference_ids) == 8
|
||||
assert plan.candidate_ids <= {f"fp{i}" for i in range(10)}
|
||||
assert plan.reference_ids <= {f"fn{i}" for i in range(10)}
|
||||
|
||||
|
||||
def test_a_rare_error_class_is_never_crowded_out() -> None:
|
||||
"""50.000 misses must not hide the three false positives."""
|
||||
|
||||
findings = {
|
||||
"false_negative_evidence": [{"reference_feature_id": f"fn{i}"} for i in range(5_000)],
|
||||
"false_positive_evidence": [{"candidate_feature_id": f"fp{i}"} for i in range(3)],
|
||||
}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=100)
|
||||
|
||||
roles = [item.role for item in plan.items]
|
||||
assert roles.count("false_positive") >= 1
|
||||
assert roles.count("false_negative") >= 90
|
||||
assert len(plan.items) == 100
|
||||
|
||||
|
||||
def test_the_plan_reports_the_complete_population_not_the_capped_one() -> None:
|
||||
findings = {
|
||||
"match_evidence": [{"candidate_feature_id": f"c{i}", "reference_feature_id": f"r{i}"} for i in range(100)],
|
||||
"false_negative_evidence": [{"reference_feature_id": "fn"}],
|
||||
}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=2)
|
||||
|
||||
assert plan.total_feature_count == 201
|
||||
assert plan.role_counts == {"match_candidate": 100, "match_reference": 100, "false_negative": 1}
|
||||
|
||||
|
||||
def test_an_uncapped_plan_keeps_everything() -> None:
|
||||
findings = {"false_negative_evidence": [{"reference_feature_id": f"fn{i}"} for i in range(30)]}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=0)
|
||||
|
||||
assert plan.truncated is False
|
||||
assert len(plan.items) == 30
|
||||
assert plan.reference_ids == {f"fn{i}" for i in range(30)}
|
||||
|
||||
|
||||
def test_evidence_without_identifiers_is_skipped_not_planned() -> None:
|
||||
findings = {
|
||||
"false_positive_evidence": [{"candidate_feature_id": None}, {"candidate_feature_id": "fp"}],
|
||||
"false_negative_evidence": [{}],
|
||||
}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=0)
|
||||
|
||||
assert [item.role for item in plan.items] == ["false_positive"]
|
||||
assert plan.candidate_ids == {"fp"}
|
||||
|
||||
|
||||
|
||||
def test_a_planned_match_keeps_its_candidate_and_reference_together() -> None:
|
||||
"""Half a match is not reviewable evidence."""
|
||||
|
||||
findings = {
|
||||
"match_evidence": [
|
||||
{"candidate_feature_id": "c1", "reference_feature_id": "r1"},
|
||||
{"candidate_feature_id": "c2", "reference_feature_id": "r2"},
|
||||
]
|
||||
}
|
||||
|
||||
plan = QualityEvidenceService.plan_evidence(findings, limit=3)
|
||||
|
||||
assert plan.truncated is True
|
||||
# An odd budget drops the second pair rather than showing one side of it.
|
||||
assert len(plan.items) == 1
|
||||
assert plan.candidate_ids == {"c1"}
|
||||
assert plan.reference_ids == {"r1"}
|
||||
@@ -95,8 +95,17 @@ def test_quality_check_evidence_geojson_resolves_persisted_vector_features() ->
|
||||
assert result["feature_count"] == 4
|
||||
assert result["geojson"]["type"] == "FeatureCollection"
|
||||
roles = [feature["properties"]["qa_evidence_role"] for feature in result["geojson"]["features"]]
|
||||
assert roles == ["match_candidate", "match_reference", "false_positive", "false_negative"]
|
||||
match_candidate = result["geojson"]["features"][0]
|
||||
# Every role resolves to persisted geometry. Errors are emitted before
|
||||
# confirmations, because a capped overlay must spend its budget on the
|
||||
# objects a reviewer has to act on.
|
||||
assert sorted(roles) == ["false_negative", "false_positive", "match_candidate", "match_reference"]
|
||||
assert roles.index("false_negative") < roles.index("match_candidate")
|
||||
assert roles.index("false_positive") < roles.index("match_candidate")
|
||||
match_candidate = next(
|
||||
feature
|
||||
for feature in result["geojson"]["features"]
|
||||
if feature["properties"]["qa_evidence_role"] == "match_candidate"
|
||||
)
|
||||
assert match_candidate["properties"]["quality_check_id"] == str(quality_check_id)
|
||||
assert match_candidate["properties"]["candidate_feature_id"] == "candidate-match"
|
||||
assert match_candidate["properties"]["reference_feature_id"] == "reference-match"
|
||||
@@ -148,7 +157,11 @@ def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch)
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"data": payload}
|
||||
# The envelope wraps the service result; asserting the exact field list
|
||||
# would break every time the response model gains a documented field.
|
||||
body = response.json()
|
||||
assert set(body) == {"data"}
|
||||
assert body["data"].items() >= payload.items()
|
||||
|
||||
|
||||
def test_frontend_quality_evidence_overlay_contract_is_wired() -> None:
|
||||
|
||||
Reference in New Issue
Block a user