diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f634da0..ffbdb461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/tests/test_sprint198_detection_review_completion.py b/backend/tests/test_sprint198_detection_review_completion.py new file mode 100644 index 00000000..70e6badf --- /dev/null +++ b/backend/tests/test_sprint198_detection_review_completion.py @@ -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 diff --git a/docs/AI_PIPELINES.md b/docs/AI_PIPELINES.md index daf87bd8..d4de20c5 100644 --- a/docs/AI_PIPELINES.md +++ b/docs/AI_PIPELINES.md @@ -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 diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 4588a75a..745d6ced 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 0aa9d092..76a9b24f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. diff --git a/docs/reviews/2026-07-15-small-building-model-review.md b/docs/reviews/2026-07-15-small-building-model-review.md new file mode 100644 index 00000000..03870fd9 --- /dev/null +++ b/docs/reviews/2026-07-15-small-building-model-review.md @@ -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. diff --git a/frontend/src/components/map/MapWorkspace.tsx b/frontend/src/components/map/MapWorkspace.tsx index 8c443170..684cb337 100644 --- a/frontend/src/components/map/MapWorkspace.tsx +++ b/frontend/src/components/map/MapWorkspace.tsx @@ -1205,14 +1205,22 @@ export function MapWorkspace({ {orthophotoAnalysisStatus ?

{orthophotoAnalysisStatus}

: null} {orthophotoAnalysisError ?

{orthophotoAnalysisError}

: null} {orthophotoAnalysisQuality ? ( -
-
Kandidaten{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}
-
Precision{formatPercentage(orthophotoAnalysisQuality.precision)}
-
Recall{formatPercentage(orthophotoAnalysisQuality.recall)}
-
F1{formatPercentage(orthophotoAnalysisQuality.f1_score)}
-
Fout{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')}
-
Gemist{orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}
-
+ <> +
+
Kandidaten{orthophotoAnalysisDetectionCount?.toLocaleString('nl-BE') ?? 'n.v.t.'}
+
Strikte matches{orthophotoAnalysisQuality.matches.toLocaleString('nl-BE')}
+
Precision{formatPercentage(orthophotoAnalysisQuality.precision)}
+
Recall{formatPercentage(orthophotoAnalysisQuality.recall)}
+
F1{formatPercentage(orthophotoAnalysisQuality.f1_score)}
+
Fout / gemist{orthophotoAnalysisQuality.false_positives.toLocaleString('nl-BE')} / {orthophotoAnalysisQuality.false_negatives.toLocaleString('nl-BE')}
+
+ {orthophotoAnalysisQuality.box_to_footprint_diagnostics ? ( +

+ 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. +

+ ) : null} + ) : null} ) : null} diff --git a/frontend/src/styles/premium.css b/frontend/src/styles/premium.css index d14145ec..e6f72cd3 100644 --- a/frontend/src/styles/premium.css +++ b/frontend/src/styles/premium.css @@ -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; diff --git a/scripts/README.md b/scripts/README.md index 5557dafc..8ebbe287 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/run_readiness_check.sh b/scripts/run_readiness_check.sh index d1433abe..98587425 100755 --- a/scripts/run_readiness_check.sh +++ b/scripts/run_readiness_check.sh @@ -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 diff --git a/scripts/validate_detection_false_negative_review_decisions.py b/scripts/validate_detection_false_negative_review_decisions.py new file mode 100644 index 00000000..62ba3d1e --- /dev/null +++ b/scripts/validate_detection_false_negative_review_decisions.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())