block checkpoint evaluation on ancestral exposure
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Jens
2026-08-09 22:10:50 +02:00
parent fd45f37a38
commit 116b8e291e
8 changed files with 390 additions and 61 deletions
+9 -6
View File
@@ -5,13 +5,16 @@ weights on one declared, non-protected `val` split using CUDA. It records exact
model and dataset hashes, standard Ultralytics detection metrics and a separate
pure-background detection count. The output claim is validation ranking only;
the script neither reads protected test data nor promotes a model.
Governed comparisons must pass every candidate's exact tile-summary with
`--training-summary` and enable `--require-training-sample-independence`. If
any validation AOI occurs in any supplied train split, the script writes a
Governed comparisons must pass the exact tile summary for every corpus in the
complete ancestry of every candidate with `--lineage-summary` and enable
`--require-lineage-sample-independence`. The check covers train, validation,
calibration and every other recorded split, because an AOI used for ancestral
checkpoint or threshold selection is also exposed. Any exposure writes a
blocked manifest and exits before importing PyTorch, loading a model or using
the GPU. A background detection count from a training-seen AOI is only a
regression check and must never be presented as independent background
evidence.
the GPU. The older `--training-summary` and
`--require-training-sample-independence` spellings remain aliases, but now use
the same safer full-split semantics. A result from any lineage-exposed AOI is
only a regression check and must never be presented as independent evidence.
`render_operator_yolo_label_qa_contact_sheets.py` paginates complete visual
reviews with `--tiles-per-sheet` (default `64`). This keeps large corpora
+68 -37
View File
@@ -111,8 +111,8 @@ def _sample_slugs(payload: dict[str, Any], *, split: str | None) -> set[str]:
return samples
def training_sample_independence_evidence(
dataset_yaml: Path, training_summaries: list[Path]
def model_lineage_independence_evidence(
dataset_yaml: Path, lineage_summaries: list[Path]
) -> dict[str, Any]:
evaluation_summary = dataset_yaml.parent / "yolo_tile_dataset_summary.json"
if not evaluation_summary.is_file():
@@ -120,7 +120,7 @@ def training_sample_independence_evidence(
"status": "unavailable",
"reason": "evaluation dataset summary is unavailable",
"evaluation_summary_path": str(evaluation_summary),
"independent_for_all_supplied_training_corpora": False,
"independent_for_all_supplied_lineage_corpora": False,
}
try:
@@ -134,18 +134,23 @@ def training_sample_independence_evidence(
"reason": str(exc),
"evaluation_summary_path": str(evaluation_summary),
"evaluation_summary_sha256": sha256_file(evaluation_summary),
"independent_for_all_supplied_training_corpora": False,
"independent_for_all_supplied_lineage_corpora": False,
}
rows: list[dict[str, Any]] = []
union_overlap: set[str] = set()
for raw_summary in training_summaries:
for raw_summary in lineage_summaries:
summary = raw_summary.expanduser().resolve(strict=True)
try:
payload = json.loads(summary.read_text(encoding="utf-8"))
training_samples = _sample_slugs(payload, split="train")
if not training_samples:
raise ValueError("training summary contains no training samples")
all_samples = _sample_slugs(payload, split=None)
if not all_samples:
raise ValueError("lineage summary contains no samples")
roles_by_sample: dict[str, set[str]] = {}
for tile in payload["tiles"]:
sample_slug = str(tile["sample_slug"]).strip()
split = str(tile.get("split") or "unknown").strip()
roles_by_sample.setdefault(sample_slug, set()).add(split)
except (json.JSONDecodeError, OSError, ValueError) as exc:
return {
"status": "invalid",
@@ -153,32 +158,35 @@ def training_sample_independence_evidence(
"evaluation_summary_path": str(evaluation_summary),
"evaluation_summary_sha256": sha256_file(evaluation_summary),
"evaluation_samples": sorted(evaluation_samples),
"independent_for_all_supplied_training_corpora": False,
"independent_for_all_supplied_lineage_corpora": False,
}
overlap = evaluation_samples & training_samples
overlap = evaluation_samples & all_samples
union_overlap.update(overlap)
rows.append(
{
"training_summary_path": str(summary),
"training_summary_sha256": sha256_file(summary),
"training_sample_count": len(training_samples),
"lineage_summary_path": str(summary),
"lineage_summary_sha256": sha256_file(summary),
"lineage_sample_count": len(all_samples),
"overlapping_evaluation_samples": sorted(overlap),
"exposure_roles": {
sample: sorted(roles_by_sample[sample]) for sample in sorted(overlap)
},
}
)
if not rows:
return {
"status": "unavailable",
"reason": "no training summaries were supplied",
"reason": "no complete model-lineage summaries were supplied",
"evaluation_summary_path": str(evaluation_summary),
"evaluation_summary_sha256": sha256_file(evaluation_summary),
"evaluation_samples": sorted(evaluation_samples),
"training_corpora": [],
"lineage_corpora": [],
"overlapping_evaluation_samples": [],
"independent_for_all_supplied_training_corpora": False,
"independent_for_all_supplied_lineage_corpora": False,
"interpretation": (
"Training/evaluation independence cannot be established "
"without exact training summaries."
"Model-lineage/evaluation independence cannot be established "
"without every ancestral corpus summary."
),
}
@@ -188,29 +196,37 @@ def training_sample_independence_evidence(
"evaluation_summary_path": str(evaluation_summary),
"evaluation_summary_sha256": sha256_file(evaluation_summary),
"evaluation_samples": sorted(evaluation_samples),
"training_corpora": rows,
"lineage_corpora": rows,
"overlapping_evaluation_samples": sorted(union_overlap),
"independent_for_all_supplied_training_corpora": independent,
"independent_for_all_supplied_lineage_corpora": independent,
"interpretation": (
"No evaluation AOI occurs in the train split of any supplied corpus."
"No evaluation AOI occurs in any split of any supplied ancestral corpus."
if independent
else "At least one evaluation AOI occurs in a supplied train split; "
"the matrix is blocked before model loading."
else "At least one evaluation AOI was exposed in an ancestral train, "
"validation, calibration or other split; the matrix is blocked before "
"model loading."
),
}
def training_sample_independence_evidence(
dataset_yaml: Path, training_summaries: list[Path]
) -> dict[str, Any]:
"""Backward-compatible alias; evidence now checks every lineage split."""
return model_lineage_independence_evidence(dataset_yaml, training_summaries)
def write_blocked_manifest(
output: Path, dataset_yaml: Path, evidence: dict[str, Any]
) -> None:
payload = {
"schema_version": 2,
"schema_version": 3,
"generated_at": datetime.now(UTC).isoformat(),
"status": "blocked_training_sample_overlap",
"status": "blocked_model_lineage_sample_exposure",
"claim_boundary": "No checkpoint ranking or release claim is permitted.",
"dataset_yaml": str(dataset_yaml),
"dataset_yaml_sha256": sha256_file(dataset_yaml),
"training_sample_independence_evidence": evidence,
"model_lineage_independence_evidence": evidence,
"model_loading_attempted": False,
"gpu_inference_attempted": False,
}
@@ -261,8 +277,21 @@ def main() -> int:
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--background-prefix", action="append", required=True)
parser.add_argument("--background-confidence", type=float, default=0.15)
parser.add_argument("--training-summary", type=Path, action="append", default=[])
parser.add_argument("--require-training-sample-independence", action="store_true")
parser.add_argument(
"--lineage-summary",
"--training-summary",
dest="lineage_summary",
type=Path,
action="append",
default=[],
help="tile summary for every corpus in the complete model ancestry",
)
parser.add_argument(
"--require-lineage-sample-independence",
"--require-training-sample-independence",
dest="require_lineage_sample_independence",
action="store_true",
)
parser.add_argument("--device", default="cuda:0")
parser.add_argument("--imgsz", type=int, default=640)
parser.add_argument("--batch", type=int, default=8)
@@ -275,17 +304,19 @@ def main() -> int:
dataset_yaml = args.dataset_yaml.expanduser().resolve(strict=True)
images = validation_images(dataset_yaml)
overlap_evidence = dataset_overlap_evidence(dataset_yaml)
independence_evidence = training_sample_independence_evidence(
dataset_yaml, args.training_summary
independence_evidence = model_lineage_independence_evidence(
dataset_yaml, args.lineage_summary
)
if args.require_training_sample_independence and not args.training_summary:
if args.require_lineage_sample_independence and not args.lineage_summary:
parser.error(
"--require-training-sample-independence requires at least one "
"--training-summary"
"--require-lineage-sample-independence requires every ancestral "
"--lineage-summary"
)
if (
args.require_training_sample_independence
and not independence_evidence["independent_for_all_supplied_training_corpora"]
args.require_lineage_sample_independence
and not independence_evidence[
"independent_for_all_supplied_lineage_corpora"
]
):
write_blocked_manifest(args.output, dataset_yaml, independence_evidence)
return 3
@@ -374,14 +405,14 @@ def main() -> int:
reverse=True,
)
payload = {
"schema_version": 2,
"schema_version": 3,
"generated_at": datetime.now(UTC).isoformat(),
"status": "ok" if successful else "failed",
"claim_boundary": "Non-protected validation ranking only; no test, challenge or promotion claim.",
"dataset_yaml": str(dataset_yaml),
"dataset_yaml_sha256": sha256_file(dataset_yaml),
"dataset_overlap_evidence": overlap_evidence,
"training_sample_independence_evidence": independence_evidence,
"model_lineage_independence_evidence": independence_evidence,
"validation_image_count": len(images),
"pure_background_prefixes": list(prefixes),
"pure_background_confidence": args.background_confidence,