feat: close measured model review evidence
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 03:34:04 +02:00
parent 2a9b48d2b5
commit 919e102069
11 changed files with 512 additions and 9 deletions
+16
View File
@@ -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
+8
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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.
+16 -8
View File
@@ -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}
+9
View File
@@ -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;
+17
View File
@@ -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
+1
View File
@@ -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())