Files
geointel/backend/app/services/reviewed_metrics_service.py
T
Jens faeb58ef6d
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
Initial public release
2026-08-31 21:56:53 +02:00

113 lines
4.8 KiB
Python

"""Detection metrics after an operator has adjudicated the evidence.
The review vocabulary already separates a model error from a reference gap,
because the product's position is that official footprints are not
automatically perfect ground truth. Until now those verdicts were only counted:
an operator who established that twelve of forty false positives are buildings
the reference simply lacks still saw a precision counting all forty against the
model — a number they had personally disproved.
Applying the verdicts gives an adjudicated score. Because part of the evidence
is usually still unreviewed, the honest form is an interval: pessimistic
assumes every unreviewed item is a model error, optimistic assumes none is. The
raw score stays reported alongside, so nothing is quietly improved.
"""
from __future__ import annotations
from typing import Any
# A verdict that the finding was not the model's fault. The detection (or the
# missing detection) was right; the reference or the matching rule was not.
EXONERATING_DECISIONS = frozenset({"reference_gap_or_change", "qa_alignment_mismatch"})
# Verdicts that confirm the finding, and verdicts that reach no conclusion.
# Both keep counting: being unable to judge is not evidence in the model's
# favour, and treating it as such is how a score drifts upward unearned.
CONFIRMING_DECISIONS = frozenset(
{"confirmed_model_false_positive", "confirmed_model_false_negative"}
)
INCONCLUSIVE_DECISIONS = frozenset({"uncertain", "imagery_obscured_or_uncertain"})
def _score(matches: int, false_positives: int, false_negatives: int) -> dict[str, Any]:
precision = matches / (matches + false_positives) if matches + false_positives > 0 else None
recall = matches / (matches + false_negatives) if matches + false_negatives > 0 else None
f1_score = None
if precision is not None and recall is not None:
f1_score = (2 * precision * recall / (precision + recall)) if precision + recall > 0 else 0.0
return {
"matches": matches,
"false_positives": false_positives,
"false_negatives": false_negatives,
"precision": precision,
"recall": recall,
"f1_score": f1_score,
}
class ReviewedMetricsService:
@staticmethod
def _adjudicate_role(total: int, decisions: dict[str, int]) -> tuple[int, int, int]:
"""Split a finding count into exonerated, confirmed and unreviewed.
A decision the runtime does not recognise — one from a later release —
counts as no judgement rather than as an exoneration.
"""
exonerated = sum(count for name, count in decisions.items() if name in EXONERATING_DECISIONS)
judged = sum(
count
for name, count in decisions.items()
if name in EXONERATING_DECISIONS | CONFIRMING_DECISIONS | INCONCLUSIVE_DECISIONS
)
exonerated = min(exonerated, total)
judged = min(judged, total)
return exonerated, judged - exonerated, max(0, total - judged)
@staticmethod
def adjudicate(
*,
matches: int,
false_positives: int,
false_negatives: int,
false_positive_decisions: dict[str, int],
false_negative_decisions: dict[str, int],
) -> dict[str, Any]:
"""Apply operator verdicts to a quality check's counts."""
fp_exonerated, _fp_confirmed, fp_unreviewed = ReviewedMetricsService._adjudicate_role(
false_positives, false_positive_decisions
)
fn_exonerated, _fn_confirmed, fn_unreviewed = ReviewedMetricsService._adjudicate_role(
false_negatives, false_negative_decisions
)
adjudicated_fp = false_positives - fp_exonerated
adjudicated_fn = false_negatives - fn_exonerated
# The interval covers what the unreviewed remainder could still turn
# out to be, so a partly reviewed check never reads as a settled one.
pessimistic = _score(matches, adjudicated_fp, adjudicated_fn)
optimistic = _score(matches, adjudicated_fp - fp_unreviewed, adjudicated_fn - fn_unreviewed)
return {
"raw": _score(matches, false_positives, false_negatives),
"adjudicated": pessimistic,
"pessimistic": pessimistic,
"optimistic": optimistic,
"review_complete": fp_unreviewed == 0 and fn_unreviewed == 0,
"false_positive_breakdown": {
"total": false_positives,
"exonerated": fp_exonerated,
"confirmed_or_inconclusive": false_positives - fp_exonerated - fp_unreviewed,
"unreviewed": fp_unreviewed,
},
"false_negative_breakdown": {
"total": false_negatives,
"exonerated": fn_exonerated,
"confirmed_or_inconclusive": false_negatives - fn_exonerated - fn_unreviewed,
"unreviewed": fn_unreviewed,
},
}