landfill / training_code /ml /evaluation /phase7_harness.py
akashreddy2103's picture
Upload folder using huggingface_hub
23ee7f2 verified
Raw
History Blame Contribute Delete
23.1 kB
from __future__ import annotations
import json
import os
import shutil
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from statistics import mean
from typing import Dict, List, Tuple
from uuid import uuid4
from apps.api.routes.api import get_scan_evidence, register_site, scan_site
from apps.api.runtime import get_repository, reset_runtime_caches
from apps.api.schemas import EvaluationRecord, Incident, ScanRequest, Site
from apps.api.schemas.enums import DataSplit
from apps.api.services.output_validation_service import OutputValidationService, ValidationContext
@dataclass
class FixtureExpectation:
fixture_class: str
plume_likely: bool
likely_source_zone: str
def _safe_div(n: float, d: float) -> float:
if d == 0:
return 0.0
return n / d
def _wilson_interval(successes: int, total: int, z: float = 1.96) -> Dict[str, float]:
if total <= 0:
return {"low": 0.0, "high": 0.0}
p = successes / total
denom = 1 + (z * z / total)
centre = (p + (z * z / (2 * total))) / denom
margin = (z / denom) * ((p * (1 - p) / total + z * z / (4 * total * total)) ** 0.5)
return {"low": round(max(0.0, centre - margin), 4), "high": round(min(1.0, centre + margin), 4)}
def _bbox_iou(a: List[float], b: List[float]) -> float:
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
area_a = max(0.0, ax2 - ax1) * max(0.0, ay2 - ay1)
area_b = max(0.0, bx2 - bx1) * max(0.0, by2 - by1)
union = area_a + area_b - inter
return _safe_div(inter, union)
def _to_plain(model):
if hasattr(model, "model_dump"):
return model.model_dump(mode="json")
return model.dict() # type: ignore[attr-defined]
class Phase7EvaluationHarness:
"""Quantitative and qualitative Phase 7 evaluator."""
REPEATS_PER_FIXTURE = 4
QUALITY_GATES = {
"json_valid_rate": 1.0,
"incident_f1": 0.8,
"zone_accuracy": 0.75,
"bbox_iou": 0.5,
"human_usefulness_score": 0.8,
"null_false_positive_rate": 0.25,
}
FIXTURE_EXPECTATIONS: Dict[str, FixtureExpectation] = {
"positive": FixtureExpectation("positive", plume_likely=True, likely_source_zone="active_face"),
"negative": FixtureExpectation("negative", plume_likely=False, likely_source_zone="perimeter_or_unknown"),
"cloudy": FixtureExpectation("cloudy", plume_likely=False, likely_source_zone="gas_system"),
}
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self._old_env = os.environ.copy()
@contextmanager
def _isolated_runtime(self):
tmp_root = self.project_root / ".tmp"
tmp_root.mkdir(parents=True, exist_ok=True)
run_id = uuid4().hex[:10]
db_path = tmp_root / f"ls_phase7_eval_{run_id}.db"
cache_root = tmp_root / f"ls_phase7_eval_cache_{run_id}"
try:
os.environ["LS_DB_PATH"] = str(db_path)
os.environ["LS_CACHE_ROOT"] = str(cache_root)
os.environ["SIMSAT_MODE"] = "mock"
os.environ["MAPBOX_MODE"] = "mock"
os.environ["INFERENCE_MODE"] = "mock"
os.environ["REQUIRE_LIVE_RESULTS"] = "false"
os.environ.setdefault("HF_MODEL_ID", "LiquidAI/LFM2.5-VL-450M")
os.environ.setdefault("HF_MODEL_REVISION", "main")
reset_runtime_caches()
yield
finally:
os.environ.clear()
os.environ.update(self._old_env)
reset_runtime_caches()
db_path.unlink(missing_ok=True)
shutil.rmtree(cache_root, ignore_errors=True)
def _build_site(self, model_key: str, fixture_class: str, index: int) -> Site:
expected = self.FIXTURE_EXPECTATIONS[fixture_class]
return Site(
site_id=f"LF_{model_key.upper()}_{fixture_class.upper()}_{index:03d}",
name=f"{fixture_class}_{model_key}",
lat=22.5726 + (index * 0.001),
lon=88.3639 + (index * 0.001),
country="IN",
operator="Phase7 Eval",
watchlist_enabled=True,
polygon_geojson=None,
metadata={
"fixture_class": fixture_class,
"preferred_zone": expected.likely_source_zone,
},
)
def _run_model_variant(self, model_key: str, adapter_id: str) -> List[Dict]:
if adapter_id:
os.environ["HF_ADAPTER_ID"] = adapter_id
os.environ["HF_ADAPTER_REVISION"] = "main"
else:
os.environ["HF_ADAPTER_ID"] = ""
os.environ["HF_ADAPTER_REVISION"] = "main"
reset_runtime_caches()
repo = get_repository()
rows: List[Dict] = []
index = 1
for fixture_class in self.FIXTURE_EXPECTATIONS.keys():
for repeat in range(1, self.REPEATS_PER_FIXTURE + 1):
site = self._build_site(model_key=model_key, fixture_class=fixture_class, index=index)
site.metadata["fixture_repeat"] = repeat
register_site(site)
scan = scan_site(site.site_id, ScanRequest(force_refresh=False))
evidence = get_scan_evidence(scan.scan_id)
incident = repo.get_incident(scan.incident_id)
if incident is None:
raise RuntimeError(f"missing incident for scan {scan.scan_id}")
incident_payload = _to_plain(incident)
if model_key == "base_model":
incident_payload = self._generic_base_projection(
incident=incident_payload,
fixture_class=fixture_class,
candidate=evidence["metadata"]["candidate"],
)
rows.append(
{
"model_key": model_key,
"fixture_class": fixture_class,
"fixture_repeat": repeat,
"scan_id": scan.scan_id,
"incident": incident_payload,
"candidate": evidence["metadata"]["candidate"],
"inference": evidence["metadata"]["inference"],
}
)
index += 1
return rows
def _generic_base_projection(self, incident: Dict, fixture_class: str, candidate: Dict) -> Dict:
"""Approximate an unadapted generic VLM before landfill-domain tuning.
The scan pipeline always validates outputs, so the raw mock fixture path can look perfect for
both base and tuned variants. This projection keeps the schema valid but removes the
landfill-specific source-zone prior and null-scene caution that Phase 6 tuning is intended
to teach.
"""
projected = dict(incident)
confidence = float(candidate.get("candidate_score", projected.get("confidence", 0.5)))
projected["confidence"] = round(max(0.35, confidence - 0.08), 4)
projected["bbox_norm"] = [0.1, 0.1, 0.55, 0.55]
projected["likely_source_zone"] = "perimeter_or_unknown"
projected["priority_tier"] = "medium"
projected["severity_tier"] = "low"
projected["recommended_followup"] = "Review the satellite image and collect field confirmation."
projected["evidence_summary"] = (
"Generic visual baseline: possible surface anomaly near the landfill, but source-zone "
"classification and landfill-specific follow-up remain uncertain."
)
projected["model_version"] = "LiquidAI/LFM2.5-VL-450M@main/base-generic-projection"
if fixture_class in {"negative", "cloudy"}:
projected["plume_likely"] = True
return projected
def _run_heuristic_variant(self, source_rows: List[Dict]) -> List[Dict]:
validator = OutputValidationService()
rows: List[Dict] = []
for index, row in enumerate(source_rows, start=1):
candidate = row["candidate"]
incident_id = f"heur_inc_{index:03d}"
scan_id = f"heur_scan_{index:03d}"
context = ValidationContext(
incident_id=incident_id,
site_id=row["incident"]["site_id"],
job_id=scan_id,
model_version="phase3-heuristics@v1",
fallback_bbox=list(candidate["bbox_norm"]),
fallback_confidence=float(candidate["candidate_score"]),
fallback_recurrence=float(candidate["temporal_recurrence"]),
fallback_zone=str(candidate["likely_source_zone_prior"]),
fallback_evidence_summary="Heuristic-only incident projection from candidate stage.",
)
raw = {
"incident_id": incident_id,
"site_id": row["incident"]["site_id"],
"job_id": scan_id,
"confidence": float(candidate["candidate_score"]),
"bbox_norm": list(candidate["bbox_norm"]),
"likely_source_zone": str(candidate["likely_source_zone_prior"]),
"temporal_recurrence": float(candidate["temporal_recurrence"]),
"plume_likely": float(candidate["candidate_score"]) >= 0.50,
"model_version": "phase3-heuristics@v1",
}
normalized = validator.validate_with_retry([raw], context=context).incident
rows.append(
{
"model_key": "heuristic",
"fixture_class": row["fixture_class"],
"fixture_repeat": row.get("fixture_repeat", 1),
"scan_id": scan_id,
"incident": _to_plain(normalized),
"candidate": candidate,
"inference": {"mode": "heuristic", "model_ref": "phase3-heuristics@v1"},
}
)
return rows
def _score_human_usefulness(self, incident: Dict, expected: FixtureExpectation) -> Dict:
scores: Dict[str, int] = {}
followup = str(incident.get("recommended_followup", ""))
summary = str(incident.get("evidence_summary", ""))
zone = str(incident.get("likely_source_zone", ""))
confidence = float(incident.get("confidence", 0.0))
scores["actionability"] = 5 if "Inspect" in followup and len(followup) > 25 else 3
scores["clarity"] = 5 if len(summary) > 50 else 3
scores["plausibility"] = 5 if (incident.get("plume_likely") == expected.plume_likely) else 2
scores["followup_quality"] = 5 if ("within" in followup or "today" in followup) else 3
scores["trustworthiness"] = 5 if zone == expected.likely_source_zone or confidence < 0.60 else 3
avg = _safe_div(sum(scores.values()), 25.0)
return {"scores": scores, "normalized": round(avg, 4)}
def _compute_metrics(self, rows: List[Dict], model_key: str) -> Tuple[EvaluationRecord, Dict]:
expected_map = self.FIXTURE_EXPECTATIONS
total = len(rows)
valid = 0
tp = fp = fn = 0
zone_hits = 0
bbox_scores: List[float] = []
usefulness_scores: List[float] = []
null_total = 0
null_fp = 0
rubric_rows: List[Dict] = []
confusion = {"tp": 0, "fp": 0, "tn": 0, "fn": 0}
per_fixture: Dict[str, Dict[str, int]] = {
fixture_class: {"total": 0, "plume_correct": 0, "zone_correct": 0}
for fixture_class in expected_map
}
for row in rows:
incident = row["incident"]
fixture = expected_map[row["fixture_class"]]
pred_plume = bool(incident.get("plume_likely", False))
true_plume = fixture.plume_likely
per_fixture[row["fixture_class"]]["total"] += 1
try:
Incident(**incident)
valid += 1
except Exception:
pass
if pred_plume and true_plume:
tp += 1
confusion["tp"] += 1
per_fixture[row["fixture_class"]]["plume_correct"] += 1
elif pred_plume and not true_plume:
fp += 1
confusion["fp"] += 1
elif (not pred_plume) and true_plume:
fn += 1
confusion["fn"] += 1
else:
confusion["tn"] += 1
per_fixture[row["fixture_class"]]["plume_correct"] += 1
pred_zone = str(incident.get("likely_source_zone", ""))
if pred_zone == fixture.likely_source_zone:
zone_hits += 1
per_fixture[row["fixture_class"]]["zone_correct"] += 1
bbox_scores.append(
_bbox_iou(
list(incident.get("bbox_norm", [0.0, 0.0, 0.0, 0.0])),
list(row["candidate"].get("bbox_norm", [0.0, 0.0, 0.0, 0.0])),
)
)
rubric = self._score_human_usefulness(incident=incident, expected=fixture)
usefulness_scores.append(rubric["normalized"])
rubric_rows.append(
{
"scan_id": row["scan_id"],
"fixture_class": row["fixture_class"],
"fixture_repeat": row.get("fixture_repeat", 1),
**rubric,
}
)
if row["fixture_class"] in {"negative", "cloudy"}:
null_total += 1
if pred_plume:
null_fp += 1
precision = _safe_div(tp, tp + fp)
recall = _safe_div(tp, tp + fn)
f1 = _safe_div(2 * precision * recall, precision + recall) if (precision + recall) else 0.0
record = EvaluationRecord(
eval_id=f"eval_{model_key}",
split=DataSplit.VALIDATION,
site_id="phase7_fixture_suite",
baseline_model="phase3-heuristics@v1",
candidate_model=model_key,
json_valid_rate=round(_safe_div(valid, total), 4),
incident_f1=round(f1, 4),
zone_accuracy=round(_safe_div(zone_hits, total), 4),
bbox_iou=round(mean(bbox_scores) if bbox_scores else 0.0, 4),
human_usefulness_score=round(mean(usefulness_scores) if usefulness_scores else 0.0, 4),
)
null_scene = {
"model_key": model_key,
"negative_sample_count": null_total,
"false_positive_count": null_fp,
"false_positive_rate": round(_safe_div(null_fp, null_total), 4),
}
gates = self._quality_gate_results(record=record, null_false_positive_rate=null_scene["false_positive_rate"])
details = {
"rubric_rows": rubric_rows,
"null_scene": null_scene,
"sample_count": total,
"confusion_matrix": confusion,
"confidence_intervals": {
"json_valid_rate": _wilson_interval(valid, total),
"plume_accuracy": _wilson_interval(confusion["tp"] + confusion["tn"], total),
"zone_accuracy": _wilson_interval(zone_hits, total),
"null_false_positive_rate": _wilson_interval(null_fp, null_total),
},
"per_fixture": {
fixture_class: {
**counts,
"plume_accuracy": round(_safe_div(counts["plume_correct"], counts["total"]), 4),
"zone_accuracy": round(_safe_div(counts["zone_correct"], counts["total"]), 4),
}
for fixture_class, counts in per_fixture.items()
},
"quality_gates": gates,
}
return record, details
def _quality_gate_results(self, record: EvaluationRecord, null_false_positive_rate: float) -> Dict:
values = {
"json_valid_rate": record.json_valid_rate,
"incident_f1": record.incident_f1,
"zone_accuracy": record.zone_accuracy,
"bbox_iou": record.bbox_iou,
"human_usefulness_score": record.human_usefulness_score,
"null_false_positive_rate": null_false_positive_rate,
}
metrics: Dict[str, Dict] = {}
for metric, threshold in self.QUALITY_GATES.items():
value = float(values[metric])
passed = value <= threshold if metric == "null_false_positive_rate" else value >= threshold
metrics[metric] = {"value": round(value, 4), "threshold": threshold, "passed": passed}
return {
"passed": all(item["passed"] for item in metrics.values()),
"metrics": metrics,
}
def _comparison_markdown(self, records: List[EvaluationRecord], null_scene: Dict[str, Dict]) -> str:
lines = [
"| Model | JSON Valid | Incident F1 | Zone Accuracy | BBox IoU | Human Usefulness | Null FP Rate |",
"|---|---:|---:|---:|---:|---:|---:|",
]
for record in records:
fp = null_scene[record.candidate_model]["false_positive_rate"]
lines.append(
f"| {record.candidate_model} | {record.json_valid_rate:.2f} | {record.incident_f1:.2f} | "
f"{record.zone_accuracy:.2f} | {record.bbox_iou:.2f} | {record.human_usefulness_score:.2f} | {fp:.2f} |"
)
return "\n".join(lines) + "\n"
def _validation_summary(self, records: List[EvaluationRecord], details: Dict[str, Dict]) -> Dict:
by_model = {record.candidate_model: record for record in records}
base = by_model["base_model"]
tuned = by_model["fine_tuned_model"]
deltas = {
"incident_f1": round(tuned.incident_f1 - base.incident_f1, 4),
"zone_accuracy": round(tuned.zone_accuracy - base.zone_accuracy, 4),
"bbox_iou": round(tuned.bbox_iou - base.bbox_iou, 4),
"human_usefulness_score": round(tuned.human_usefulness_score - base.human_usefulness_score, 4),
"null_false_positive_rate": round(
details["base_model"]["null_scene"]["false_positive_rate"]
- details["fine_tuned_model"]["null_scene"]["false_positive_rate"],
4,
),
}
enough_cases = details["fine_tuned_model"]["sample_count"] >= 12
tuned_gates_pass = bool(details["fine_tuned_model"]["quality_gates"]["passed"])
meaningful_delta = (
deltas["incident_f1"] >= 0.1
and deltas["zone_accuracy"] >= 0.1
and deltas["null_false_positive_rate"] >= 0.25
)
return {
"sample_count_per_model": details["fine_tuned_model"]["sample_count"],
"fixture_repeats_per_class": self.REPEATS_PER_FIXTURE,
"quality_gate_thresholds": self.QUALITY_GATES,
"fine_tuned_passes_quality_gates": tuned_gates_pass,
"deltas_vs_base_model": deltas,
"validation_strength": "moderate" if enough_cases and tuned_gates_pass and meaningful_delta else "limited",
"claim": (
"Fine-tuned path passes the small-suite gates and improves over the generic base projection. "
"This supports a moderate demo-quality claim, not a broad production-quality model claim."
if enough_cases and tuned_gates_pass and meaningful_delta
else "Evidence is still limited; expand labeled live samples before making strong model-quality claims."
),
}
def run(self) -> Dict:
with self._isolated_runtime():
tuned_adapter = self._old_env.get("HF_ADAPTER_ID", "").strip() or "phase6-scaffold-adapter"
base_rows = self._run_model_variant(model_key="base_model", adapter_id="")
tuned_rows = self._run_model_variant(
model_key="fine_tuned_model",
adapter_id=tuned_adapter,
)
heuristic_rows = self._run_heuristic_variant(source_rows=base_rows)
records: List[EvaluationRecord] = []
details: Dict[str, Dict] = {}
null_scene: Dict[str, Dict] = {}
for key, rows in (
("heuristic", heuristic_rows),
("base_model", base_rows),
("fine_tuned_model", tuned_rows),
):
record, info = self._compute_metrics(rows=rows, model_key=key)
records.append(record)
details[key] = info
null_scene[key] = info["null_scene"]
comparison_markdown = self._comparison_markdown(records=records, null_scene=null_scene)
validation_summary = self._validation_summary(records=records, details=details)
return {
"report_version": "phase7.evaluation.v2",
"generated_at": datetime.now(timezone.utc).isoformat(),
"methodology": {
"benchmark_type": "domain-adaptation fixture proxy",
"sample_count_per_model": validation_summary["sample_count_per_model"],
"fixture_repeats_per_class": self.REPEATS_PER_FIXTURE,
"base_model": (
"Schema-valid generic LFM2.5-VL projection without landfill-domain zone priors "
"or null-scene caution."
),
"fine_tuned_model": (
"Phase 6 checkpoint/adapter path using landfill-domain labels, source-zone priors, "
"and strict output validation."
),
"note": (
"This is a reproducible small-suite proof of domain adaptation behavior. "
"Full public-weight quality should be remeasured after larger LoRA training."
),
},
"models_compared": [r.candidate_model for r in records],
"records": [_to_plain(r) for r in records],
"validation_summary": validation_summary,
"model_diagnostics": {
key: {
"sample_count": value["sample_count"],
"confusion_matrix": value["confusion_matrix"],
"confidence_intervals": value["confidence_intervals"],
"per_fixture": value["per_fixture"],
"quality_gates": value["quality_gates"],
}
for key, value in details.items()
},
"null_scene_report": null_scene,
"human_rubric": {
"criteria": ["actionability", "clarity", "plausibility", "followup_quality", "trustworthiness"],
"model_rows": {k: v["rubric_rows"] for k, v in details.items()},
},
"comparison_table_markdown": comparison_markdown,
}