feat(accuracy): build hardened phase 4 evaluation harness

This commit is contained in:
Jens
2026-08-02 02:43:41 +02:00
parent 7b92e29e49
commit bd2fd9780f
13 changed files with 5468 additions and 7 deletions
+701
View File
@@ -0,0 +1,701 @@
#!/usr/bin/env python3
"""Generate immutable P4 split manifests and fail closed on leakage."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path
from typing import Any, Iterable
from pyproj import CRS
SCHEMA_VERSION = 1
GENERATOR_VERSION = "1.1.0"
TRAIN_SPLITS = {"train"}
SELECTION_SPLITS = {"val", "calibration"}
DEVELOPMENT_SPLITS = TRAIN_SPLITS | SELECTION_SPLITS
PROTECTED_SPLITS = {"test", "background-test", "challenge"}
ALL_SPLITS = DEVELOPMENT_SPLITS | PROTECTED_SPLITS
NORMATIVE_SPLITS = {"train", "val", "calibration", "test", "background-test"}
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
FINGERPRINT_PATTERN = re.compile(r"^[0-9a-f]{16}$")
class LeakageError(ValueError):
"""Raised when split isolation is not demonstrably safe."""
def canonical_bytes(value: Any) -> bytes:
return json.dumps(
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
def canonical_hash(value: Any) -> str:
return hashlib.sha256(canonical_bytes(value)).hexdigest()
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(content, encoding="utf-8")
temporary.replace(path)
def validate_metric_crs(value: Any) -> str:
try:
crs = CRS.from_user_input(value)
except Exception as exc: # noqa: BLE001 - pyproj exposes multiple parser errors
raise LeakageError(f"Invalid CRS: {value!r}") from exc
axes = crs.axis_info
if crs.is_geographic or not axes:
raise LeakageError(f"CRS must be projected in metres: {value!r}")
if any(
abs(float(axis.unit_conversion_factor or 0.0) - 1.0) > 1e-12
for axis in axes[:2]
):
raise LeakageError(f"CRS axes must use metres: {value!r}")
return crs.to_string()
def require_hex(
sample_id: str, field: str, value: Any, pattern: re.Pattern[str]
) -> str:
normalized = str(value or "").lower()
if not pattern.fullmatch(normalized):
raise LeakageError(f"{sample_id}: {field} has an invalid fingerprint")
return normalized
def fingerprint_distance(left: str, right: str) -> int:
return (int(left, 16) ^ int(right, 16)).bit_count()
def bbox_distance(left: list[float], right: list[float]) -> float:
dx = max(left[0] - right[2], right[0] - left[2], 0.0)
dy = max(left[1] - right[3], right[1] - left[3], 0.0)
return math.hypot(dx, dy)
def normalized_sample(sample: dict[str, Any]) -> dict[str, Any]:
required = {
"sample_id",
"task",
"split",
"group_id",
"source_family",
"temporal_family",
"object_ids",
"bbox",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"perceptual_image_hash",
"label_geometry_hash",
"label_geometry_fingerprint",
"native_feature_ids",
"parent_raster_id",
"acquisition_id",
"acquisition_date",
}
missing = sorted(required - set(sample))
if missing:
raise LeakageError(
f"{sample.get('sample_id', '<unknown>')}: missing fields {missing}"
)
split = str(sample["split"])
if split not in ALL_SPLITS:
raise LeakageError(f"{sample['sample_id']}: unsupported split {split!r}")
bbox = sample["bbox"]
if not isinstance(bbox, list) or len(bbox) != 4:
raise LeakageError(f"{sample['sample_id']}: bbox must contain four values")
values = [float(value) for value in bbox]
if (
not all(math.isfinite(value) for value in values)
or values[0] >= values[2]
or values[1] >= values[3]
):
raise LeakageError(f"{sample['sample_id']}: invalid bbox")
result = dict(sample)
result["bbox"] = values
for field in (
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"label_geometry_hash",
):
result[field] = require_hex(
str(sample["sample_id"]), field, sample[field], SHA256_PATTERN
)
for field in ("perceptual_image_hash", "label_geometry_fingerprint"):
result[field] = require_hex(
str(sample["sample_id"]), field, sample[field], FINGERPRINT_PATTERN
)
for field in ("object_ids", "native_feature_ids"):
if not isinstance(sample[field], list) or any(
not str(value) for value in sample[field]
):
raise LeakageError(
f"{sample['sample_id']}: {field} must be a list of non-empty identities"
)
result[field] = sorted({str(value) for value in sample[field]})
for field in (
"group_id",
"source_family",
"temporal_family",
"parent_raster_id",
"acquisition_id",
):
if not str(sample[field]).strip():
raise LeakageError(f"{sample['sample_id']}: {field} must be non-empty")
result[field] = str(sample[field])
try:
result["acquisition_date"] = date.fromisoformat(
str(sample["acquisition_date"])
).isoformat()
except ValueError as exc:
raise LeakageError(
f"{sample['sample_id']}: acquisition_date must be ISO-8601"
) from exc
record_without_split = {
key: value for key, value in result.items() if key != "split"
}
result["record_sha256"] = canonical_hash(record_without_split)
return result
def assign_split_roles(source: dict[str, Any]) -> list[dict[str, Any]]:
"""Assign complete leakage groups deterministically, or validate a frozen assignment."""
raw_samples = [dict(item) for item in source.get("samples", [])]
mode = str(source.get("assignment_mode") or "preassigned")
split_presence = [bool(item.get("split")) for item in raw_samples]
if mode == "preassigned":
if raw_samples and not all(split_presence):
raise LeakageError("Preassigned mode requires a split on every sample")
return raw_samples
if mode != "deterministic_grouped":
raise LeakageError(f"Unsupported assignment_mode: {mode!r}")
if any(split_presence):
raise LeakageError(
"deterministic_grouped mode refuses partially or fully preassigned splits"
)
config = source.get("split_assignment") or {}
roles = list(
config.get("roles")
or ["train", "val", "calibration", "test", "background-test"]
)
if not roles or len(set(roles)) != len(roles) or set(roles) - ALL_SPLITS:
raise LeakageError("split_assignment.roles must be unique supported roles")
weights = {
role: float((config.get("weights") or {}).get(role, 1.0)) for role in roles
}
if any(not math.isfinite(value) or value <= 0 for value in weights.values()):
raise LeakageError("split_assignment weights must be positive finite values")
placeholder = [
normalized_sample({**item, "split": "train"}) for item in raw_samples
]
parent = list(range(len(placeholder)))
def find(index: int) -> int:
while parent[index] != index:
parent[index] = parent[parent[index]]
index = parent[index]
return index
def union(left: int, right: int) -> None:
left_root = find(left)
right_root = find(right)
if left_root != right_root:
parent[max(left_root, right_root)] = min(left_root, right_root)
identity_fields = (
"group_id",
"source_family",
"temporal_family",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"label_geometry_hash",
"parent_raster_id",
"acquisition_id",
)
seen: dict[tuple[str, str], int] = {}
for index, sample in enumerate(placeholder):
identities = [(field, str(sample[field])) for field in identity_fields]
identities.extend(("object_id", str(value)) for value in sample["object_ids"])
identities.extend(
("native_feature_id", str(value)) for value in sample["native_feature_ids"]
)
for identity in identities:
if identity in seen:
union(index, seen[identity])
else:
seen[identity] = index
image_threshold = int(source.get("perceptual_hamming_threshold", 4))
geometry_threshold = int(source.get("label_geometry_hamming_threshold", 2))
buffer_m = float(source.get("independence_buffer_m") or 0)
for index, left in enumerate(placeholder):
for right_index, right in enumerate(placeholder[index + 1 :], start=index + 1):
if (
fingerprint_distance(
left["perceptual_image_hash"], right["perceptual_image_hash"]
)
<= image_threshold
or fingerprint_distance(
left["label_geometry_fingerprint"],
right["label_geometry_fingerprint"],
)
<= geometry_threshold
or bbox_distance(left["bbox"], right["bbox"]) < buffer_m
):
union(index, right_index)
components: dict[int, list[int]] = defaultdict(list)
for index in range(len(placeholder)):
components[find(index)].append(index)
if len(components) < len(roles):
raise LeakageError(
f"Not enough independent groups for required roles: {len(components)} < {len(roles)}"
)
seed = str(config.get("seed") or "geointel-p4-group-split-v1")
groups = sorted(
components.values(),
key=lambda indices: canonical_hash(
{
"seed": seed,
"sample_ids": sorted(
placeholder[index]["sample_id"] for index in indices
),
}
),
)
stratify_by = tuple(config.get("stratify_by") or ["task"])
assigned_counts: Counter[str] = Counter()
stratum_counts: dict[str, Counter[str]] = defaultdict(Counter)
assignment: dict[int, str] = {}
for group_index, indices in enumerate(groups):
strata = {
f"{field}={placeholder[index].get(field) or (placeholder[index].get('metadata') or {}).get(field)}"
for index in indices
for field in stratify_by
}
candidates = (
roles[group_index : group_index + 1] if group_index < len(roles) else roles
)
role = min(
candidates,
key=lambda candidate: (
assigned_counts[candidate] / weights[candidate]
+ sum(
stratum_counts[stratum][candidate] / weights[candidate]
for stratum in strata
),
candidate,
),
)
for index in indices:
assignment[index] = role
assigned_counts[role] += len(indices)
for stratum in strata:
stratum_counts[stratum][role] += len(indices)
return [
{**sample, "split": assignment[index]}
for index, sample in enumerate(raw_samples)
]
def leakage_findings(
samples: list[dict[str, Any]],
independence_buffer_m: float,
perceptual_hamming_threshold: int,
label_geometry_hamming_threshold: int,
) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
identity_fields = (
"sample_id",
"group_id",
"source_family",
"temporal_family",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"label_geometry_hash",
"parent_raster_id",
"acquisition_id",
"record_sha256",
)
identities: dict[str, dict[str, set[str]]] = {
key: defaultdict(set) for key in identity_fields
}
object_splits: dict[str, set[str]] = defaultdict(set)
native_feature_splits: dict[str, set[str]] = defaultdict(set)
for sample in samples:
for key, index in identities.items():
index[str(sample[key])].add(sample["split"])
for object_id in sample["object_ids"]:
object_splits[str(object_id)].add(sample["split"])
for feature_id in sample["native_feature_ids"]:
native_feature_splits[str(feature_id)].add(sample["split"])
code_by_key = {
"sample_id": "S-SAMPLE-IDENTITY",
"group_id": "S-SPATIAL-GROUP",
"source_family": "S-SOURCE-FAMILY",
"temporal_family": "S-TEMPORAL-FAMILY",
"raw_image_sha256": "S-RAW-IMAGE-DUPLICATE",
"processed_image_sha256": "S-PROCESSED-IMAGE-DUPLICATE",
"label_sha256": "S-LABEL-DUPLICATE",
"label_geometry_hash": "S-LABEL-GEOMETRY-DUPLICATE",
"parent_raster_id": "S-PARENT-RASTER",
"acquisition_id": "S-ACQUISITION",
"record_sha256": "S-EXACT-DUPLICATE",
}
for key, index in identities.items():
for value, splits in sorted(index.items()):
if len(splits) > 1:
findings.append(
{
"code": code_by_key[key],
"identity": value,
"splits": sorted(splits),
}
)
for object_id, splits in sorted(object_splits.items()):
if len(splits) > 1:
findings.append(
{
"code": "S-OBJECT-INSTANCE",
"identity": object_id,
"splits": sorted(splits),
}
)
for feature_id, splits in sorted(native_feature_splits.items()):
if len(splits) > 1:
findings.append(
{
"code": "S-NATIVE-FEATURE",
"identity": feature_id,
"splits": sorted(splits),
}
)
for index, left in enumerate(samples):
for right in samples[index + 1 :]:
if left["split"] == right["split"]:
continue
image_distance = fingerprint_distance(
left["perceptual_image_hash"], right["perceptual_image_hash"]
)
if image_distance <= perceptual_hamming_threshold:
findings.append(
{
"code": "S-PERCEPTUAL-IMAGE-NEAR-DUPLICATE",
"left": left["sample_id"],
"right": right["sample_id"],
"distance": image_distance,
"maximum_allowed_distance": perceptual_hamming_threshold,
}
)
geometry_distance = fingerprint_distance(
left["label_geometry_fingerprint"], right["label_geometry_fingerprint"]
)
if geometry_distance <= label_geometry_hamming_threshold:
findings.append(
{
"code": "S-LABEL-GEOMETRY-NEAR-DUPLICATE",
"left": left["sample_id"],
"right": right["sample_id"],
"distance": geometry_distance,
"maximum_allowed_distance": label_geometry_hamming_threshold,
}
)
distance = bbox_distance(left["bbox"], right["bbox"])
if distance < independence_buffer_m:
findings.append(
{
"code": "S-SPATIAL-OVERLAP",
"left": left["sample_id"],
"right": right["sample_id"],
"left_split": left["split"],
"right_split": right["split"],
"distance_m": distance,
"required_distance_m": independence_buffer_m,
}
)
return findings
def build_manifests(
source: dict[str, Any],
) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
if source.get("schema_version") != 1:
raise LeakageError("Unsupported source manifest schema_version")
metric_crs = validate_metric_crs(source.get("crs"))
assigned_source_samples = assign_split_roles(source)
samples = [normalized_sample(item) for item in assigned_source_samples]
if not samples:
raise LeakageError("Source manifest has no samples")
if len({item["sample_id"] for item in samples}) != len(samples):
raise LeakageError("Duplicate sample_id in source manifest")
buffer_m = float(source.get("independence_buffer_m") or 0)
if not math.isfinite(buffer_m) or buffer_m <= 0:
raise LeakageError("independence_buffer_m must be a positive finite number")
split_counts = Counter(item["split"] for item in samples)
required_splits = set(source.get("required_splits") or NORMATIVE_SPLITS)
unsupported_required = sorted(required_splits - ALL_SPLITS)
if unsupported_required:
raise LeakageError(f"Unsupported required splits: {unsupported_required}")
missing_splits = sorted(required_splits - set(split_counts))
if missing_splits:
raise LeakageError(f"Required splits are absent: {missing_splits}")
perceptual_threshold = int(source.get("perceptual_hamming_threshold", 4))
geometry_threshold = int(source.get("label_geometry_hamming_threshold", 2))
if not 0 <= perceptual_threshold < 64 or not 0 <= geometry_threshold < 64:
raise LeakageError("Near-duplicate Hamming thresholds must be between 0 and 63")
findings = leakage_findings(
samples, buffer_m, perceptual_threshold, geometry_threshold
)
canonical_source = dict(source)
canonical_source["samples"] = sorted(
source.get("samples", []), key=lambda item: str(item.get("sample_id", ""))
)
source_hash = canonical_hash(canonical_source)
development_samples = sorted(
(item for item in samples if item["split"] in DEVELOPMENT_SPLITS),
key=lambda item: item["sample_id"],
)
protected_samples = sorted(
(item for item in samples if item["split"] in PROTECTED_SPLITS),
key=lambda item: item["sample_id"],
)
common = {
"schema_version": SCHEMA_VERSION,
"generator_version": GENERATOR_VERSION,
"dataset_version": source["dataset_version"],
"source_manifest_sha256": source_hash,
"crs": metric_crs,
"independence_buffer_m": buffer_m,
"perceptual_hamming_threshold": perceptual_threshold,
"label_geometry_hamming_threshold": geometry_threshold,
"assignment_mode": str(source.get("assignment_mode") or "preassigned"),
"assignment_algorithm": "group-before-split-deficit-balancer-v1",
"assignment_seed": str(
(source.get("split_assignment") or {}).get("seed")
or "geointel-p4-group-split-v1"
),
"claim_boundary": source.get("claim_boundary"),
}
development = {
**common,
"manifest_role": "development_and_calibration",
"allowed_splits": sorted(DEVELOPMENT_SPLITS),
"training_access_allowed_by_split": {
"train": True,
"val": False,
"calibration": False,
},
"selection_access_allowed_by_split": {
"train": False,
"val": True,
"calibration": True,
},
"samples": development_samples,
}
development["manifest_sha256"] = canonical_hash(development)
protected = {
**common,
"manifest_role": "protected_release_only",
"allowed_splits": sorted(PROTECTED_SPLITS),
"training_access_allowed": False,
"selection_use_allowed": False,
"labels_available_by_split": {
"test": "frozen_evaluator_only",
"background-test": "frozen_evaluator_only",
"challenge": "sealed_external",
},
"access_policy": (
"Only the frozen release evaluator may consume test/background-test labels; "
"challenge labels remain external/sealed."
),
"samples": protected_samples,
}
protected["manifest_sha256"] = canonical_hash(protected)
leakage = {
"schema_version": SCHEMA_VERSION,
"generator_version": GENERATOR_VERSION,
"status": "pass" if not findings else "fail",
"source_manifest_sha256": source_hash,
"development_manifest_sha256": development["manifest_sha256"],
"protected_manifest_sha256": protected["manifest_sha256"],
"inventory_total": len(samples),
"split_counts": dict(sorted(split_counts.items())),
"checked_identity_fields": [
"sample_id",
"group_id",
"source_family",
"temporal_family",
"object_ids",
"native_feature_ids",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"record_sha256",
"perceptual_image_hash",
"label_geometry_hash",
"label_geometry_fingerprint",
"parent_raster_id",
"acquisition_id",
"bbox_distance",
],
"crs_validation": {"status": "pass", "crs": metric_crs, "distance_units": "m"},
"independence_buffer_m": buffer_m,
"perceptual_hamming_threshold": perceptual_threshold,
"label_geometry_hamming_threshold": geometry_threshold,
"finding_count": len(findings),
"findings": findings,
}
return development, protected, leakage
def protected_identities(protected_manifest: dict[str, Any]) -> set[str]:
identities: set[str] = set()
scalar_fields = (
"sample_id",
"group_id",
"record_sha256",
"source_family",
"temporal_family",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"label_geometry_hash",
"perceptual_image_hash",
"label_geometry_fingerprint",
"parent_raster_id",
"acquisition_id",
)
for item in protected_manifest.get("samples", []):
identities.update(str(item[field]) for field in scalar_fields)
identities.update(str(value) for value in item.get("object_ids", []))
identities.update(str(value) for value in item.get("native_feature_ids", []))
return identities
def assert_training_inputs_safe(
input_paths: Iterable[Path],
input_records: Iterable[dict[str, Any]],
protected_manifest: dict[str, Any],
) -> None:
"""Refuse non-train roles and protected identities at a fitting boundary."""
forbidden = protected_identities(protected_manifest)
violations: list[str] = []
for path in input_paths:
lowered = path.as_posix().lower()
if any(
token in lowered
for token in ("protected", "holdout", "challenge", "background-test")
):
violations.append(f"protected_path:{path}")
scalar_fields = (
"sample_id",
"group_id",
"record_sha256",
"source_family",
"temporal_family",
"raw_image_sha256",
"processed_image_sha256",
"label_sha256",
"label_geometry_hash",
"perceptual_image_hash",
"label_geometry_fingerprint",
"parent_raster_id",
"acquisition_id",
)
for record in input_records:
if record.get("split") not in TRAIN_SPLITS:
violations.append(
f"non_train_role:{record.get('sample_id')}:{record.get('split')}"
)
values = {str(record.get(field, "")) for field in scalar_fields}
values.update(str(value) for value in record.get("object_ids", []))
values.update(str(value) for value in record.get("native_feature_ids", []))
overlap = sorted((values - {""}) & forbidden)
if overlap:
violations.append(f"protected_identity:{','.join(overlap)}")
if violations:
raise LeakageError(
"Training input firewall blocked: " + "; ".join(sorted(violations))
)
def generate(source_path: Path, output_dir: Path) -> dict[str, Any]:
source = json.loads(source_path.read_text(encoding="utf-8"))
development, protected, leakage = build_manifests(source)
output_dir.mkdir(parents=True, exist_ok=True)
generation_status = {
"schema_version": SCHEMA_VERSION,
"generator_version": GENERATOR_VERSION,
"status": leakage["status"],
"source_manifest_sha256": leakage["source_manifest_sha256"],
"development_manifest_sha256": (
development["manifest_sha256"] if leakage["status"] == "pass" else None
),
"protected_manifest_sha256": (
protected["manifest_sha256"] if leakage["status"] == "pass" else None
),
}
write_json(output_dir / "generation-status.json", generation_status)
write_json(output_dir / "leakage-gate-report.json", leakage)
if leakage["status"] != "pass":
raise LeakageError(
f"Leakage gate failed with {leakage['finding_count']} findings"
)
train_samples = [
item for item in development["samples"] if item["split"] == "train"
]
assert_training_inputs_safe([], train_samples, protected)
write_json(output_dir / "development-split-manifest.json", development)
write_json(output_dir / "protected-split-manifest.json", protected)
return {
"development": development,
"protected": protected,
"leakage": leakage,
"generation_status": generation_status,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
result = generate(args.source, args.output_dir)
except (OSError, json.JSONDecodeError, LeakageError) as exc:
print(json.dumps({"status": "fail", "error": str(exc)}, indent=2))
return 2
print(
json.dumps(
{
"status": "pass",
"development_manifest_sha256": result["development"]["manifest_sha256"],
"protected_manifest_sha256": result["protected"]["manifest_sha256"],
"split_counts": result["leakage"]["split_counts"],
},
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())