feat: add coverage-aware Mol benchmark
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-14 12:27:48 +02:00
parent 4f0d2891dd
commit 9f61037794
12 changed files with 682 additions and 10 deletions
+8
View File
@@ -7,6 +7,14 @@
# Changelog
## Sprint 185 Coverage-aware Mol operational benchmark (2026-07-14)
- Extended real detection quality-matrix evidence with persisted inference coverage, raw/evaluated/excluded/clipped populations and diagnostic-only box-to-footprint mismatch counts.
- Preserved municipality, operational-zone, split and source-reference metadata in combined multi-sample summaries.
- Added a fail-closed Mol benchmark report that groups exact model/tile/overlap/threshold candidates and gates four independent positive holdouts plus a pure-empty background control.
- Kept canonical footprint-IoU metrics authoritative and made model-quality rejection a reported evidence outcome rather than a hidden runner failure or automatic model mutation.
- Added readiness, all-in-one image and focused pass/reject regression coverage without changing APIs, migrations, inference behavior or dependencies.
## Sprint 184 Detection QA coverage and matching diagnostics (2026-07-14)
- Clipped configured-YOLO candidate and reference QA populations to the union of the exact persisted inference tile footprints before canonical IoU matching.
+4 -1
View File
@@ -494,7 +494,10 @@ Mol additionally has operational holdouts for Achterbos, Gompel, Donk and
Postel, with Mol center as the historical baseline and Postel-bos as a separate
background control. Prepare and execute that pack with the documented
`prepare_operator_real_data_samples.py` and
`run_mol_operational_validation.sh` commands in `scripts/README.md`.
`run_mol_operational_validation.sh` commands in `scripts/README.md`. The runner
produces a coverage-aware operational decision report: canonical footprint-IoU
metrics remain authoritative, reference-envelope matches remain diagnostic,
and no report can activate or mutate a model asset.
For municipality-wide navigation, run
`/app/scripts/provision_mol_municipality_workspace.py` inside the all-in-one
@@ -0,0 +1,185 @@
from __future__ import annotations
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
def positive_item(slug: str, *, f1: float = 0.6, coverage: bool = True) -> dict:
matches = 60
false_positives = 30
false_negatives = 50
return {
"sample_slug": slug,
"sample_display_name": f"Mol {slug}",
"municipality": "Mol",
"operational_zone": slug,
"recommended_split": "val",
"model_asset_id": "mol-model",
"tile_size": 512,
"tile_overlap": 64,
"threshold": 0.15,
"project_id": f"project-{slug}",
"area_id": f"area-{slug}",
"analysis_run_id": f"run-{slug}",
"quality_check_id": f"quality-{slug}",
"detection_count": 90,
"precision": 0.6666666667,
"recall": 0.5454545455,
"f1_score": f1,
"mean_iou": 0.65,
"matches": matches,
"false_positives": false_positives,
"false_negatives": false_negatives,
"coverage_applied": coverage,
"coverage_mode": "persisted_tile_manifest_union",
"coverage_tile_count": 9,
"candidate_raw_count": 90,
"candidate_evaluated_count": 90,
"candidate_excluded_outside_count": 0,
"candidate_clipped_boundary_count": 3,
"reference_raw_count": 112,
"reference_evaluated_count": 110,
"reference_excluded_outside_count": 2,
"reference_clipped_boundary_count": 4,
"reference_coverage_ratio": 110 / 112,
"diagnostic_only": True,
"strict_matches": matches,
"envelope_matches": 72,
"possible_box_to_footprint_mismatch_count": 12,
"envelope_precision": 0.8,
"envelope_recall": 0.65,
"envelope_f1_score": 0.717,
}
def write_inputs(tmp_path: Path, *, rejected: bool = False) -> tuple[Path, Path, Path]:
slugs = ["mol_achterbos", "mol_gompel", "mol_donk", "mol_postel"]
items = [
positive_item(slug, f1=0.05 if rejected and slug == "mol_postel" else 0.6, coverage=not rejected)
for slug in slugs
]
positive_path = tmp_path / "positive.json"
positive_path.write_text(json.dumps({"items": items}), encoding="utf-8")
background_path = tmp_path / "background.json"
background_path.write_text(
json.dumps(
{
"items": [
{
"sample_slug": "postel_bos",
"background_category": "pure_empty_negative",
"model_asset_id": "mol-model",
"tile_size": 512,
"tile_overlap": 64,
"threshold": 0.15,
"project_id": "project-background",
"area_id": "area-background",
"analysis_run_id": "run-background",
"tile_count": 9,
"detection_count": 2 if rejected else 0,
}
]
}
),
encoding="utf-8",
)
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"samples": [
{
"sample_slug": slug,
"display_name": f"Mol {slug}",
"municipality": "Mol",
"operational_zone": slug,
"recommended_split": "val",
}
for slug in slugs
]
}
),
encoding="utf-8",
)
return positive_path, background_path, manifest_path
def run_report(tmp_path: Path, *, rejected: bool = False) -> dict:
positive, background, manifest = write_inputs(tmp_path, rejected=rejected)
output_dir = tmp_path / "report"
result = subprocess.run(
[
sys.executable,
str(ROOT / "scripts" / "build_mol_operational_benchmark_report.py"),
"--positive-summary",
str(positive),
"--background-summary",
str(background),
"--manifest-path",
str(manifest),
"--output-dir",
str(output_dir),
"--base-url",
"http://example.test",
],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
assert "Mol operational benchmark report passed" in result.stdout
assert (output_dir / "mol_operational_benchmark_report.md").is_file()
return json.loads((output_dir / "mol_operational_benchmark_report.json").read_text(encoding="utf-8"))
def test_mol_benchmark_accepts_coverage_safe_multi_zone_evidence(tmp_path: Path) -> None:
report = run_report(tmp_path)
assert report["status"] == "accepted"
assert report["recommendation"] == "retain_or_promote_candidate"
decision = report["recommended_candidate"]
assert decision["decision"] == "operationally_accepted"
assert decision["positive_sample_count"] == 4
assert decision["background_sample_count"] == 1
assert decision["total_references_raw"] == 448
assert decision["total_references_evaluated"] == 440
assert decision["total_box_to_footprint_mismatch_count"] == 48
assert decision["total_background_detections"] == 0
assert decision["failed_gates"] == []
def test_mol_benchmark_rejects_missing_coverage_zone_collapse_and_background_pressure(tmp_path: Path) -> None:
report = run_report(tmp_path, rejected=True)
assert report["status"] == "review_required"
assert report["recommended_candidate"] is None
decision = report["candidate_decisions"][0]
assert decision["decision"] == "review_required"
assert "coverage_provenance" in decision["failed_gates"]
assert "minimum_zone_f1" in decision["failed_gates"]
assert "background_false_positive_pressure" in decision["failed_gates"]
def test_mol_benchmark_is_wired_into_existing_operator_pipeline() -> None:
matrix = (ROOT / "scripts" / "run_detection_quality_matrix.sh").read_text(encoding="utf-8")
multi = (ROOT / "scripts" / "run_multi_sample_detection_quality_matrix.sh").read_text(encoding="utf-8")
runner = (ROOT / "scripts" / "run_mol_operational_validation.sh").read_text(encoding="utf-8")
readiness = (ROOT / "scripts" / "run_readiness_check.sh").read_text(encoding="utf-8")
dockerfile = (ROOT / "deploy" / "unraid" / "Dockerfile.all-in-one").read_text(encoding="utf-8")
assert 'findings.get("coverage")' in matrix
assert 'findings.get("box_to_footprint_diagnostics")' in matrix
assert '"reference_coverage_ratio"' in matrix
assert '"possible_box_to_footprint_mismatch_count"' in matrix
assert 'enriched["operational_zone"]' in multi
assert "build_mol_operational_benchmark_report.py" in runner
assert "MOL_MIN_REFERENCE_COVERAGE" in runner
assert "py_compile scripts/build_mol_operational_benchmark_report.py" in readiness
assert "COPY scripts/build_mol_operational_benchmark_report.py" in dockerfile
assert "fixture_mode" not in runner
assert "manual-fixture-detector" not in runner
+1
View File
@@ -93,6 +93,7 @@ COPY scripts/run_operator_hard_negative_detection_matrix.sh /app/scripts/run_ope
COPY scripts/run_background_corpus_split_matrix.sh /app/scripts/run_background_corpus_split_matrix.sh
COPY scripts/build_background_corpus_split_report.py /app/scripts/build_background_corpus_split_report.py
COPY scripts/build_detection_model_promotion_report.py /app/scripts/build_detection_model_promotion_report.py
COPY scripts/build_mol_operational_benchmark_report.py /app/scripts/build_mol_operational_benchmark_report.py
COPY scripts/run_split_background_promotion_workflow.sh /app/scripts/run_split_background_promotion_workflow.sh
COPY scripts/activate_promoted_yolo_candidate.py /app/scripts/activate_promoted_yolo_candidate.py
COPY deploy/unraid/nginx-all-in-one.conf /etc/nginx/conf.d/default.conf
+39
View File
@@ -7659,3 +7659,42 @@ Open:
from envelope diagnostics. The next safe model step is an evidence-led
footprint-label/matching review, followed by a fresh bounded Mol multi-zone
benchmark before any activation or retraining decision.
# Sprint 185 - Coverage-aware Mol operational benchmark
## Implementation
- Extended the existing real-data quality matrix summaries with the exact
persisted tile-coverage population, raw/evaluated/excluded/clipped candidate
and reference counts, CRS/tile provenance and diagnostic-only
box-to-footprint match gap.
- Preserved manifest-backed Mol municipality, operational-zone, validation
split, WGS84 bounds and source reference counts in the multi-sample summary.
- Added `build_mol_operational_benchmark_report.py`. It groups evidence by the
exact model asset, tile size, overlap and confidence threshold and refuses to
blend different candidate configurations.
- Added explicit gates for four independent positive Mol holdouts, one
pure-empty background control, complete coverage provenance, minimum 95%
reference coverage, mean F1 `0.25`, minimum per-zone F1 `0.10` and zero
background detections. Envelope matches remain diagnostic and cannot satisfy
the canonical F1 gates.
- Wired the report into the persistent Mol operator runner, readiness gate and
all-in-one image. The report does not activate, replace, download or train a
model and does not change APIs, migrations or inference behavior.
## Local validation
- Focused Sprint 126/127/178/184/185 regression set passed: `14` tests.
- Full readiness passed with `512` backend tests, one Alembic head
`202606120900`, 81 audited API operations, frontend typecheck and Vite 7.3.6
production build.
- `npm audit --audit-level=high` reported zero vulnerabilities.
- Shell syntax checks passed for the single-, multi-sample and Mol operational
runners. Fixture tests prove both an accepted four-zone result and explicit
rejection for missing coverage, one-zone collapse and background pressure.
## Next pass
- Deploy the operator tooling to Tower and run the current active local model
over Achterbos, Gompel, Donk, Postel and the Postel-bos pure-empty control.
Record the coverage-aware operational decision before changing model state.
+2
View File
@@ -27,6 +27,8 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Persist combined Mol operator evidence under the Unraid storage mount so reports survive all-in-one container replacement.
- [x] Visually review Mol Postel and Donk false-positive/false-negative evidence, classify the dominant error modes and only then decide whether another model-training pass is justified.
- [x] Clip detection QA populations to persisted raster/tile coverage and add box-to-footprint matching diagnostics before reconsidering model training.
- [x] Add a coverage-aware Mol multi-zone benchmark report with explicit positive-zone, per-zone collapse, reference-coverage and pure-empty background gates.
- [ ] Execute the refreshed coverage-aware Mol operational benchmark against the active local model and record the resulting retain/review decision.
- [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.
+18 -4
View File
@@ -261,9 +261,20 @@ confidence `0.15` and QA IoU `0.25`. Every positive run persists Project, Area,
Dataset, Job, AnalysisRun, Detection, QualityCheck, Metric and Export records.
The background run persists its project, AOI, raster, job, analysis and
detections but intentionally does not invent QA metrics for an empty or sparse
reference context. The combined JSON/Markdown summary reports
`evidence_ready`; this records completed evidence and is not an automatic model
promotion decision.
reference context. Each matrix row now preserves the exact persisted inference
coverage counts and the diagnostic-only reference-envelope comparison beside
the canonical footprint-IoU metrics.
The runner also writes `mol_operational_benchmark_report.json` and `.md`. The
default operational gates require four positive holdouts, one background
control, coverage provenance for every positive run, at least 95% reference
coverage in every zone, mean F1 at least `0.25`, per-zone F1 at least `0.10`
and zero detections in each pure-empty control. Override the numeric gates only
through the documented `MOL_MIN_MEAN_F1`, `MOL_MIN_ZONE_F1`,
`MOL_MIN_REFERENCE_COVERAGE` and `MOL_MAX_BACKGROUND_DETECTIONS` variables.
An `accepted` report records bounded operational evidence; it does not mutate
the active model. A `review_required` report is still a successful benchmark
execution but explicitly blocks a promotion recommendation.
For model-training candidates, prepare a larger operator-only sample manifest so
tile overlap can create meaningful context instead of one tile per source
@@ -344,7 +355,10 @@ logs plus `quality_matrix_summary.json` under
set. The summary ranks `best_by_score`, `best_by_recall` and
`best_by_precision` so the next model decision is based on persisted
`QualityCheck`/`Metric` evidence rather than visual guesses. It does not create
provider data, use fixtures or download model weights.
provider data, use fixtures or download model weights. Coverage-aware rows also
record raw/evaluated/excluded/clipped candidate and reference counts, tile
coverage provenance and the separately labelled box-to-footprint diagnostic
gap.
Run the same matrix across every prepared operator sample:
@@ -0,0 +1,361 @@
from __future__ import annotations
import argparse
from collections import defaultdict
from datetime import datetime, timezone
import json
from pathlib import Path
from statistics import fmean
from typing import Any
CandidateKey = tuple[str, int, int, float]
def load_json(path: Path) -> dict[str, Any]:
if not path.is_file():
raise SystemExit(f"Input is not readable: {path}")
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError) as exc:
raise SystemExit(f"Input is not valid JSON: {path}: {exc}") from exc
if not isinstance(payload, dict):
raise SystemExit(f"Input root must be an object: {path}")
return payload
def candidate_key(item: dict[str, Any]) -> CandidateKey:
model_asset_id = str(item.get("model_asset_id") or "").strip()
if not model_asset_id:
raise SystemExit("Benchmark item is missing model_asset_id")
try:
tile_size = int(item["tile_size"])
tile_overlap = int(item["tile_overlap"])
threshold = round(float(item["threshold"]), 8)
except (KeyError, TypeError, ValueError) as exc:
raise SystemExit(f"Benchmark item has an invalid model configuration: {item}") from exc
return model_asset_id, tile_size, tile_overlap, threshold
def candidate_label(key: CandidateKey) -> str:
return f"{key[0]}|{key[1]}|{key[2]}|{key[3]:.8g}"
def number(item: dict[str, Any], key: str) -> float:
value = item.get(key)
if value is None:
raise SystemExit(f"Positive benchmark item is missing {key}: {item.get('sample_slug')}")
try:
return float(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"Positive benchmark item has invalid {key}: {item.get('sample_slug')}") from exc
def integer(item: dict[str, Any], key: str) -> int:
value = item.get(key)
if value is None:
raise SystemExit(f"Benchmark item is missing {key}: {item.get('sample_slug')}")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise SystemExit(f"Benchmark item has invalid {key}: {item.get('sample_slug')}") from exc
def coverage_ratio(item: dict[str, Any]) -> float:
explicit = item.get("reference_coverage_ratio")
if explicit is not None:
return float(explicit)
raw_count = integer(item, "reference_raw_count")
evaluated_count = integer(item, "reference_evaluated_count")
if raw_count < 1:
raise SystemExit(f"Positive benchmark item has no raw references: {item.get('sample_slug')}")
return evaluated_count / raw_count
def gate(name: str, passed: bool, observed: Any, required: str) -> dict[str, Any]:
return {"name": name, "passed": bool(passed), "observed": observed, "required": required}
def zone_row(item: dict[str, Any], manifest_samples: dict[str, dict[str, Any]]) -> dict[str, Any]:
slug = str(item.get("sample_slug") or "").strip().lower()
if not slug:
raise SystemExit("Positive benchmark item is missing sample_slug")
manifest = manifest_samples.get(slug, {})
matches = integer(item, "matches")
false_positives = integer(item, "false_positives")
false_negatives = integer(item, "false_negatives")
strict_matches = item.get("strict_matches")
if strict_matches is not None and int(strict_matches) != matches:
raise SystemExit(f"Diagnostic strict match count drifted from canonical matches for {slug}")
return {
"sample_slug": slug,
"display_name": item.get("sample_display_name") or manifest.get("display_name") or slug,
"municipality": item.get("municipality") or manifest.get("municipality"),
"operational_zone": item.get("operational_zone") or manifest.get("operational_zone"),
"recommended_split": item.get("recommended_split") or manifest.get("recommended_split"),
"project_id": item.get("project_id"),
"area_id": item.get("area_id"),
"analysis_run_id": item.get("analysis_run_id"),
"quality_check_id": item.get("quality_check_id"),
"detection_count": integer(item, "detection_count"),
"precision": number(item, "precision"),
"recall": number(item, "recall"),
"f1_score": number(item, "f1_score"),
"mean_iou": number(item, "mean_iou"),
"matches": matches,
"false_positives": false_positives,
"false_negatives": false_negatives,
"coverage_applied": item.get("coverage_applied") is True,
"coverage_mode": item.get("coverage_mode"),
"coverage_tile_count": integer(item, "coverage_tile_count"),
"reference_raw_count": integer(item, "reference_raw_count"),
"reference_evaluated_count": integer(item, "reference_evaluated_count"),
"reference_excluded_outside_count": integer(item, "reference_excluded_outside_count"),
"reference_clipped_boundary_count": integer(item, "reference_clipped_boundary_count"),
"reference_coverage_ratio": coverage_ratio(item),
"candidate_raw_count": integer(item, "candidate_raw_count"),
"candidate_evaluated_count": integer(item, "candidate_evaluated_count"),
"candidate_excluded_outside_count": integer(item, "candidate_excluded_outside_count"),
"candidate_clipped_boundary_count": integer(item, "candidate_clipped_boundary_count"),
"diagnostic_only": item.get("diagnostic_only") is True,
"strict_matches": int(strict_matches) if strict_matches is not None else None,
"envelope_matches": integer(item, "envelope_matches"),
"possible_box_to_footprint_mismatch_count": integer(item, "possible_box_to_footprint_mismatch_count"),
"envelope_precision": number(item, "envelope_precision"),
"envelope_recall": number(item, "envelope_recall"),
"envelope_f1_score": number(item, "envelope_f1_score"),
}
def evaluate_candidate(
key: CandidateKey,
positive_items: list[dict[str, Any]],
background_items: list[dict[str, Any]],
manifest_samples: dict[str, dict[str, Any]],
args: argparse.Namespace,
) -> dict[str, Any]:
zones = [zone_row(item, manifest_samples) for item in positive_items]
zone_slugs = [row["sample_slug"] for row in zones]
if len(zone_slugs) != len(set(zone_slugs)):
raise SystemExit(f"Candidate has duplicate positive sample rows: {candidate_label(key)}")
background_slugs = [str(item.get("sample_slug") or "").strip().lower() for item in background_items]
if any(not slug for slug in background_slugs) or len(background_slugs) != len(set(background_slugs)):
raise SystemExit(f"Candidate has invalid or duplicate background rows: {candidate_label(key)}")
f1_values = [row["f1_score"] for row in zones]
precision_values = [row["precision"] for row in zones]
recall_values = [row["recall"] for row in zones]
coverage_ratios = [row["reference_coverage_ratio"] for row in zones]
background_detections = [integer(item, "detection_count") for item in background_items]
total_matches = sum(row["matches"] for row in zones)
total_fp = sum(row["false_positives"] for row in zones)
total_fn = sum(row["false_negatives"] for row in zones)
micro_precision = total_matches / (total_matches + total_fp) if total_matches + total_fp else None
micro_recall = total_matches / (total_matches + total_fn) if total_matches + total_fn else None
micro_f1 = None
if micro_precision is not None and micro_recall is not None:
micro_f1 = 2 * micro_precision * micro_recall / (micro_precision + micro_recall) if micro_precision + micro_recall else 0.0
gates = [
gate("positive_sample_count", len(zones) >= args.min_positive_samples, len(zones), f">={args.min_positive_samples}"),
gate("background_sample_count", len(background_items) >= args.min_background_samples, len(background_items), f">={args.min_background_samples}"),
gate("coverage_provenance", all(row["coverage_applied"] for row in zones), sum(row["coverage_applied"] for row in zones), "all positive runs"),
gate("coverage_ratio", bool(coverage_ratios) and min(coverage_ratios) >= args.min_reference_coverage_ratio, min(coverage_ratios) if coverage_ratios else None, f">={args.min_reference_coverage_ratio}"),
gate("diagnostic_separation", all(row["diagnostic_only"] for row in zones), sum(row["diagnostic_only"] for row in zones), "all positive runs"),
gate("mean_f1", bool(f1_values) and fmean(f1_values) >= args.min_mean_f1, fmean(f1_values) if f1_values else None, f">={args.min_mean_f1}"),
gate("minimum_zone_f1", bool(f1_values) and min(f1_values) >= args.min_zone_f1, min(f1_values) if f1_values else None, f">={args.min_zone_f1}"),
gate("background_false_positive_pressure", bool(background_detections) and max(background_detections) <= args.max_background_detections, max(background_detections) if background_detections else None, f"<={args.max_background_detections} per sample"),
]
failed_gates = [item["name"] for item in gates if not item["passed"]]
return {
"candidate_key": candidate_label(key),
"model_asset_id": key[0],
"tile_size": key[1],
"tile_overlap": key[2],
"threshold": key[3],
"decision": "operationally_accepted" if not failed_gates else "review_required",
"failed_gates": failed_gates,
"gates": gates,
"positive_sample_count": len(zones),
"background_sample_count": len(background_items),
"mean_precision": fmean(precision_values) if precision_values else None,
"mean_recall": fmean(recall_values) if recall_values else None,
"mean_f1": fmean(f1_values) if f1_values else None,
"minimum_zone_f1": min(f1_values) if f1_values else None,
"micro_precision": micro_precision,
"micro_recall": micro_recall,
"micro_f1": micro_f1,
"total_matches": total_matches,
"total_false_positives": total_fp,
"total_false_negatives": total_fn,
"total_background_detections": sum(background_detections),
"max_background_detections": max(background_detections) if background_detections else None,
"minimum_reference_coverage_ratio": min(coverage_ratios) if coverage_ratios else None,
"total_references_raw": sum(row["reference_raw_count"] for row in zones),
"total_references_evaluated": sum(row["reference_evaluated_count"] for row in zones),
"total_references_excluded_outside": sum(row["reference_excluded_outside_count"] for row in zones),
"total_references_clipped_boundary": sum(row["reference_clipped_boundary_count"] for row in zones),
"total_candidates_raw": sum(row["candidate_raw_count"] for row in zones),
"total_candidates_evaluated": sum(row["candidate_evaluated_count"] for row in zones),
"total_box_to_footprint_mismatch_count": sum(row["possible_box_to_footprint_mismatch_count"] for row in zones),
"zones": zones,
"background_controls": [
{
"sample_slug": str(item.get("sample_slug") or "").strip().lower(),
"background_category": item.get("background_category"),
"project_id": item.get("project_id"),
"area_id": item.get("area_id"),
"analysis_run_id": item.get("analysis_run_id"),
"tile_count": item.get("tile_count"),
"detection_count": integer(item, "detection_count"),
}
for item in background_items
],
}
def render_markdown(report: dict[str, Any]) -> str:
lines = [
"# Mol operational detection benchmark",
"",
f"- Generated: `{report['generated_at']}`",
f"- Status: `{report['status']}`",
f"- Recommendation: `{report['recommendation']}`",
f"- Candidate configurations: `{report['candidate_count']}`",
"",
"Canonical metrics remain footprint-IoU based. Envelope results are diagnostic only.",
"",
]
for decision in report["candidate_decisions"]:
lines.extend(
[
f"## {decision['candidate_key']}",
"",
f"- Decision: `{decision['decision']}`",
f"- Mean/minimum F1: `{decision['mean_f1']:.4f}` / `{decision['minimum_zone_f1']:.4f}`",
f"- Micro precision/recall/F1: `{decision['micro_precision']:.4f}` / `{decision['micro_recall']:.4f}` / `{decision['micro_f1']:.4f}`",
f"- Canonical matches / FP / FN: `{decision['total_matches']}` / `{decision['total_false_positives']}` / `{decision['total_false_negatives']}`",
f"- Reference coverage: `{decision['total_references_evaluated']}` / `{decision['total_references_raw']}` evaluated; `{decision['total_references_excluded_outside']}` outside; `{decision['total_references_clipped_boundary']}` boundary-clipped",
f"- Diagnostic box-to-footprint gap: `{decision['total_box_to_footprint_mismatch_count']}`",
f"- Background detections: `{decision['total_background_detections']}`",
f"- Failed gates: `{', '.join(decision['failed_gates']) or 'none'}`",
"",
"| Zone | Precision | Recall | F1 | Coverage | Strict | Envelope | Gap |",
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
]
)
for zone in decision["zones"]:
lines.append(
f"| {zone['display_name']} | {zone['precision']:.4f} | {zone['recall']:.4f} | {zone['f1_score']:.4f} | "
f"{zone['reference_evaluated_count']}/{zone['reference_raw_count']} | {zone['matches']} | {zone['envelope_matches']} | "
f"{zone['possible_box_to_footprint_mismatch_count']} |"
)
lines.append("")
return "\n".join(lines) + "\n"
def build_report(args: argparse.Namespace) -> dict[str, Any]:
positive = load_json(args.positive_summary)
background = load_json(args.background_summary)
manifest = load_json(args.manifest_path)
positive_items = positive.get("items") or []
background_items = background.get("items") or []
if not isinstance(positive_items, list) or not positive_items:
raise SystemExit("Positive summary contains no benchmark items")
if not isinstance(background_items, list) or not background_items:
raise SystemExit("Background summary contains no benchmark items")
manifest_rows = manifest.get("samples") or []
manifest_samples = {
str(item.get("sample_slug") or "").strip().lower(): item
for item in manifest_rows
if isinstance(item, dict) and item.get("sample_slug")
}
grouped_positive: dict[CandidateKey, list[dict[str, Any]]] = defaultdict(list)
grouped_background: dict[CandidateKey, list[dict[str, Any]]] = defaultdict(list)
for item in positive_items:
if not isinstance(item, dict):
raise SystemExit("Positive summary contains a non-object item")
grouped_positive[candidate_key(item)].append(item)
for item in background_items:
if not isinstance(item, dict):
raise SystemExit("Background summary contains a non-object item")
grouped_background[candidate_key(item)].append(item)
decisions = [
evaluate_candidate(key, items, grouped_background.get(key, []), manifest_samples, args)
for key, items in sorted(grouped_positive.items(), key=lambda entry: candidate_label(entry[0]))
]
accepted = [item for item in decisions if item["decision"] == "operationally_accepted"]
recommended = max(
accepted,
key=lambda item: (item["mean_f1"], item["minimum_zone_f1"], item["mean_precision"]),
default=None,
)
report = {
"schema_version": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"status": "accepted" if recommended else "review_required",
"recommendation": "retain_or_promote_candidate" if recommended else "do_not_promote_retraining_review_required",
"base_url": args.base_url,
"positive_summary_path": str(args.positive_summary),
"background_summary_path": str(args.background_summary),
"operator_sample_manifest_path": str(args.manifest_path),
"gate_configuration": {
"min_positive_samples": args.min_positive_samples,
"min_background_samples": args.min_background_samples,
"min_mean_f1": args.min_mean_f1,
"min_zone_f1": args.min_zone_f1,
"min_reference_coverage_ratio": args.min_reference_coverage_ratio,
"max_background_detections": args.max_background_detections,
},
"candidate_count": len(decisions),
"recommended_candidate": recommended,
"candidate_decisions": decisions,
}
return report
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build a coverage-aware Mol operational detection benchmark report.")
parser.add_argument("--positive-summary", type=Path, required=True)
parser.add_argument("--background-summary", type=Path, required=True)
parser.add_argument("--manifest-path", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--base-url", default="")
parser.add_argument("--min-positive-samples", type=int, default=4)
parser.add_argument("--min-background-samples", type=int, default=1)
parser.add_argument("--min-mean-f1", type=float, default=0.25)
parser.add_argument("--min-zone-f1", type=float, default=0.10)
parser.add_argument("--min-reference-coverage-ratio", type=float, default=0.95)
parser.add_argument("--max-background-detections", type=int, default=0)
args = parser.parse_args()
if args.min_positive_samples < 1 or args.min_background_samples < 1:
parser.error("sample gates must be at least 1")
if not 0 <= args.min_mean_f1 <= 1 or not 0 <= args.min_zone_f1 <= 1:
parser.error("F1 gates must be between 0 and 1")
if not 0 < args.min_reference_coverage_ratio <= 1:
parser.error("coverage ratio gate must be greater than 0 and at most 1")
if args.max_background_detections < 0:
parser.error("background detection gate cannot be negative")
return args
def main() -> int:
args = parse_args()
report = build_report(args)
args.output_dir.mkdir(parents=True, exist_ok=True)
json_path = args.output_dir / "mol_operational_benchmark_report.json"
markdown_path = args.output_dir / "mol_operational_benchmark_report.md"
json_path.write_text(json.dumps(report, indent=2, sort_keys=True), encoding="utf-8")
markdown_path.write_text(render_markdown(report), encoding="utf-8")
print("Mol operational benchmark report passed")
print(f"Status: {report['status']}")
print(f"Recommendation: {report['recommendation']}")
print(f"JSON: {json_path}")
print(f"Markdown: {markdown_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+34 -3
View File
@@ -286,6 +286,13 @@ metrics = {
if metric.get("metric_key")
}
findings = quality_check.get("findings_json") or {}
coverage = findings.get("coverage") or {}
diagnostics = findings.get("box_to_footprint_diagnostics") or {}
reference_raw_count = coverage.get("reference_raw_count")
reference_evaluated_count = coverage.get("reference_evaluated_count")
reference_coverage_ratio = None
if isinstance(reference_raw_count, int) and reference_raw_count > 0 and isinstance(reference_evaluated_count, int):
reference_coverage_ratio = reference_evaluated_count / reference_raw_count
summary = {
"model_request": model_request,
"model_asset_id": selected_model_asset_id,
@@ -312,6 +319,27 @@ summary = {
"matches": findings.get("matches"),
"false_positives": findings.get("false_positives"),
"false_negatives": findings.get("false_negatives"),
"coverage_applied": coverage.get("applied", False),
"coverage_mode": coverage.get("mode"),
"coverage_tile_count": coverage.get("tile_count", 0),
"coverage_source_crs_values": coverage.get("source_crs_values") or [],
"candidate_raw_count": coverage.get("candidate_raw_count"),
"candidate_evaluated_count": coverage.get("candidate_evaluated_count"),
"candidate_excluded_outside_count": coverage.get("candidate_excluded_outside_count"),
"candidate_clipped_boundary_count": coverage.get("candidate_clipped_boundary_count"),
"reference_raw_count": reference_raw_count,
"reference_evaluated_count": reference_evaluated_count,
"reference_excluded_outside_count": coverage.get("reference_excluded_outside_count"),
"reference_clipped_boundary_count": coverage.get("reference_clipped_boundary_count"),
"reference_coverage_ratio": reference_coverage_ratio,
"diagnostic_only": diagnostics.get("diagnostic_only"),
"diagnostic_method": diagnostics.get("diagnostic_method"),
"strict_matches": diagnostics.get("strict_matches"),
"envelope_matches": diagnostics.get("envelope_matches"),
"possible_box_to_footprint_mismatch_count": diagnostics.get("possible_box_to_footprint_mismatch_count"),
"envelope_precision": diagnostics.get("envelope_precision"),
"envelope_recall": diagnostics.get("envelope_recall"),
"envelope_f1_score": diagnostics.get("envelope_f1_score"),
"export_id": export_id,
"run_log": run_log,
}
@@ -319,7 +347,8 @@ with open(output_path, "w", encoding="utf-8") as handle:
json.dump(summary, handle, indent=2, sort_keys=True)
print(
"model={model} tile={tile} overlap={overlap} threshold={threshold} detections={detections} raw={raw} suppressed={suppressed} "
"score={score} precision={precision} recall={recall} f1={f1} matches={matches} fp={fp} fn={fn}".format(
"score={score} precision={precision} recall={recall} f1={f1} matches={matches} fp={fp} fn={fn} "
"coverage={coverage} diagnostic_gap={diagnostic_gap}".format(
model=summary["model_asset_id"],
tile=summary["tile_size"],
overlap=summary["tile_overlap"],
@@ -334,6 +363,8 @@ print(
matches=summary["matches"],
fp=summary["false_positives"],
fn=summary["false_negatives"],
coverage=summary["reference_coverage_ratio"],
diagnostic_gap=summary["possible_box_to_footprint_mismatch_count"],
)
)
PY
@@ -381,10 +412,10 @@ with open(summary_path, "w", encoding="utf-8") as handle:
print("")
print("Detection quality matrix summary")
print("model\ttile\toverlap\tthreshold\tdetections\traw\tsuppressed\tscore\tprecision\trecall\tf1\tmatches\tfp\tfn")
print("model\ttile\toverlap\tthreshold\tdetections\traw\tsuppressed\tscore\tprecision\trecall\tf1\tmatches\tfp\tfn\tcoverage\tdiagnostic_gap")
for item in items:
print(
"{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{raw_detection_count}\t{suppressed_detection_count}\t{quality_score}\t{precision}\t{recall}\t{f1_score}\t{matches}\t{false_positives}\t{false_negatives}".format(
"{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{raw_detection_count}\t{suppressed_detection_count}\t{quality_score}\t{precision}\t{recall}\t{f1_score}\t{matches}\t{false_positives}\t{false_negatives}\t{reference_coverage_ratio}\t{possible_box_to_footprint_mismatch_count}".format(
**item
)
)
+21
View File
@@ -17,6 +17,10 @@ Optional environment:
QUALITY_TILE_OVERLAPS Default: 64.
QUALITY_THRESHOLDS Default: 0.15.
REAL_IOU_THRESHOLD Default: 0.25.
MOL_MIN_MEAN_F1 Operational gate, default: 0.25.
MOL_MIN_ZONE_F1 Per-zone collapse gate, default: 0.10.
MOL_MIN_REFERENCE_COVERAGE Minimum evaluated/raw reference ratio, default: 0.95.
MOL_MAX_BACKGROUND_DETECTIONS Maximum detections per pure-empty control, default: 0.
The runner never downloads weights, fetches product providers or uses fixture
outputs. Prepare the documented real orthophoto/GRB files explicitly first.
@@ -41,6 +45,10 @@ QUALITY_TILE_SIZES="${QUALITY_TILE_SIZES:-512}"
QUALITY_TILE_OVERLAPS="${QUALITY_TILE_OVERLAPS:-64}"
QUALITY_THRESHOLDS="${QUALITY_THRESHOLDS:-0.15}"
REAL_IOU_THRESHOLD="${REAL_IOU_THRESHOLD:-0.25}"
MOL_MIN_MEAN_F1="${MOL_MIN_MEAN_F1:-0.25}"
MOL_MIN_ZONE_F1="${MOL_MIN_ZONE_F1:-0.10}"
MOL_MIN_REFERENCE_COVERAGE="${MOL_MIN_REFERENCE_COVERAGE:-0.95}"
MOL_MAX_BACKGROUND_DETECTIONS="${MOL_MAX_BACKGROUND_DETECTIONS:-0}"
if [ "${BASE_URL}" = "-h" ] || [ "${BASE_URL}" = "--help" ]; then
usage
@@ -213,3 +221,16 @@ lines = [
(output_dir / "mol_operational_validation_summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps({"status": summary["status"], "summary_path": str(summary_path)}, indent=2))
PY
"${PYTHON_BIN}" scripts/build_mol_operational_benchmark_report.py \
--positive-summary "${positive_output_dir}/multi_sample_quality_summary.json" \
--background-summary "${background_output_dir}/hard_negative_matrix_summary.json" \
--manifest-path "${OPERATOR_SAMPLE_MANIFEST_PATH}" \
--output-dir "${MOL_VALIDATION_OUTPUT_DIR}" \
--base-url "${BASE_URL}" \
--min-positive-samples 4 \
--min-background-samples 1 \
--min-mean-f1 "${MOL_MIN_MEAN_F1}" \
--min-zone-f1 "${MOL_MIN_ZONE_F1}" \
--min-reference-coverage-ratio "${MOL_MIN_REFERENCE_COVERAGE}" \
--max-background-detections "${MOL_MAX_BACKGROUND_DETECTIONS}"
@@ -184,6 +184,12 @@ for summary_path in sorted(glob.glob(str(output_dir / "*" / "quality_matrix_summ
for item in items:
enriched = dict(item)
enriched["sample_slug"] = sample_slug
enriched["sample_display_name"] = sample_metadata.get("display_name")
enriched["municipality"] = sample_metadata.get("municipality")
enriched["operational_zone"] = sample_metadata.get("operational_zone")
enriched["recommended_split"] = sample_metadata.get("recommended_split")
enriched["manifest_reference_feature_count"] = sample_metadata.get("reference_feature_count")
enriched["wgs84_bbox"] = sample_metadata.get("wgs84_bbox")
flat_items.append(enriched)
sample_summaries.append(
{
@@ -232,10 +238,10 @@ summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True), encoding=
print("")
print("Multi-sample detection quality summary")
print("sample\tmodel\ttile\toverlap\tthreshold\tdetections\traw\tsuppressed\tscore\tprecision\trecall\tf1\tmatches\tfp\tfn")
print("sample\tzone\tmodel\ttile\toverlap\tthreshold\tdetections\traw\tsuppressed\tscore\tprecision\trecall\tf1\tmatches\tfp\tfn\tcoverage\tdiagnostic_gap")
for item in flat_items:
print(
"{sample_slug}\t{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{raw_detection_count}\t{suppressed_detection_count}\t{quality_score}\t{precision}\t{recall}\t{f1_score}\t{matches}\t{false_positives}\t{false_negatives}".format(
"{sample_slug}\t{operational_zone}\t{model_asset_id}\t{tile_size}\t{tile_overlap}\t{threshold:.2f}\t{detection_count}\t{raw_detection_count}\t{suppressed_detection_count}\t{quality_score}\t{precision}\t{recall}\t{f1_score}\t{matches}\t{false_positives}\t{false_negatives}\t{reference_coverage_ratio}\t{possible_box_to_footprint_mismatch_count}".format(
**item
)
)
+1
View File
@@ -47,6 +47,7 @@ ${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_dataset.py
${PYTHON_BIN} -m py_compile scripts/export_operator_yolo_tile_dataset.py
${PYTHON_BIN} -m py_compile scripts/audit_operator_yolo_dataset_quality.py
${PYTHON_BIN} -m py_compile scripts/build_detection_model_promotion_report.py
${PYTHON_BIN} -m py_compile scripts/build_mol_operational_benchmark_report.py
${PYTHON_BIN} -m py_compile scripts/build_background_corpus_split_report.py
${PYTHON_BIN} -m py_compile scripts/build_fixed_threshold_evidence_portfolio_inputs.py
${PYTHON_BIN} -m py_compile scripts/audit_detection_false_negative_evidence.py