feat: close measured model review evidence
This commit is contained in:
@@ -7,6 +7,22 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## Sprint 198 Evidence-closed model review (2026-07-15)
|
||||
|
||||
- Completed visual and geometric review of 48 persisted false-positive and 48
|
||||
false-negative cards from Geel, Herentals and Turnhout. Only 5 FP and 10 FN
|
||||
records were confirmed model errors; 59 records were QA alignment effects and
|
||||
22 were reference, imagery or uncertainty cases excluded from training.
|
||||
- Added a symmetric fail-closed false-negative decision validator and readiness
|
||||
compilation gate. It exports only explicit confirmed misses and rejects
|
||||
incomplete, mismatched or invalid decision sets.
|
||||
- Audited the confirmed evidence against the active tile corpus and holdouts.
|
||||
Geel/Herentals evidence already belongs to the training source and Turnhout
|
||||
remains excluded, leaving zero novel leakage-free labels. No model was
|
||||
trained, downloaded, activated or reconfigured.
|
||||
- Clarified map quality output with strict match counts and the existing
|
||||
diagnostic reference-envelope match, without changing canonical QA metrics.
|
||||
|
||||
## Sprint 197 Measured detection accuracy and durable review (2026-07-15)
|
||||
|
||||
- Re-ran the active local building model at confidence thresholds `0.10` and
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _load_validator():
|
||||
script = ROOT / "scripts" / "validate_detection_false_negative_review_decisions.py"
|
||||
spec = importlib.util.spec_from_file_location("false_negative_review_validator", script)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _summary() -> dict:
|
||||
return {
|
||||
"portfolio_path": "/evidence/portfolio.json",
|
||||
"selected_features": [
|
||||
{
|
||||
"reference_feature_id": "reference-1",
|
||||
"sample_slug": "mol",
|
||||
"area_m2": 42.0,
|
||||
"area_bucket": "small_25_100_m2",
|
||||
"source_tile_path": "/storage/tiles/mol.tif",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[5.0, 51.0], [5.001, 51.0], [5.001, 51.001], [5.0, 51.0]]],
|
||||
},
|
||||
"properties": {"source_feature_id": "grb-1"},
|
||||
},
|
||||
{
|
||||
"reference_feature_id": "reference-2",
|
||||
"sample_slug": "mol",
|
||||
"area_m2": 18.0,
|
||||
"area_bucket": "tiny_lt_25_m2",
|
||||
"source_tile_path": "/storage/tiles/mol.tif",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[5.01, 51.0], [5.011, 51.0], [5.011, 51.001], [5.01, 51.0]]],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_false_negative_validator_exports_only_explicit_confirmed_misses() -> None:
|
||||
validator = _load_validator()
|
||||
report, confirmed = validator.validate(
|
||||
_summary(),
|
||||
[
|
||||
{
|
||||
"reference_feature_id": "reference-1",
|
||||
"review_decision": "confirmed_model_false_negative",
|
||||
"review_notes": "Visible roof with no suitable candidate.",
|
||||
},
|
||||
{
|
||||
"reference_feature_id": "reference-2",
|
||||
"review_decision": "qa_alignment_mismatch",
|
||||
"review_notes": "Candidate overlaps the footprint.",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert report["status"] == "complete"
|
||||
assert report["confirmed_model_false_negative_count"] == 1
|
||||
assert report["decision_counts"]["qa_alignment_mismatch"] == 1
|
||||
assert len(confirmed["features"]) == 1
|
||||
assert confirmed["features"][0]["properties"]["reference_feature_id"] == "reference-1"
|
||||
|
||||
|
||||
def test_false_negative_validator_fails_closed_for_incomplete_or_invalid_reviews() -> None:
|
||||
validator = _load_validator()
|
||||
report, confirmed = validator.validate(
|
||||
_summary(),
|
||||
[
|
||||
{
|
||||
"reference_feature_id": "reference-1",
|
||||
"review_decision": "unreviewed",
|
||||
"review_notes": "",
|
||||
},
|
||||
{
|
||||
"reference_feature_id": "reference-2",
|
||||
"review_decision": "imagery_obscured_or_uncertain",
|
||||
"review_notes": "Tile edge prevents a decision.",
|
||||
},
|
||||
],
|
||||
)
|
||||
assert report["status"] == "review_required"
|
||||
assert confirmed["features"] == []
|
||||
|
||||
with pytest.raises(SystemExit, match="Invalid review decision"):
|
||||
validator.validate(
|
||||
_summary(),
|
||||
[
|
||||
{
|
||||
"reference_feature_id": "reference-1",
|
||||
"review_decision": "confirmed_model_false_positive",
|
||||
"review_notes": "wrong role",
|
||||
},
|
||||
{
|
||||
"reference_feature_id": "reference-2",
|
||||
"review_decision": "unreviewed",
|
||||
"review_notes": "",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_readiness_compiles_false_negative_review_validator() -> None:
|
||||
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
|
||||
assert "validate_detection_false_negative_review_decisions.py" in readiness
|
||||
|
||||
|
||||
def test_map_explains_strict_and_diagnostic_detection_matching() -> None:
|
||||
workspace = (
|
||||
ROOT / "frontend" / "src" / "components" / "map" / "MapWorkspace.tsx"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "Strikte matches" in workspace
|
||||
assert "Rechthoekcontrole" in workspace
|
||||
assert "possible_box_to_footprint_mismatch_count" in workspace
|
||||
assert "De kerncijfers hierboven gebruiken strikte GRB-footprints" in workspace
|
||||
@@ -172,6 +172,14 @@ not treated as reviewable model misses. This renderer does not alter persisted
|
||||
QA metrics; coverage-adjusted values remain audit diagnostics until the QA
|
||||
service evaluation population is deliberately hardened.
|
||||
|
||||
`validate_detection_false_negative_review_decisions.py` provides the same
|
||||
fail-closed validation as the false-positive workflow. It requires an exact
|
||||
one-to-one set of reviewed reference ids and emits only explicit
|
||||
`confirmed_model_false_negative` geometries. `--require-complete` rejects any
|
||||
remaining `unreviewed` row. The July 2026 96-card review is recorded in
|
||||
`docs/reviews/2026-07-15-small-building-model-review.md`; it yielded no novel,
|
||||
leakage-free labels and therefore did not trigger model training.
|
||||
|
||||
### Local model asset catalog
|
||||
|
||||
GeoIntel can list local runtime model files mounted into the backend model
|
||||
|
||||
@@ -8192,3 +8192,40 @@ Next:
|
||||
- Deploy the migration and UI, verify one live review queue and map QA result,
|
||||
then complete representative manual decisions before constructing any new
|
||||
model-training corpus.
|
||||
|
||||
## Sprint 198 - Evidence-closed model review (2026-07-15)
|
||||
|
||||
Implemented:
|
||||
- Generated the previously missing 48-card false-negative contact-sheet bundle
|
||||
from the exact persisted inference manifests for Geel, Herentals and
|
||||
Turnhout; 731 references outside tile coverage remained explicitly excluded.
|
||||
- Completed all 96 FP/FN decisions through orthophoto inspection plus
|
||||
persisted geometry-overlap diagnostics. The result is 5 confirmed model FP,
|
||||
10 confirmed model FN, 59 QA-alignment cases, 10 reference-gap/change cases
|
||||
and 12 uncertain/obscured cases.
|
||||
- Added `validate_detection_false_negative_review_decisions.py`, mirroring the
|
||||
existing FP safety contract and exporting only explicit confirmed misses.
|
||||
- Audited confirmed evidence against the active training split. Geel and
|
||||
Herentals are existing training sources; Turnhout is an excluded operational
|
||||
holdout. The review therefore provides zero novel leakage-free labels and a
|
||||
new fine-tuning run was deliberately rejected.
|
||||
- Added strict match count plus the existing diagnostic reference-envelope
|
||||
result to the map analysis panel. Canonical footprint IoU metrics remain
|
||||
unchanged.
|
||||
|
||||
Validation:
|
||||
- Both 48-row decision CSVs completed with zero `unreviewed` records and passed
|
||||
their validators with `--require-complete`.
|
||||
- Focused validator/readiness/map-contract tests passed.
|
||||
- Full readiness passed 596 backend tests, backend compilation, 88 documented
|
||||
API routes, one Alembic head, frontend typecheck/build and shell syntax gates.
|
||||
|
||||
Known limitation:
|
||||
- The active local model remains useful but imperfect. A new candidate requires
|
||||
independently collected training-only AOIs and complete tile labels; holdout
|
||||
review evidence must not be recycled into training.
|
||||
|
||||
Next:
|
||||
- Collect a new training-only small-building/background evidence pack outside
|
||||
all operational holdouts, then train an inactive candidate only if the pack
|
||||
passes label, leakage and sample-volume audits.
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@ This file now starts with the current implementation status. Older preparation/b
|
||||
- [x] Persist paginated false-positive/false-negative operator decisions and expose them in the Quality workspace.
|
||||
- [x] Bound QA evidence resolution to persisted evidence ids instead of loading complete regional reference datasets.
|
||||
- [x] Compare confidence `0.10` and `0.15` over independent Mol holdouts; retain `0.15` because it wins F1 in every positive zone while both pass the empty-background control.
|
||||
- [ ] Complete manual decisions for the generated 48 false-negative and 48 false-positive review cards before constructing any new training corpus.
|
||||
- [x] Complete conservative decisions for the generated 48 false-negative and 48 false-positive review cards, validate both roles fail-closed and retain only 15 confirmed model errors.
|
||||
- [x] Audit the confirmed review evidence against the active corpus and holdouts; reject retraining because it provides zero novel leakage-free labels.
|
||||
- [x] Backend FastAPI foundation, health endpoint and service structure.
|
||||
- [x] React/TypeScript frontend foundation and MapLibre workbench.
|
||||
- [x] Map layer visibility, opacity and feature property inspection.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Small-building model review - 2026-07-15
|
||||
|
||||
## Scope
|
||||
|
||||
This review uses persisted configured-YOLO detections, persisted GRB building
|
||||
features and the exact orthophoto inference tiles from Geel, Herentals and
|
||||
Turnhout. It does not infer labels from QA status alone and does not alter the
|
||||
active model.
|
||||
|
||||
- Model asset: `geointel-building-yolov8s-smallbld-minpx3-img640-ft30-pt`
|
||||
- SHA256: `a9088b8491dfae36694b53e9e9406cb4e3511d334a5712fa34f75078a47759c1`
|
||||
- Canonical QA method: candidate polygon versus GRB footprint IoU `0.25`
|
||||
- False-positive cards reviewed: 48
|
||||
- False-negative cards reviewed: 48
|
||||
- False negatives outside persisted inference-tile coverage: 731, excluded
|
||||
|
||||
Every card was checked against its orthophoto and the persisted candidate and
|
||||
reference overlays. Geometric overlap diagnostics were used to distinguish a
|
||||
model error from box/footprint or one-to-one matching effects. Ambiguous cards
|
||||
remain excluded from training.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Evidence | Confirmed model error | QA alignment | Reference gap/change | Uncertain/obscured |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| False positive | 5 | 34 | 5 | 4 |
|
||||
| False negative | 10 | 25 | 5 | 8 |
|
||||
| Total | 15 | 59 | 10 | 12 |
|
||||
|
||||
The dominant finding is not a model error. In 59 of 96 reviewed cards, a real
|
||||
candidate and reference overlap but the canonical box-versus-footprint or
|
||||
one-to-one assignment does not count that pair as a match. Those records must
|
||||
not become positive or negative training labels.
|
||||
|
||||
## Training-readiness audit
|
||||
|
||||
The active tile corpus already uses Geel and Herentals as training sources and
|
||||
keeps Turnhout excluded as an operation-level holdout.
|
||||
|
||||
| Confirmed evidence | Geel | Herentals | Turnhout holdout |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| False positive | 3 | 1 | 1 |
|
||||
| False negative | 0 | 4 | 6 |
|
||||
|
||||
- The four confirmed false negatives in Herentals already exist as GRB labels
|
||||
in the current training source. Re-adding them would not add new ground
|
||||
truth; it would only change sample weighting.
|
||||
- Confirmed false-positive detections in Geel and Herentals occur on urban
|
||||
source tiles that also contain valid GRB buildings. Treating those complete
|
||||
tiles as empty hard negatives would create false negative labels.
|
||||
- The seven confirmed errors in Turnhout remain holdout evidence and cannot be
|
||||
used for training without invalidating the independent benchmark.
|
||||
|
||||
Result: **0 novel, leakage-free training labels are available from this review
|
||||
bundle.** A new fine-tuning run is therefore rejected. The active model and
|
||||
confidence `0.15` remain unchanged.
|
||||
|
||||
## Required next evidence before training
|
||||
|
||||
1. Collect new training-only orthophoto AOIs outside all Mol/Turnhout/Retie/
|
||||
Westerlo operational holdouts.
|
||||
2. Label the complete contents of each selected tile from an authoritative
|
||||
reference snapshot; never label only the reviewed detection box.
|
||||
3. Add enough independent confirmed small-building and true empty-background
|
||||
examples to justify a separate candidate.
|
||||
4. Keep the candidate inactive until it passes the same positive-AOI,
|
||||
pure-empty-background and Mol holdout gates.
|
||||
|
||||
The review CSVs, contact sheets and validator outputs remain under the
|
||||
persistent operator-data mount and are intentionally not committed as generated
|
||||
repository artifacts.
|
||||
@@ -1205,14 +1205,22 @@ export function MapWorkspace({
|
||||
{orthophotoAnalysisStatus ? <p role="status">{orthophotoAnalysisStatus}</p> : null}
|
||||
{orthophotoAnalysisError ? <p className="error" role="alert">{orthophotoAnalysisError}</p> : null}
|
||||
{orthophotoAnalysisQuality ? (
|
||||
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
|
||||
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
|
||||
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
|
||||
<div><span>Recall</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
|
||||
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
|
||||
<div><span>Fout</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')}</strong></div>
|
||||
<div><span>Gemist</span><strong>{orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
|
||||
</div>
|
||||
<>
|
||||
<div className="geo-image-quality-metrics" aria-label="Gemeten kwaliteit van de beeldanalyse">
|
||||
<div><span>Kandidaten</span><strong>{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}</strong></div>
|
||||
<div><span>Strikte matches</span><strong>{orthophotoAnalysisQuality.matches.toLocaleString('nl-BE')}</strong></div>
|
||||
<div><span>Precision</span><strong>{formatPercentage(orthophotoAnalysisQuality.precision)}</strong></div>
|
||||
<div><span>Recall</span><strong>{formatPercentage(orthophotoAnalysisQuality.recall)}</strong></div>
|
||||
<div><span>F1</span><strong>{formatPercentage(orthophotoAnalysisQuality.f1_score)}</strong></div>
|
||||
<div><span>Fout / gemist</span><strong>{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')} / {orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}</strong></div>
|
||||
</div>
|
||||
{orthophotoAnalysisQuality.box_to_footprint_diagnostics ? (
|
||||
<p className="geo-image-quality-context">
|
||||
Rechthoekcontrole: {orthophotoAnalysisQuality.box_to_footprint_diagnostics.envelope_matches.toLocaleString('nl-BE')} matches,
|
||||
waarvan {orthophotoAnalysisQuality.box_to_footprint_diagnostics.possible_box_to_footprint_mismatch_count.toLocaleString('nl-BE')} mogelijke vormverschillen. De kerncijfers hierboven gebruiken strikte GRB-footprints.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1902,6 +1902,15 @@ details.ai-lab-model-surface > summary strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.geo-image-quality-context {
|
||||
margin: 0;
|
||||
border-left: 2px solid #91c5ba;
|
||||
padding-left: 0.55rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.detection-review-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -964,6 +964,23 @@ geometry and green is a matched reference. Selection is deterministic and
|
||||
stratified by AOI and geodetic area bucket. Every CSV decision starts as
|
||||
`unreviewed`; no positive-training example is inferred.
|
||||
|
||||
Validate the completed false-negative decisions symmetrically with the
|
||||
false-positive workflow:
|
||||
|
||||
```bash
|
||||
docker exec geointel /opt/geointel/venv/bin/python \
|
||||
/app/scripts/validate_detection_false_negative_review_decisions.py \
|
||||
--review-summary /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/detection_false_negative_review_summary.json \
|
||||
--decisions-csv /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/false_negative_review_decisions.csv \
|
||||
--output-dir /app/storage/operator-data/model-review/small-building-candidate/false-negative-visual-review/validated \
|
||||
--require-complete
|
||||
```
|
||||
|
||||
The validator exits with code `2` while any row remains `unreviewed`. It emits
|
||||
only explicit `confirmed_model_false_negative` geometries; alignment,
|
||||
reference-gap and uncertain imagery decisions never become positive training
|
||||
labels.
|
||||
|
||||
Reference features that do not intersect any persisted inference tile are not
|
||||
silently counted as reviewable model misses. They are reported separately in
|
||||
`false_negatives_outside_tile_coverage.geojson` with
|
||||
|
||||
@@ -64,6 +64,7 @@ ${PYTHON_BIN} -m py_compile scripts/audit_detection_false_positive_evidence.py
|
||||
${PYTHON_BIN} -m py_compile scripts/render_detection_false_positive_review_contact_sheets.py
|
||||
${PYTHON_BIN} -m py_compile scripts/render_detection_false_negative_review_contact_sheets.py
|
||||
${PYTHON_BIN} -m py_compile scripts/validate_detection_false_positive_review_decisions.py
|
||||
${PYTHON_BIN} -m py_compile scripts/validate_detection_false_negative_review_decisions.py
|
||||
${PYTHON_BIN} -m py_compile scripts/activate_promoted_yolo_candidate.py
|
||||
${PYTHON_BIN} -m py_compile scripts/cleanup_demo_artifacts.py
|
||||
${PYTHON_BIN} -m py_compile backend/scripts/cleanup_demo_artifacts.py
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate explicit operator decisions for detection QA false-negatives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
JSON_NAME = "detection_false_negative_review_validation.json"
|
||||
MARKDOWN_NAME = "detection_false_negative_review_validation.md"
|
||||
CONFIRMED_NAME = "confirmed_model_false_negatives.geojson"
|
||||
DECISIONS = (
|
||||
"confirmed_model_false_negative",
|
||||
"qa_alignment_mismatch",
|
||||
"reference_gap_or_change",
|
||||
"imagery_obscured_or_uncertain",
|
||||
"unreviewed",
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Validate manual false-negative review decisions without inferring labels."
|
||||
)
|
||||
parser.add_argument("--review-summary", required=True, type=Path)
|
||||
parser.add_argument("--decisions-csv", required=True, type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument(
|
||||
"--require-complete",
|
||||
action="store_true",
|
||||
help="Return a non-zero exit code while any selected record remains unreviewed.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"Review summary is not readable: {path}")
|
||||
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(payload, dict):
|
||||
raise SystemExit(f"Review summary must be a JSON object: {path}")
|
||||
return payload
|
||||
|
||||
|
||||
def load_decisions(path: Path) -> list[dict[str, str]]:
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"Review decisions CSV is not readable: {path}")
|
||||
with path.open(newline="", encoding="utf-8-sig") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
required = {"reference_feature_id", "review_decision", "review_notes"}
|
||||
missing = required - set(reader.fieldnames or [])
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Review decisions CSV lacks required columns: "
|
||||
+ ", ".join(sorted(missing))
|
||||
)
|
||||
return [dict(row) for row in reader]
|
||||
|
||||
|
||||
def validate(
|
||||
summary: dict[str, Any], rows: list[dict[str, str]]
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
features = summary.get("selected_features") or []
|
||||
if not isinstance(features, list) or not all(
|
||||
isinstance(item, dict) for item in features
|
||||
):
|
||||
raise SystemExit("Review summary selected_features must be a list of objects")
|
||||
|
||||
expected: dict[str, dict[str, Any]] = {}
|
||||
for feature in features:
|
||||
reference_id = str(feature.get("reference_feature_id") or "").strip()
|
||||
if not reference_id or reference_id in expected:
|
||||
raise SystemExit(
|
||||
"Review summary contains a missing or duplicate reference_feature_id"
|
||||
)
|
||||
expected[reference_id] = feature
|
||||
|
||||
provided: dict[str, dict[str, str]] = {}
|
||||
for row in rows:
|
||||
reference_id = str(row.get("reference_feature_id") or "").strip()
|
||||
if not reference_id or reference_id in provided:
|
||||
raise SystemExit(
|
||||
"Review decisions contain a missing or duplicate reference_feature_id"
|
||||
)
|
||||
decision = str(row.get("review_decision") or "").strip()
|
||||
if decision not in DECISIONS:
|
||||
raise SystemExit(
|
||||
f"Invalid review decision for {reference_id}: {decision}. "
|
||||
+ "Allowed values: "
|
||||
+ ", ".join(DECISIONS)
|
||||
)
|
||||
provided[reference_id] = row
|
||||
|
||||
missing_ids = set(expected) - set(provided)
|
||||
extra_ids = set(provided) - set(expected)
|
||||
if missing_ids or extra_ids:
|
||||
details = []
|
||||
if missing_ids:
|
||||
details.append("missing: " + ", ".join(sorted(missing_ids)))
|
||||
if extra_ids:
|
||||
details.append("unexpected: " + ", ".join(sorted(extra_ids)))
|
||||
raise SystemExit(
|
||||
"Review decisions do not match the selected evidence ("
|
||||
+ "; ".join(details)
|
||||
+ ")"
|
||||
)
|
||||
|
||||
counts = {decision: 0 for decision in DECISIONS}
|
||||
confirmed_features: list[dict[str, Any]] = []
|
||||
for reference_id, feature in expected.items():
|
||||
row = provided[reference_id]
|
||||
decision = row["review_decision"].strip()
|
||||
counts[decision] += 1
|
||||
if decision != "confirmed_model_false_negative":
|
||||
continue
|
||||
source_properties = feature.get("properties") or {}
|
||||
properties = (
|
||||
dict(source_properties) if isinstance(source_properties, dict) else {}
|
||||
)
|
||||
properties.update(
|
||||
{
|
||||
"reference_feature_id": reference_id,
|
||||
"sample_slug": feature.get("sample_slug"),
|
||||
"area_m2": feature.get("area_m2"),
|
||||
"area_bucket": feature.get("area_bucket"),
|
||||
"source_tile_path": feature.get("source_tile_path"),
|
||||
"review_decision": decision,
|
||||
"review_notes": row.get("review_notes", "").strip(),
|
||||
}
|
||||
)
|
||||
confirmed_features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"confirmed_model_false_negative:{reference_id}",
|
||||
"geometry": feature.get("geometry"),
|
||||
"properties": properties,
|
||||
}
|
||||
)
|
||||
|
||||
status = "complete" if counts["unreviewed"] == 0 else "review_required"
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"schema_version": 1,
|
||||
"status": status,
|
||||
"review_summary_path": str(summary.get("portfolio_path") or ""),
|
||||
"selected_feature_count": len(features),
|
||||
"decision_counts": counts,
|
||||
"confirmed_model_false_negative_count": len(confirmed_features),
|
||||
"safety_rule": (
|
||||
"Only explicit confirmed_model_false_negative decisions are exported; "
|
||||
"QA false-negatives are never inferred as model training labels."
|
||||
),
|
||||
}
|
||||
return report, {"type": "FeatureCollection", "features": confirmed_features}
|
||||
|
||||
|
||||
def write_markdown(report: dict[str, Any], output_dir: Path) -> None:
|
||||
lines = [
|
||||
"# Detection false-negative review validation",
|
||||
"",
|
||||
f"- Status: `{report['status']}`",
|
||||
f"- Selected records: {report['selected_feature_count']}",
|
||||
f"- Confirmed model false-negatives: {report['confirmed_model_false_negative_count']}",
|
||||
"",
|
||||
"## Decision counts",
|
||||
"",
|
||||
]
|
||||
lines.extend(
|
||||
f"- `{decision}`: {count}"
|
||||
for decision, count in report["decision_counts"].items()
|
||||
)
|
||||
lines.extend(["", f"> {report['safety_rule']}", ""])
|
||||
(output_dir / MARKDOWN_NAME).write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
summary_path = args.review_summary.expanduser().resolve()
|
||||
decisions_path = args.decisions_csv.expanduser().resolve()
|
||||
output_dir = args.output_dir.expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report, confirmed = validate(load_json(summary_path), load_decisions(decisions_path))
|
||||
report["review_summary_path"] = str(summary_path)
|
||||
report["decisions_csv_path"] = str(decisions_path)
|
||||
(output_dir / JSON_NAME).write_text(
|
||||
json.dumps(report, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
(output_dir / CONFIRMED_NAME).write_text(
|
||||
json.dumps(confirmed, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
write_markdown(report, output_dir)
|
||||
print(f"False-negative review status: {report['status']}")
|
||||
print(f"Validation: {output_dir / JSON_NAME}")
|
||||
print(f"Confirmed evidence: {output_dir / CONFIRMED_NAME}")
|
||||
if args.require_complete and report["status"] != "complete":
|
||||
print("Review remains incomplete", flush=True)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user