Spaces:
Paused
Paused
Deploy Sensorflow Studio
Browse files- app_backend.py +9 -0
- sensorflow/safety/__init__.py +21 -0
- sensorflow/safety/api.py +414 -0
- sensorflow/safety/calibration.py +302 -0
- sensorflow/safety/discrepancy.py +247 -0
- sensorflow/safety/gates.py +522 -0
- sensorflow/safety/odd.py +433 -0
- sensorflow/safety/scenario_db.py +223 -0
- sensorflow/safety/semantic.py +340 -0
- sensorflow/safety/ssam_ext.py +525 -0
- sensorflow/safety/store.py +60 -0
- sensorflow/seqeval/DESIGN.md +449 -0
- sensorflow/seqeval/__init__.py +24 -0
- sensorflow/seqeval/api.py +125 -0
- sensorflow/seqeval/attribution.py +71 -0
- sensorflow/seqeval/controller.py +565 -0
- sensorflow/seqeval/hierarchy.py +223 -0
- sensorflow/seqeval/ledger.py +112 -0
- sensorflow/seqeval/paired.py +267 -0
- sensorflow/seqeval/planner.py +227 -0
- sensorflow/seqeval/sequential.py +286 -0
- sensorflow/seqeval/units.py +107 -0
- tests/test_safety/conftest.py +80 -0
- tests/test_safety/test_api_safety.py +203 -0
- tests/test_safety/test_calibration.py +65 -0
- tests/test_safety/test_consensus.py +95 -0
- tests/test_safety/test_discrepancy.py +83 -0
- tests/test_safety/test_gates.py +124 -0
- tests/test_safety/test_odd.py +172 -0
- tests/test_safety/test_scenario_db.py +87 -0
- tests/test_safety/test_semantic.py +71 -0
- tests/test_safety/test_ssam.py +144 -0
- tests/test_seqeval/__init__.py +0 -0
- tests/test_seqeval/conftest.py +36 -0
app_backend.py
CHANGED
|
@@ -1467,10 +1467,19 @@ app.include_router(labeleval_router)
|
|
| 1467 |
from sensorflow.megaeval.api import router as megaeval_router
|
| 1468 |
app.include_router(megaeval_router)
|
| 1469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1470 |
# BEV-Fusion perception engine (camera+LiDAR fusion, masklet tracking, self-eval).
|
| 1471 |
from sensorflow.bevfusion.api import router as bevfusion_router
|
| 1472 |
app.include_router(bevfusion_router)
|
| 1473 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
# Mount static folder
|
| 1475 |
static_dir = Path(__file__).parent / "static"
|
| 1476 |
if static_dir.exists():
|
|
|
|
| 1467 |
from sensorflow.megaeval.api import router as megaeval_router
|
| 1468 |
app.include_router(megaeval_router)
|
| 1469 |
|
| 1470 |
+
# Sequential regression detection engine (anytime-valid, budgeted, paired).
|
| 1471 |
+
from sensorflow.seqeval.api import router as seqeval_router
|
| 1472 |
+
app.include_router(seqeval_router)
|
| 1473 |
+
|
| 1474 |
# BEV-Fusion perception engine (camera+LiDAR fusion, masklet tracking, self-eval).
|
| 1475 |
from sensorflow.bevfusion.api import router as bevfusion_router
|
| 1476 |
app.include_router(bevfusion_router)
|
| 1477 |
|
| 1478 |
+
# Safety & compliance layer (ODD coverage, release gates, SSAM, calibration,
|
| 1479 |
+
# discrepancy mining, scenario DB, semantic mining).
|
| 1480 |
+
from sensorflow.safety.api import router as safety_router
|
| 1481 |
+
app.include_router(safety_router)
|
| 1482 |
+
|
| 1483 |
# Mount static folder
|
| 1484 |
static_dir = Path(__file__).parent / "static"
|
| 1485 |
if static_dir.exists():
|
sensorflow/safety/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safety & compliance layer for Sensorflow Studio.
|
| 2 |
+
|
| 3 |
+
Closes competitive feature gaps identified against AV perception evaluation
|
| 4 |
+
platforms (Applied Intuition, Deepen AI, Scale Nucleus, Voxel51):
|
| 5 |
+
|
| 6 |
+
- odd.py ODD taxonomy + combinatorial coverage (ISO 34503 / ASAM OpenODD-inspired)
|
| 7 |
+
- ssam_ext.py Extended SSAM surrogate safety measures (FHWA SSAM: DRAC, DeltaS,
|
| 8 |
+
MaxS, TTC/PET, Conflict Severity Index)
|
| 9 |
+
- calibration.py Multi-sensor extrinsic calibration validation (Deepen-style gate)
|
| 10 |
+
- gates.py Layered release gating + Safety Evidence Package (ISO 26262 /
|
| 11 |
+
ISO 21448 SOTIF / UL 4600 mappings — the package SUPPORTS a
|
| 12 |
+
safety case, it does not certify compliance)
|
| 13 |
+
- discrepancy.py Online-vs-offline auto-label discrepancy mining
|
| 14 |
+
- scenario_db.py Curated scenario database (Safety Pool-inspired, local)
|
| 15 |
+
- semantic.py Neuro-symbolic two-stage semantic scenario mining
|
| 16 |
+
- api.py FastAPI router exposing everything under /api/safety/*
|
| 17 |
+
|
| 18 |
+
Foundation-model-style components are lightweight deterministic simulations
|
| 19 |
+
behind clean interfaces, clearly marked as such — consistent with the rest of
|
| 20 |
+
the platform.
|
| 21 |
+
"""
|
sensorflow/safety/api.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI router for the safety & compliance layer (/api/safety/*).
|
| 2 |
+
|
| 3 |
+
Endpoint map (frontend integration pass consumes this):
|
| 4 |
+
|
| 5 |
+
ODD coverage (ISO 34503 / ASAM OpenODD-inspired)
|
| 6 |
+
GET /api/safety/odd/taxonomy
|
| 7 |
+
GET /api/safety/odd/coverage?run=...&dims=weather,lighting&include_cells=...
|
| 8 |
+
POST /api/safety/odd/fill-gap {run, cell, num_sequences?, ...}
|
| 9 |
+
|
| 10 |
+
Release gating + Safety Evidence Package (ISO 26262 / SOTIF / UL 4600-inspired)
|
| 11 |
+
POST /api/safety/gates/evaluate {candidate_run, baseline_run, policy_overrides?}
|
| 12 |
+
GET /api/safety/gates/policy
|
| 13 |
+
POST /api/safety/gates/policy {overrides} (persisted)
|
| 14 |
+
GET /api/safety/gates/result/{run_id}
|
| 15 |
+
GET /api/safety/evidence/{run_id}?format=json|markdown
|
| 16 |
+
|
| 17 |
+
Extended SSAM surrogate safety (FHWA SSAM)
|
| 18 |
+
POST /api/safety/ssam/analyze {trajectories?|scenario?, params?, ...}
|
| 19 |
+
GET /api/safety/ssam/summary?run=...
|
| 20 |
+
|
| 21 |
+
Multi-sensor calibration validation (Deepen-style)
|
| 22 |
+
POST /api/safety/calibration/validate {mode, rotation_offset_deg?, ...}
|
| 23 |
+
GET /api/safety/calibration/status
|
| 24 |
+
|
| 25 |
+
Auto-label discrepancy mining (continuous-learning loop)
|
| 26 |
+
POST /api/safety/discrepancy/mine {dataset_id?, profile?, seed?}
|
| 27 |
+
GET /api/safety/discrepancy/summary?dataset_id=...
|
| 28 |
+
|
| 29 |
+
Scenario database (Safety Pool-inspired, local)
|
| 30 |
+
GET /api/safety/scenarios?source=&severity=&scenario_type=&text=&limit=
|
| 31 |
+
POST /api/safety/scenarios/populate (auto-import rare events)
|
| 32 |
+
POST /api/safety/scenarios/export {filters?}
|
| 33 |
+
|
| 34 |
+
Neuro-symbolic semantic mining
|
| 35 |
+
POST /api/safety/semantic-search {concept, filters?, run?, target?, k?, use_llm?}
|
| 36 |
+
|
| 37 |
+
Consensus extensions (additive evidence over the grader subsystem)
|
| 38 |
+
GET /api/safety/consensus/summary?dataset_id=...
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
from typing import Dict, List, Optional
|
| 44 |
+
|
| 45 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 46 |
+
from pydantic import BaseModel, Field
|
| 47 |
+
|
| 48 |
+
from sensorflow.safety import calibration as calib_mod
|
| 49 |
+
from sensorflow.safety import discrepancy as disc_mod
|
| 50 |
+
from sensorflow.safety import gates as gates_mod
|
| 51 |
+
from sensorflow.safety import odd as odd_mod
|
| 52 |
+
from sensorflow.safety import scenario_db as sdb_mod
|
| 53 |
+
from sensorflow.safety import semantic as sem_mod
|
| 54 |
+
from sensorflow.safety import ssam_ext
|
| 55 |
+
|
| 56 |
+
router = APIRouter(prefix="/api/safety", tags=["safety"])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _mega_store():
|
| 60 |
+
from sensorflow.megaeval.runs import get_mega_store
|
| 61 |
+
return get_mega_store()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _eval_store():
|
| 65 |
+
from sensorflow.evaluation.records import get_store
|
| 66 |
+
return get_store()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _published_run(run_id: str):
|
| 70 |
+
run = _mega_store().runs.get(run_id)
|
| 71 |
+
if run is None:
|
| 72 |
+
raise HTTPException(404, f"Unknown evaluation run {run_id}")
|
| 73 |
+
if run.status != "published":
|
| 74 |
+
raise HTTPException(409, f"Run {run_id} is {run.status}; safety analyses "
|
| 75 |
+
"need a published run")
|
| 76 |
+
return run
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# ------------------------------------------------------------------ ODD
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/odd/taxonomy")
|
| 83 |
+
def odd_taxonomy():
|
| 84 |
+
return odd_mod.taxonomy()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@router.get("/odd/coverage")
|
| 88 |
+
def odd_coverage(run: str,
|
| 89 |
+
dims: Optional[str] = Query(None, description="comma-separated"),
|
| 90 |
+
min_samples: int = Query(odd_mod.DEFAULT_MIN_SAMPLES, ge=1, le=100_000),
|
| 91 |
+
max_ci_width: float = Query(odd_mod.DEFAULT_MAX_CI_WIDTH, gt=0, le=1),
|
| 92 |
+
target_recall: Optional[float] = Query(None, gt=0, le=1),
|
| 93 |
+
include_cells: bool = False,
|
| 94 |
+
max_gaps: int = Query(50, ge=1, le=500)):
|
| 95 |
+
r = _published_run(run)
|
| 96 |
+
dim_list = [d.strip() for d in dims.split(",") if d.strip()] if dims else None
|
| 97 |
+
try:
|
| 98 |
+
return odd_mod.coverage_for_run(
|
| 99 |
+
_mega_store(), r, dims=dim_list, min_samples=min_samples,
|
| 100 |
+
max_ci_width=max_ci_width, target_recall=target_recall,
|
| 101 |
+
include_cells=include_cells, max_gaps=max_gaps)
|
| 102 |
+
except ValueError as e:
|
| 103 |
+
raise HTTPException(422, str(e))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class FillGapRequest(BaseModel):
|
| 107 |
+
run: str
|
| 108 |
+
cell: Dict[str, str]
|
| 109 |
+
num_sequences: int = Field(2, ge=1, le=10)
|
| 110 |
+
frames_per_sequence: int = Field(20, ge=5, le=60)
|
| 111 |
+
seed: Optional[int] = None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.post("/odd/fill-gap")
|
| 115 |
+
def odd_fill_gap(req: FillGapRequest):
|
| 116 |
+
r = _published_run(req.run)
|
| 117 |
+
try:
|
| 118 |
+
return odd_mod.fill_gap(_mega_store(), r, req.cell,
|
| 119 |
+
num_sequences=req.num_sequences,
|
| 120 |
+
frames_per_sequence=req.frames_per_sequence,
|
| 121 |
+
seed=req.seed)
|
| 122 |
+
except ValueError as e:
|
| 123 |
+
raise HTTPException(422, str(e))
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ------------------------------------------------------------------ gates
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class GateEvaluateRequest(BaseModel):
|
| 130 |
+
candidate_run: str
|
| 131 |
+
baseline_run: str
|
| 132 |
+
policy_overrides: Optional[Dict] = None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.post("/gates/evaluate")
|
| 136 |
+
def gates_evaluate(req: GateEvaluateRequest):
|
| 137 |
+
cand = _published_run(req.candidate_run)
|
| 138 |
+
base = _published_run(req.baseline_run)
|
| 139 |
+
return gates_mod.evaluate_gates(_mega_store(), cand, base,
|
| 140 |
+
policy_overrides=req.policy_overrides)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("/gates/policy")
|
| 144 |
+
def gates_policy():
|
| 145 |
+
return {"policy": gates_mod.get_policy(),
|
| 146 |
+
"defaults": gates_mod.DEFAULT_POLICY}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class PolicyUpdateRequest(BaseModel):
|
| 150 |
+
overrides: Dict
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@router.post("/gates/policy")
|
| 154 |
+
def gates_policy_update(req: PolicyUpdateRequest):
|
| 155 |
+
return {"policy": gates_mod.set_policy(req.overrides)}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.get("/gates/result/{run_id}")
|
| 159 |
+
def gates_result(run_id: str):
|
| 160 |
+
result = gates_mod.latest_gate_result(run_id)
|
| 161 |
+
if result is None:
|
| 162 |
+
raise HTTPException(404, f"No gate evaluation recorded for run {run_id}")
|
| 163 |
+
return result
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@router.get("/evidence/{run_id}")
|
| 167 |
+
def evidence(run_id: str, format: str = Query("json", pattern="^(json|markdown)$")):
|
| 168 |
+
pkg = gates_mod.load_evidence(run_id)
|
| 169 |
+
if pkg is None:
|
| 170 |
+
raise HTTPException(404, f"No Safety Evidence Package for run {run_id}; "
|
| 171 |
+
"run POST /api/safety/gates/evaluate first")
|
| 172 |
+
if format == "markdown":
|
| 173 |
+
return {"run_id": run_id, "format": "markdown",
|
| 174 |
+
"markdown": gates_mod.render_markdown(pkg)}
|
| 175 |
+
return pkg
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ------------------------------------------------------------------ SSAM
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class TrajectoryState(BaseModel):
|
| 182 |
+
t: float
|
| 183 |
+
x: float
|
| 184 |
+
y: float
|
| 185 |
+
speed: float
|
| 186 |
+
heading: float
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class Trajectory(BaseModel):
|
| 190 |
+
vehicle_id: str
|
| 191 |
+
vehicle_type: str = "car"
|
| 192 |
+
length: float = Field(4.5, gt=0)
|
| 193 |
+
width: float = Field(1.9, gt=0)
|
| 194 |
+
states: List[TrajectoryState]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class SsamAnalyzeRequest(BaseModel):
|
| 198 |
+
trajectories: Optional[List[Trajectory]] = None
|
| 199 |
+
scenario: Optional[str] = Field(
|
| 200 |
+
None, description="crossing | rear_end | lane_change | mixed — generates "
|
| 201 |
+
"synthetic TRJ-like trajectories when none are supplied")
|
| 202 |
+
seed: int = 0
|
| 203 |
+
reaction_delay_s: float = Field(0.6, ge=0, le=5)
|
| 204 |
+
params: Optional[Dict] = None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@router.post("/ssam/analyze")
|
| 208 |
+
def ssam_analyze(req: SsamAnalyzeRequest):
|
| 209 |
+
if req.trajectories:
|
| 210 |
+
trajs = [t.model_dump() for t in req.trajectories]
|
| 211 |
+
generated = None
|
| 212 |
+
else:
|
| 213 |
+
scenario = req.scenario or "mixed"
|
| 214 |
+
try:
|
| 215 |
+
trajs = ssam_ext.generate_trajectories(
|
| 216 |
+
seed=req.seed, scenario=scenario, reaction_delay_s=req.reaction_delay_s)
|
| 217 |
+
except ValueError as e:
|
| 218 |
+
raise HTTPException(422, str(e))
|
| 219 |
+
generated = {"scenario": scenario, "seed": req.seed,
|
| 220 |
+
"reaction_delay_s": req.reaction_delay_s, "simulated": True}
|
| 221 |
+
result = ssam_ext.analyze_trajectories(trajs, params=req.params)
|
| 222 |
+
if generated:
|
| 223 |
+
result["generated"] = generated
|
| 224 |
+
return result
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
@router.get("/ssam/summary")
|
| 228 |
+
def ssam_summary(run: str, force: bool = False):
|
| 229 |
+
r = _published_run(run)
|
| 230 |
+
return ssam_ext.csi_for_run(r, force=force)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# ------------------------------------------------------------------ calibration
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class CalibrationValidateRequest(BaseModel):
|
| 237 |
+
mode: str = Field("clean", pattern="^(clean|miscalibrated|perception_failure)$")
|
| 238 |
+
rotation_offset_deg: float = Field(2.0, ge=0, le=30)
|
| 239 |
+
translation_offset_m: float = Field(0.0, ge=0, le=5)
|
| 240 |
+
tamper_fraction: float = Field(0.25, ge=0, le=1)
|
| 241 |
+
num_objects: int = Field(14, ge=3, le=60)
|
| 242 |
+
seed: int = 3
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
@router.post("/calibration/validate")
|
| 246 |
+
def calibration_validate(req: CalibrationValidateRequest):
|
| 247 |
+
return calib_mod.run_validation(
|
| 248 |
+
mode=req.mode, rotation_offset_deg=req.rotation_offset_deg,
|
| 249 |
+
translation_offset_m=req.translation_offset_m,
|
| 250 |
+
tamper_fraction=req.tamper_fraction, num_objects=req.num_objects,
|
| 251 |
+
seed=req.seed)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.get("/calibration/status")
|
| 255 |
+
def calibration_status():
|
| 256 |
+
status = calib_mod.latest_status()
|
| 257 |
+
if status is None:
|
| 258 |
+
return {"status": "NEVER_RUN",
|
| 259 |
+
"note": "POST /api/safety/calibration/validate to run a validation"}
|
| 260 |
+
return status
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ------------------------------------------------------------------ discrepancy mining
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class DiscrepancyMineRequest(BaseModel):
|
| 267 |
+
dataset_id: Optional[str] = Field(
|
| 268 |
+
None, description="defaults to the newest labeleval dataset")
|
| 269 |
+
profile: Optional[Dict] = None
|
| 270 |
+
seed: int = 101
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@router.post("/discrepancy/mine")
|
| 274 |
+
def discrepancy_mine(req: DiscrepancyMineRequest):
|
| 275 |
+
store = _eval_store()
|
| 276 |
+
dataset_id = req.dataset_id
|
| 277 |
+
if dataset_id is None:
|
| 278 |
+
datasets = store.all("datasets")
|
| 279 |
+
if not datasets:
|
| 280 |
+
raise HTTPException(404, "No labeleval datasets exist; generate one first "
|
| 281 |
+
"(POST /api/labeleval/datasets/generate)")
|
| 282 |
+
dataset_id = max(datasets, key=lambda d: d.created_at).dataset_id
|
| 283 |
+
try:
|
| 284 |
+
report = disc_mod.mine(store, dataset_id, profile=req.profile, seed=req.seed)
|
| 285 |
+
except KeyError as e:
|
| 286 |
+
raise HTTPException(404, str(e))
|
| 287 |
+
# full per-discrepancy list can be large; cap the response (persisted intact)
|
| 288 |
+
slim = dict(report)
|
| 289 |
+
slim["discrepancies"] = report["discrepancies"][:100]
|
| 290 |
+
slim["discrepancies_truncated"] = len(report["discrepancies"]) > 100
|
| 291 |
+
return slim
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
@router.get("/discrepancy/summary")
|
| 295 |
+
def discrepancy_summary(dataset_id: Optional[str] = None):
|
| 296 |
+
if dataset_id:
|
| 297 |
+
summary = disc_mod.latest_summary(dataset_id)
|
| 298 |
+
if summary is None:
|
| 299 |
+
raise HTTPException(404, f"No discrepancy mining report for {dataset_id}")
|
| 300 |
+
return summary
|
| 301 |
+
store = _eval_store()
|
| 302 |
+
summaries = []
|
| 303 |
+
for ds in store.all("datasets"):
|
| 304 |
+
s = disc_mod.latest_summary(ds.dataset_id)
|
| 305 |
+
if s is not None:
|
| 306 |
+
summaries.append(s)
|
| 307 |
+
return {"datasets": summaries}
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
# ------------------------------------------------------------------ scenario database
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@router.get("/scenarios")
|
| 314 |
+
def scenarios(scenario_type: Optional[str] = None, source: Optional[str] = None,
|
| 315 |
+
severity: Optional[str] = None, weather: Optional[str] = None,
|
| 316 |
+
lighting: Optional[str] = None, text: Optional[str] = None,
|
| 317 |
+
limit: int = Query(100, ge=1, le=1000)):
|
| 318 |
+
odd_tags = {}
|
| 319 |
+
if weather:
|
| 320 |
+
odd_tags["weather"] = weather
|
| 321 |
+
if lighting:
|
| 322 |
+
odd_tags["lighting"] = lighting
|
| 323 |
+
db = sdb_mod.get_db()
|
| 324 |
+
records = db.search(scenario_type=scenario_type, source=source, severity=severity,
|
| 325 |
+
odd_tags=odd_tags or None, text=text, limit=limit)
|
| 326 |
+
return {"counts": db.counts(),
|
| 327 |
+
"scenarios": [r.model_dump() for r in records]}
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@router.post("/scenarios/populate")
|
| 331 |
+
def scenarios_populate():
|
| 332 |
+
added = sdb_mod.get_db().add_from_rare_events(_eval_store())
|
| 333 |
+
return {"imported_rare_events": added, "counts": sdb_mod.get_db().counts()}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
class ScenarioExportRequest(BaseModel):
|
| 337 |
+
scenario_type: Optional[str] = None
|
| 338 |
+
source: Optional[str] = None
|
| 339 |
+
severity: Optional[str] = None
|
| 340 |
+
text: Optional[str] = None
|
| 341 |
+
limit: int = Field(1000, ge=1, le=5000)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@router.post("/scenarios/export")
|
| 345 |
+
def scenarios_export(req: ScenarioExportRequest):
|
| 346 |
+
return sdb_mod.get_db().export_bundle(
|
| 347 |
+
scenario_type=req.scenario_type, source=req.source,
|
| 348 |
+
severity=req.severity, text=req.text, limit=req.limit)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ------------------------------------------------------------------ semantic search
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
class SemanticSearchRequest(BaseModel):
|
| 355 |
+
concept: str = Field(..., min_length=2, max_length=500)
|
| 356 |
+
filters: Optional[Dict] = None
|
| 357 |
+
target: str = Field("containers", pattern="^(containers|scenarios)$")
|
| 358 |
+
run: Optional[str] = Field(None, description="megaeval run for target=containers; "
|
| 359 |
+
"defaults to the newest published run")
|
| 360 |
+
k: int = Field(12, ge=1, le=100)
|
| 361 |
+
use_llm: Optional[bool] = Field(
|
| 362 |
+
None, description="None=auto (short-timeout Ollama attempt), true/false to force")
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
@router.post("/semantic-search")
|
| 366 |
+
def semantic_search(req: SemanticSearchRequest):
|
| 367 |
+
if req.target == "scenarios":
|
| 368 |
+
return sem_mod.search_scenarios(req.concept, filters=req.filters, k=req.k)
|
| 369 |
+
store = _mega_store()
|
| 370 |
+
if req.run:
|
| 371 |
+
run = _published_run(req.run)
|
| 372 |
+
else:
|
| 373 |
+
published = [r for r in store.runs.values() if r.status == "published"]
|
| 374 |
+
if not published:
|
| 375 |
+
raise HTTPException(404, "No published megaeval runs; start one via "
|
| 376 |
+
"/api/megaeval or use target=scenarios")
|
| 377 |
+
run = max(published, key=lambda r: r.created_at)
|
| 378 |
+
return sem_mod.search_containers(store, run, req.concept, filters=req.filters,
|
| 379 |
+
k=req.k, use_llm=req.use_llm)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
# ------------------------------------------------------------------ consensus evidence
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
@router.get("/consensus/summary")
|
| 386 |
+
def consensus_summary(dataset_id: str, examples: int = Query(5, ge=0, le=50)):
|
| 387 |
+
from sensorflow.evaluation.graders import dataset_grader_statistics
|
| 388 |
+
store = _eval_store()
|
| 389 |
+
if store.get("datasets", dataset_id) is None:
|
| 390 |
+
raise HTTPException(404, f"Unknown dataset {dataset_id}")
|
| 391 |
+
stats = dataset_grader_statistics(store, dataset_id)
|
| 392 |
+
if not stats:
|
| 393 |
+
raise HTTPException(404, f"No grader comparisons for {dataset_id}; run the "
|
| 394 |
+
"evaluation pipeline first")
|
| 395 |
+
anns = store.where("annotations", dataset_id=dataset_id)
|
| 396 |
+
sample = []
|
| 397 |
+
for a in sorted(anns, key=lambda x: x.annotation_id):
|
| 398 |
+
c = store.get("grader_comparisons", a.annotation_id)
|
| 399 |
+
if c is None:
|
| 400 |
+
continue
|
| 401 |
+
vector = {k[4:]: v for k, v in c.kappa_stats.items() if k.startswith("csv_")}
|
| 402 |
+
mbr = {k[4:]: v for k, v in c.kappa_stats.items() if k.startswith("mbr_")}
|
| 403 |
+
if not vector:
|
| 404 |
+
continue
|
| 405 |
+
sample.append({"annotation_id": a.annotation_id,
|
| 406 |
+
"consensus_scalar": c.consensus,
|
| 407 |
+
"consensus_score_vector": vector,
|
| 408 |
+
"mbr": mbr})
|
| 409 |
+
if len(sample) >= examples:
|
| 410 |
+
break
|
| 411 |
+
return {"dataset_id": dataset_id, "statistics": stats,
|
| 412 |
+
"examples": sample,
|
| 413 |
+
"note": "grader panel is simulated (see graders.py); kappa/tau/MBR "
|
| 414 |
+
"math is exact"}
|
sensorflow/safety/calibration.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-sensor extrinsic calibration validation (Deepen-style safety gate).
|
| 2 |
+
|
| 3 |
+
Models a 6-DoF extrinsic transform (rotation + translation) for a LiDAR ->
|
| 4 |
+
camera sensor pair and validates it by projecting LiDAR points of labeled
|
| 5 |
+
objects into the camera frame and measuring projection consistency against the
|
| 6 |
+
objects' 2D boxes:
|
| 7 |
+
|
| 8 |
+
- reprojection residual: the labeled 3D box corners are projected through the
|
| 9 |
+
assumed extrinsic; residual = projected-box center vs labeled 2D box center (px)
|
| 10 |
+
- inlier ratio: fraction of the object's projected LiDAR points inside the
|
| 11 |
+
(padded) 2D box
|
| 12 |
+
|
| 13 |
+
Diagnosis separates two failure modes:
|
| 14 |
+
- MISCALIBRATED systematic bias — residual vectors share a common
|
| 15 |
+
direction/magnitude across objects (extrinsic drift);
|
| 16 |
+
the rotation offset is estimated from the bias.
|
| 17 |
+
- PERCEPTION_FAILURE random disagreement — a minority of objects have large
|
| 18 |
+
residuals in inconsistent directions while the global
|
| 19 |
+
bias stays small (bad 2D detections, not calibration);
|
| 20 |
+
flagged objects carry the platform's existing
|
| 21 |
+
SENSOR_DISAGREEMENT failure reason.
|
| 22 |
+
|
| 23 |
+
SIMULATED: scenes are synthetic (deterministic given seed). Calibrated scenes
|
| 24 |
+
are generated with the true extrinsic; the validator then either uses a
|
| 25 |
+
perturbed extrinsic (miscalibration injection) or tampers a fraction of the 2D
|
| 26 |
+
boxes (perception-failure injection) to prove the detector works. Results feed
|
| 27 |
+
the Scenario Quality Gate (gates.py) and the labeleval alerting store.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import math
|
| 33 |
+
from datetime import datetime, timezone
|
| 34 |
+
from typing import Dict, List, Optional
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
from sensorflow.safety.store import read_json, write_json
|
| 39 |
+
|
| 40 |
+
# Pinhole intrinsics matching the platform's synthetic camera (800x450, f=500).
|
| 41 |
+
INTRINSICS = {"fx": 500.0, "fy": 500.0, "cx": 400.0, "cy": 225.0,
|
| 42 |
+
"width": 800, "height": 450}
|
| 43 |
+
|
| 44 |
+
# True LiDAR->camera extrinsic. LiDAR frame: x fwd / y left / z up.
|
| 45 |
+
# Camera frame: x right / y down / z fwd. R0 maps axes; t0 is the lever arm (m).
|
| 46 |
+
R0 = np.array([[0.0, -1.0, 0.0],
|
| 47 |
+
[0.0, 0.0, -1.0],
|
| 48 |
+
[1.0, 0.0, 0.0]])
|
| 49 |
+
T0 = np.array([0.06, -0.12, 0.15])
|
| 50 |
+
|
| 51 |
+
CLASS_DIMS = { # l, w, h (m) — mirrors the labeleval synthetic priors
|
| 52 |
+
"vehicle": (4.5, 1.9, 1.6),
|
| 53 |
+
"truck": (8.0, 2.5, 3.1),
|
| 54 |
+
"pedestrian": (0.7, 0.7, 1.75),
|
| 55 |
+
"cyclist": (1.8, 0.6, 1.7),
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
DEFAULT_THRESHOLDS = {
|
| 59 |
+
"bias_px": 6.0, # systematic residual => miscalibration suspect
|
| 60 |
+
"bias_to_scatter_ratio": 1.5,
|
| 61 |
+
"outlier_px": 14.0, # per-object residual => perception suspect
|
| 62 |
+
"min_outlier_fraction": 0.10,
|
| 63 |
+
"min_inlier_ratio": 0.75, # mean over objects for a healthy pair
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _now() -> str:
|
| 68 |
+
return datetime.now(timezone.utc).isoformat()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def rotation_matrix(roll_deg: float = 0.0, pitch_deg: float = 0.0,
|
| 72 |
+
yaw_deg: float = 0.0) -> np.ndarray:
|
| 73 |
+
"""Intrinsic XYZ Euler rotation (applied in the camera frame)."""
|
| 74 |
+
r, p, y = (math.radians(a) for a in (roll_deg, pitch_deg, yaw_deg))
|
| 75 |
+
rx = np.array([[1, 0, 0], [0, math.cos(r), -math.sin(r)], [0, math.sin(r), math.cos(r)]])
|
| 76 |
+
ry = np.array([[math.cos(p), 0, math.sin(p)], [0, 1, 0], [-math.sin(p), 0, math.cos(p)]])
|
| 77 |
+
rz = np.array([[math.cos(y), -math.sin(y), 0], [math.sin(y), math.cos(y), 0], [0, 0, 1]])
|
| 78 |
+
return rz @ ry @ rx
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def make_extrinsic(rotation: Optional[np.ndarray] = None,
|
| 82 |
+
translation: Optional[np.ndarray] = None) -> Dict:
|
| 83 |
+
return {"R": (rotation if rotation is not None else R0),
|
| 84 |
+
"t": (translation if translation is not None else T0)}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def perturb_extrinsic(extrinsic: Dict, rotation_offset_deg: float = 0.0,
|
| 88 |
+
axis: str = "pitch", translation_offset_m: float = 0.0) -> Dict:
|
| 89 |
+
kw = {f"{axis}_deg": rotation_offset_deg}
|
| 90 |
+
r_off = rotation_matrix(**kw)
|
| 91 |
+
t_off = np.array([translation_offset_m, 0.0, 0.0])
|
| 92 |
+
return {"R": r_off @ extrinsic["R"], "t": extrinsic["t"] + t_off}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def project_points(points_lidar: np.ndarray, extrinsic: Dict) -> np.ndarray:
|
| 96 |
+
"""Project Nx3 LiDAR points to Nx2 pixel coordinates."""
|
| 97 |
+
cam = points_lidar @ extrinsic["R"].T + extrinsic["t"]
|
| 98 |
+
z = np.clip(cam[:, 2], 0.1, None)
|
| 99 |
+
u = INTRINSICS["fx"] * cam[:, 0] / z + INTRINSICS["cx"]
|
| 100 |
+
v = INTRINSICS["fy"] * cam[:, 1] / z + INTRINSICS["cy"]
|
| 101 |
+
return np.stack([u, v], axis=1)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ------------------------------------------------------------------ scene generation
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def generate_scene(num_objects: int = 14, seed: int = 3) -> Dict:
|
| 108 |
+
"""Deterministic calibrated scene: 3D objects with LiDAR point clusters and
|
| 109 |
+
2D boxes produced by projecting through the TRUE extrinsic."""
|
| 110 |
+
rng = np.random.default_rng(seed)
|
| 111 |
+
classes = list(CLASS_DIMS)
|
| 112 |
+
objects = []
|
| 113 |
+
attempts = 0
|
| 114 |
+
while len(objects) < num_objects and attempts < num_objects * 20:
|
| 115 |
+
attempts += 1
|
| 116 |
+
cls = classes[int(rng.integers(0, len(classes)))]
|
| 117 |
+
l, w, h = CLASS_DIMS[cls]
|
| 118 |
+
center = np.array([float(rng.uniform(8, 38)),
|
| 119 |
+
float(rng.uniform(-9, 9)),
|
| 120 |
+
h / 2])
|
| 121 |
+
n_pts = int(np.clip(240 * 12.0 / center[0], 40, 260))
|
| 122 |
+
pts = center + rng.uniform([-l / 2, -w / 2, -h / 2], [l / 2, w / 2, h / 2],
|
| 123 |
+
size=(n_pts, 3))
|
| 124 |
+
corners = center + np.array([[sx * l / 2, sy * w / 2, sz * h / 2]
|
| 125 |
+
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)])
|
| 126 |
+
uv = project_points(corners, make_extrinsic())
|
| 127 |
+
u0, v0 = uv.min(axis=0)
|
| 128 |
+
u1, v1 = uv.max(axis=0)
|
| 129 |
+
if u0 < 0 or v0 < 0 or u1 > INTRINSICS["width"] or v1 > INTRINSICS["height"]:
|
| 130 |
+
continue # keep only fully-visible objects
|
| 131 |
+
# 2D label noise: real camera labels are not pixel-perfect.
|
| 132 |
+
jitter = rng.normal(0.0, 1.2, size=4)
|
| 133 |
+
objects.append({
|
| 134 |
+
"object_id": f"cal-obj-{len(objects)}",
|
| 135 |
+
"class_name": cls,
|
| 136 |
+
"center_lidar": center.tolist(),
|
| 137 |
+
"corners_lidar": corners,
|
| 138 |
+
"points_lidar": pts,
|
| 139 |
+
"bbox_2d": [float(u0 + jitter[0]), float(v0 + jitter[1]),
|
| 140 |
+
float(u1 - u0 + jitter[2]), float(v1 - v0 + jitter[3])],
|
| 141 |
+
})
|
| 142 |
+
return {"seed": seed, "objects": objects, "true_extrinsic": make_extrinsic(),
|
| 143 |
+
"simulated": True}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ------------------------------------------------------------------ validation
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def validate_scene(scene: Dict, assumed_extrinsic: Dict,
|
| 150 |
+
thresholds: Optional[Dict] = None) -> Dict:
|
| 151 |
+
"""Project each object's LiDAR points with the assumed extrinsic and measure
|
| 152 |
+
consistency against its 2D box; classify the failure mode."""
|
| 153 |
+
th = {**DEFAULT_THRESHOLDS, **(thresholds or {})}
|
| 154 |
+
per_object: List[Dict] = []
|
| 155 |
+
residuals = []
|
| 156 |
+
for obj in scene["objects"]:
|
| 157 |
+
corner_uv = project_points(np.asarray(obj["corners_lidar"]), assumed_extrinsic)
|
| 158 |
+
proj_center = (corner_uv.min(axis=0) + corner_uv.max(axis=0)) / 2
|
| 159 |
+
uv = project_points(np.asarray(obj["points_lidar"]), assumed_extrinsic)
|
| 160 |
+
bx, by, bw, bh = obj["bbox_2d"]
|
| 161 |
+
box_center = np.array([bx + bw / 2, by + bh / 2])
|
| 162 |
+
residual = proj_center - box_center
|
| 163 |
+
pad_x, pad_y = 0.15 * bw, 0.15 * bh
|
| 164 |
+
inside = ((uv[:, 0] >= bx - pad_x) & (uv[:, 0] <= bx + bw + pad_x)
|
| 165 |
+
& (uv[:, 1] >= by - pad_y) & (uv[:, 1] <= by + bh + pad_y))
|
| 166 |
+
inlier_ratio = float(inside.mean())
|
| 167 |
+
r_mag = float(np.linalg.norm(residual))
|
| 168 |
+
residuals.append(residual)
|
| 169 |
+
per_object.append({
|
| 170 |
+
"object_id": obj["object_id"],
|
| 171 |
+
"class_name": obj["class_name"],
|
| 172 |
+
"residual_px": [round(float(residual[0]), 2), round(float(residual[1]), 2)],
|
| 173 |
+
"residual_magnitude_px": round(r_mag, 2),
|
| 174 |
+
"inlier_ratio": round(inlier_ratio, 4),
|
| 175 |
+
"flagged": False,
|
| 176 |
+
"failure_reason": None,
|
| 177 |
+
})
|
| 178 |
+
|
| 179 |
+
residuals = np.array(residuals)
|
| 180 |
+
mean_res = residuals.mean(axis=0)
|
| 181 |
+
bias = float(np.linalg.norm(mean_res))
|
| 182 |
+
scatter = float(np.linalg.norm(residuals - mean_res, axis=1).mean())
|
| 183 |
+
mean_inlier = float(np.mean([o["inlier_ratio"] for o in per_object]))
|
| 184 |
+
outliers = [o for o in per_object if o["residual_magnitude_px"] > th["outlier_px"]]
|
| 185 |
+
outlier_fraction = len(outliers) / max(len(per_object), 1)
|
| 186 |
+
|
| 187 |
+
systematic = bias > th["bias_px"] and bias > th["bias_to_scatter_ratio"] * scatter
|
| 188 |
+
if systematic:
|
| 189 |
+
status = "MISCALIBRATED"
|
| 190 |
+
for o in per_object:
|
| 191 |
+
o["flagged"] = True
|
| 192 |
+
o["failure_reason"] = "MISCALIBRATION"
|
| 193 |
+
elif outlier_fraction >= th["min_outlier_fraction"]:
|
| 194 |
+
status = "PERCEPTION_FAILURE"
|
| 195 |
+
for o in outliers:
|
| 196 |
+
o["flagged"] = True
|
| 197 |
+
# existing platform failure reason (records.FAILURE_REASONS)
|
| 198 |
+
o["failure_reason"] = "SENSOR_DISAGREEMENT"
|
| 199 |
+
else:
|
| 200 |
+
status = "CALIBRATED"
|
| 201 |
+
|
| 202 |
+
est_rot_deg = math.degrees(math.atan(bias / INTRINSICS["fx"]))
|
| 203 |
+
checks = [
|
| 204 |
+
{"name": "systematic_bias_px", "actual": round(bias, 2),
|
| 205 |
+
"threshold": th["bias_px"], "passed": bias <= th["bias_px"]},
|
| 206 |
+
{"name": "outlier_fraction", "actual": round(outlier_fraction, 4),
|
| 207 |
+
"threshold": th["min_outlier_fraction"],
|
| 208 |
+
"passed": outlier_fraction < th["min_outlier_fraction"]},
|
| 209 |
+
{"name": "mean_inlier_ratio", "actual": round(mean_inlier, 4),
|
| 210 |
+
"threshold": th["min_inlier_ratio"],
|
| 211 |
+
"passed": mean_inlier >= th["min_inlier_ratio"]},
|
| 212 |
+
]
|
| 213 |
+
return {
|
| 214 |
+
"status": status,
|
| 215 |
+
"passed": status == "CALIBRATED",
|
| 216 |
+
"sensor_pair": "lidar_top->camera_front",
|
| 217 |
+
"checks": checks,
|
| 218 |
+
"metrics": {
|
| 219 |
+
"bias_px": round(bias, 3),
|
| 220 |
+
"bias_vector_px": [round(float(mean_res[0]), 3), round(float(mean_res[1]), 3)],
|
| 221 |
+
"scatter_px": round(scatter, 3),
|
| 222 |
+
"mean_inlier_ratio": round(mean_inlier, 4),
|
| 223 |
+
"outlier_fraction": round(outlier_fraction, 4),
|
| 224 |
+
"estimated_rotation_offset_deg": round(est_rot_deg, 3),
|
| 225 |
+
"num_objects": len(per_object),
|
| 226 |
+
},
|
| 227 |
+
"per_object": per_object,
|
| 228 |
+
"thresholds": th,
|
| 229 |
+
"diagnosis": {
|
| 230 |
+
"MISCALIBRATED": "systematic bias shared across objects — extrinsic drift",
|
| 231 |
+
"PERCEPTION_FAILURE": "random disagreement on a minority of objects — "
|
| 232 |
+
"detection defects, mapped to SENSOR_DISAGREEMENT",
|
| 233 |
+
"CALIBRATED": "projection consistent within thresholds",
|
| 234 |
+
}[status],
|
| 235 |
+
"simulated": True,
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def run_validation(mode: str = "clean", rotation_offset_deg: float = 2.0,
|
| 240 |
+
translation_offset_m: float = 0.0, tamper_fraction: float = 0.25,
|
| 241 |
+
num_objects: int = 14, seed: int = 3,
|
| 242 |
+
thresholds: Optional[Dict] = None, persist: bool = True) -> Dict:
|
| 243 |
+
"""Generate a calibrated scene, optionally inject a fault, and validate.
|
| 244 |
+
|
| 245 |
+
Modes: clean | miscalibrated (extrinsic perturbed by rotation_offset_deg)
|
| 246 |
+
| perception_failure (a fraction of 2D boxes tampered)."""
|
| 247 |
+
scene = generate_scene(num_objects=num_objects, seed=seed)
|
| 248 |
+
assumed = make_extrinsic()
|
| 249 |
+
if mode == "miscalibrated":
|
| 250 |
+
assumed = perturb_extrinsic(assumed, rotation_offset_deg=rotation_offset_deg,
|
| 251 |
+
axis="pitch", translation_offset_m=translation_offset_m)
|
| 252 |
+
elif mode == "perception_failure":
|
| 253 |
+
rng = np.random.default_rng(seed + 77)
|
| 254 |
+
n_tamper = max(1, int(round(tamper_fraction * len(scene["objects"]))))
|
| 255 |
+
idx = rng.choice(len(scene["objects"]), size=n_tamper, replace=False)
|
| 256 |
+
for i in idx:
|
| 257 |
+
box = scene["objects"][int(i)]["bbox_2d"]
|
| 258 |
+
box[0] += float(rng.choice([-1, 1]) * rng.uniform(25, 45))
|
| 259 |
+
box[1] += float(rng.choice([-1, 1]) * rng.uniform(20, 35))
|
| 260 |
+
elif mode != "clean":
|
| 261 |
+
raise ValueError(f"unknown mode {mode!r}")
|
| 262 |
+
|
| 263 |
+
result = validate_scene(scene, assumed, thresholds)
|
| 264 |
+
result["mode"] = mode
|
| 265 |
+
result["injected"] = {
|
| 266 |
+
"rotation_offset_deg": rotation_offset_deg if mode == "miscalibrated" else 0.0,
|
| 267 |
+
"translation_offset_m": translation_offset_m if mode == "miscalibrated" else 0.0,
|
| 268 |
+
"tamper_fraction": tamper_fraction if mode == "perception_failure" else 0.0,
|
| 269 |
+
}
|
| 270 |
+
result["seed"] = seed
|
| 271 |
+
result["created_at"] = _now()
|
| 272 |
+
|
| 273 |
+
if persist:
|
| 274 |
+
slim = {k: v for k, v in result.items() if k != "per_object"}
|
| 275 |
+
slim["per_object"] = [o for o in result["per_object"] if o["flagged"]][:25]
|
| 276 |
+
write_json(slim, "calibration", "status.json")
|
| 277 |
+
_emit_alert(result)
|
| 278 |
+
return result
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def latest_status() -> Optional[Dict]:
|
| 282 |
+
return read_json("calibration", "status.json")
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _emit_alert(result: Dict) -> None:
|
| 286 |
+
"""Surface failures in the existing labeleval alerting store (best-effort)."""
|
| 287 |
+
if result["status"] == "CALIBRATED":
|
| 288 |
+
return
|
| 289 |
+
try:
|
| 290 |
+
from sensorflow.evaluation.records import Alert, get_store, new_id
|
| 291 |
+
store = get_store()
|
| 292 |
+
store.put("alerts", Alert(
|
| 293 |
+
alert_id=new_id("alert"), kind="calibration",
|
| 294 |
+
severity="critical" if result["status"] == "MISCALIBRATED" else "warning",
|
| 295 |
+
message=(f"Calibration validation: {result['status']} — "
|
| 296 |
+
f"bias {result['metrics']['bias_px']}px, "
|
| 297 |
+
f"outliers {result['metrics']['outlier_fraction']*100:.0f}%, "
|
| 298 |
+
f"{result['diagnosis']}"),
|
| 299 |
+
evidence_page="safety", evidence_id="calibration"))
|
| 300 |
+
store.save()
|
| 301 |
+
except Exception:
|
| 302 |
+
pass
|
sensorflow/safety/discrepancy.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-labeling discrepancy mining (continuous-learning loop).
|
| 2 |
+
|
| 3 |
+
Industry concept: production AV stacks run a weaker real-time ("online")
|
| 4 |
+
perception pass in the vehicle and a stronger non-causal ("offline") auto-label
|
| 5 |
+
pass over the same logs. Diffing the two is one of the highest-yield scenario
|
| 6 |
+
mining signals — anything the online stack missed but the offline stack found
|
| 7 |
+
is a candidate training example / regression scenario.
|
| 8 |
+
|
| 9 |
+
This module simulates the online pass deterministically over an existing
|
| 10 |
+
labeleval dataset (the stored auto-labels play the role of the strong offline
|
| 11 |
+
pass), diffs the two, and converts discrepancies into queryable scenario-mining
|
| 12 |
+
records:
|
| 13 |
+
|
| 14 |
+
- MISSED_ONLINE: offline labeled the object, online did not detect it
|
| 15 |
+
- CLASS_MISMATCH: both detected it, classes differ
|
| 16 |
+
- GEOMETRY_DELTA: both detected it, geometry disagrees (center offset / IoU)
|
| 17 |
+
- OFFLINE_MISS: online detected an object the offline pass dropped (rare)
|
| 18 |
+
|
| 19 |
+
High-severity discrepancies feed the existing rare-event store; all of them
|
| 20 |
+
feed the scenario database (source="discrepancy") and cohort summaries.
|
| 21 |
+
|
| 22 |
+
SIMULATED: the online pass is a seeded degradation model over ground truth
|
| 23 |
+
(higher miss rates, class confusion, geometry noise), not a real detector.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import math
|
| 29 |
+
from datetime import datetime, timezone
|
| 30 |
+
from typing import Dict, List, Optional
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
from sensorflow.evaluation.records import (
|
| 35 |
+
SAFETY_CRITICAL_CLASSES,
|
| 36 |
+
EvalStore,
|
| 37 |
+
RareEvent,
|
| 38 |
+
new_id,
|
| 39 |
+
)
|
| 40 |
+
from sensorflow.metrics.perception_3d import bev_iou
|
| 41 |
+
from sensorflow.safety.store import read_json, write_json
|
| 42 |
+
|
| 43 |
+
DEFAULT_ONLINE_PROFILE = {
|
| 44 |
+
"extra_miss_rate": 0.12, # online misses this fraction on top of offline
|
| 45 |
+
"miss_rate_by_class": {"pedestrian": 0.08, "cyclist": 0.08}, # additional VRU misses
|
| 46 |
+
"class_confusion_rate": 0.05,
|
| 47 |
+
"position_noise_m": 0.55,
|
| 48 |
+
"yaw_noise_deg": 6.0,
|
| 49 |
+
"night_miss_multiplier": 1.6, # online degrades harder at night/rain
|
| 50 |
+
"rain_miss_multiplier": 1.4,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
GEOMETRY_CENTER_THRESHOLD_M = 0.75
|
| 54 |
+
GEOMETRY_IOU_THRESHOLD = 0.5
|
| 55 |
+
|
| 56 |
+
_CONFUSABLE = {
|
| 57 |
+
"vehicle": "truck", "truck": "vehicle", "pedestrian": "cyclist",
|
| 58 |
+
"cyclist": "pedestrian", "motorcycle": "cyclist",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _now() -> str:
|
| 63 |
+
return datetime.now(timezone.utc).isoformat()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def simulate_online_pass(store: EvalStore, dataset_id: str,
|
| 67 |
+
profile: Optional[Dict] = None, seed: int = 101) -> Dict[str, Dict]:
|
| 68 |
+
"""Deterministic weaker 'online' perception pass over the dataset's GT.
|
| 69 |
+
|
| 70 |
+
Returns {gt_id: detection dict} for every ground-truth object the online
|
| 71 |
+
stack detected. SIMULATED degradation model, clearly marked.
|
| 72 |
+
"""
|
| 73 |
+
p = {**DEFAULT_ONLINE_PROFILE, **(profile or {})}
|
| 74 |
+
rng = np.random.default_rng(seed)
|
| 75 |
+
detections: Dict[str, Dict] = {}
|
| 76 |
+
frames = sorted(store.where("frames", dataset_id=dataset_id), key=lambda f: f.index)
|
| 77 |
+
for frame in frames:
|
| 78 |
+
cond_mult = 1.0
|
| 79 |
+
if frame.time_of_day == "night":
|
| 80 |
+
cond_mult *= p["night_miss_multiplier"]
|
| 81 |
+
if frame.weather not in ("clear", ""):
|
| 82 |
+
cond_mult *= p["rain_miss_multiplier"]
|
| 83 |
+
for gt in frame.gt_boxes:
|
| 84 |
+
miss = p["extra_miss_rate"] + p["miss_rate_by_class"].get(gt.class_name, 0.0)
|
| 85 |
+
miss = min(1.0, miss * cond_mult) if miss < 1.0 else 1.0
|
| 86 |
+
if rng.random() < miss:
|
| 87 |
+
continue # online miss
|
| 88 |
+
x, y, z, l, w, h, yaw = gt.bbox_3d
|
| 89 |
+
x += float(rng.normal(0, p["position_noise_m"]))
|
| 90 |
+
y += float(rng.normal(0, p["position_noise_m"]))
|
| 91 |
+
yaw += float(rng.normal(0, math.radians(p["yaw_noise_deg"])))
|
| 92 |
+
cls = gt.class_name
|
| 93 |
+
if rng.random() < p["class_confusion_rate"]:
|
| 94 |
+
cls = _CONFUSABLE.get(cls, cls)
|
| 95 |
+
detections[gt.gt_id] = {
|
| 96 |
+
"gt_id": gt.gt_id, "frame_id": frame.frame_id, "class_name": cls,
|
| 97 |
+
"bbox_3d": [round(v, 4) for v in (x, y, z, l, w, h, yaw)],
|
| 98 |
+
"confidence": round(float(rng.uniform(0.35, 0.85)), 3),
|
| 99 |
+
}
|
| 100 |
+
return detections
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def mine(store: EvalStore, dataset_id: str, profile: Optional[Dict] = None,
|
| 104 |
+
seed: int = 101, persist: bool = True) -> Dict:
|
| 105 |
+
"""Diff the simulated online pass against the stored offline auto-labels."""
|
| 106 |
+
dataset = store.get("datasets", dataset_id)
|
| 107 |
+
if dataset is None:
|
| 108 |
+
raise KeyError(f"Unknown dataset {dataset_id}")
|
| 109 |
+
|
| 110 |
+
online = simulate_online_pass(store, dataset_id, profile, seed)
|
| 111 |
+
frames = {f.frame_id: f for f in store.where("frames", dataset_id=dataset_id)}
|
| 112 |
+
offline_by_gt = {a.matched_gt_id: a
|
| 113 |
+
for a in store.where("annotations", dataset_id=dataset_id)
|
| 114 |
+
if a.matched_gt_id}
|
| 115 |
+
|
| 116 |
+
discrepancies: List[Dict] = []
|
| 117 |
+
cohort_totals: Dict[str, int] = {}
|
| 118 |
+
cohort_hits: Dict[str, int] = {}
|
| 119 |
+
|
| 120 |
+
for frame in frames.values():
|
| 121 |
+
for gt in frame.gt_boxes:
|
| 122 |
+
cohort = f"{gt.class_name}/{frame.weather}/{frame.time_of_day}"
|
| 123 |
+
cohort_totals[cohort] = cohort_totals.get(cohort, 0) + 1
|
| 124 |
+
off = offline_by_gt.get(gt.gt_id)
|
| 125 |
+
on = online.get(gt.gt_id)
|
| 126 |
+
dtype = details = None
|
| 127 |
+
if off is not None and on is None:
|
| 128 |
+
dtype = "MISSED_ONLINE"
|
| 129 |
+
details = {"offline_confidence": off.confidence}
|
| 130 |
+
elif off is not None and on is not None:
|
| 131 |
+
if on["class_name"] != off.class_name:
|
| 132 |
+
dtype = "CLASS_MISMATCH"
|
| 133 |
+
details = {"offline_class": off.class_name,
|
| 134 |
+
"online_class": on["class_name"]}
|
| 135 |
+
elif off.bbox_3d:
|
| 136 |
+
center_d = math.hypot(on["bbox_3d"][0] - off.bbox_3d[0],
|
| 137 |
+
on["bbox_3d"][1] - off.bbox_3d[1])
|
| 138 |
+
iou = bev_iou(on["bbox_3d"], off.bbox_3d)
|
| 139 |
+
if center_d > GEOMETRY_CENTER_THRESHOLD_M or iou < GEOMETRY_IOU_THRESHOLD:
|
| 140 |
+
dtype = "GEOMETRY_DELTA"
|
| 141 |
+
details = {"center_delta_m": round(center_d, 3),
|
| 142 |
+
"bev_iou": round(iou, 4)}
|
| 143 |
+
elif off is None and on is not None:
|
| 144 |
+
dtype = "OFFLINE_MISS"
|
| 145 |
+
details = {"online_class": on["class_name"]}
|
| 146 |
+
if dtype is None:
|
| 147 |
+
continue
|
| 148 |
+
cohort_hits[cohort] = cohort_hits.get(cohort, 0) + 1
|
| 149 |
+
safety = gt.class_name in SAFETY_CRITICAL_CLASSES
|
| 150 |
+
severity = ("critical" if dtype == "MISSED_ONLINE" and safety
|
| 151 |
+
else "high" if dtype == "MISSED_ONLINE" or safety
|
| 152 |
+
else "medium")
|
| 153 |
+
discrepancies.append({
|
| 154 |
+
"discrepancy_id": new_id("disc"),
|
| 155 |
+
"dataset_id": dataset_id,
|
| 156 |
+
"frame_id": frame.frame_id,
|
| 157 |
+
"gt_id": gt.gt_id,
|
| 158 |
+
"type": dtype,
|
| 159 |
+
"class_name": gt.class_name,
|
| 160 |
+
"weather": frame.weather,
|
| 161 |
+
"time_of_day": frame.time_of_day,
|
| 162 |
+
"safety_critical": safety,
|
| 163 |
+
"severity": severity,
|
| 164 |
+
"details": details,
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
by_type: Dict[str, int] = {}
|
| 168 |
+
for d in discrepancies:
|
| 169 |
+
by_type[d["type"]] = by_type.get(d["type"], 0) + 1
|
| 170 |
+
|
| 171 |
+
cohorts = []
|
| 172 |
+
for cohort, total in sorted(cohort_totals.items()):
|
| 173 |
+
hits = cohort_hits.get(cohort, 0)
|
| 174 |
+
cls, weather, tod = cohort.split("/")
|
| 175 |
+
cohorts.append({"cohort": cohort, "class": cls, "weather": weather,
|
| 176 |
+
"time_of_day": tod, "objects": total, "discrepancies": hits,
|
| 177 |
+
"discrepancy_rate": round(hits / total, 4)})
|
| 178 |
+
cohorts.sort(key=lambda c: -c["discrepancy_rate"])
|
| 179 |
+
|
| 180 |
+
total_objects = sum(cohort_totals.values())
|
| 181 |
+
report = {
|
| 182 |
+
"dataset_id": dataset_id,
|
| 183 |
+
"seed": seed,
|
| 184 |
+
"created_at": _now(),
|
| 185 |
+
"online_profile": {**DEFAULT_ONLINE_PROFILE, **(profile or {})},
|
| 186 |
+
"totals": {
|
| 187 |
+
"objects": total_objects,
|
| 188 |
+
"online_detections": len(online),
|
| 189 |
+
"offline_annotations": len(offline_by_gt),
|
| 190 |
+
"discrepancies": len(discrepancies),
|
| 191 |
+
"discrepancy_rate": round(len(discrepancies) / max(total_objects, 1), 4),
|
| 192 |
+
},
|
| 193 |
+
"by_type": by_type,
|
| 194 |
+
"cohorts": cohorts,
|
| 195 |
+
"discrepancies": discrepancies,
|
| 196 |
+
"simulated": True,
|
| 197 |
+
"method": "deterministic simulated online pass vs stored offline auto-labels; "
|
| 198 |
+
"diff over shared ground-truth ids",
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
if persist:
|
| 202 |
+
write_json(report, "discrepancy", f"{dataset_id}.json")
|
| 203 |
+
_feed_stores(store, report)
|
| 204 |
+
return report
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _feed_stores(store: EvalStore, report: Dict) -> None:
|
| 208 |
+
"""Feed high-severity discrepancies into the rare-event store, all of them
|
| 209 |
+
into the scenario database, and record process units (best-effort)."""
|
| 210 |
+
critical = [d for d in report["discrepancies"] if d["severity"] == "critical"]
|
| 211 |
+
for d in critical[:40]:
|
| 212 |
+
store.put("rare_events", RareEvent(
|
| 213 |
+
event_id=new_id("evt"),
|
| 214 |
+
dataset_id=d["dataset_id"],
|
| 215 |
+
scenario_type="online_perception_miss",
|
| 216 |
+
severity="critical",
|
| 217 |
+
rarity_score=0.85,
|
| 218 |
+
anomaly_score=0.0,
|
| 219 |
+
confidence=0.9,
|
| 220 |
+
evidence_frames=[d["frame_id"]],
|
| 221 |
+
sensor_evidence={"discrepancy": f"{d['type']} on {d['class_name']} "
|
| 222 |
+
f"({d['weather']}/{d['time_of_day']})"},
|
| 223 |
+
description=f"Online stack missed a {d['class_name']} the offline "
|
| 224 |
+
f"auto-label pass found (simulated online pass)",
|
| 225 |
+
))
|
| 226 |
+
try:
|
| 227 |
+
from sensorflow.evaluation.process_units import ProcessMeter
|
| 228 |
+
ProcessMeter(store, run_id=report["dataset_id"]).record(
|
| 229 |
+
"discrepancy_mining", report["totals"]["objects"], factor=0.3)
|
| 230 |
+
except Exception:
|
| 231 |
+
pass
|
| 232 |
+
store.save()
|
| 233 |
+
try:
|
| 234 |
+
from sensorflow.safety import scenario_db
|
| 235 |
+
scenario_db.get_db().add_from_discrepancies(report)
|
| 236 |
+
except Exception:
|
| 237 |
+
pass
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def latest_summary(dataset_id: str) -> Optional[Dict]:
|
| 241 |
+
"""Persisted mining report without the raw per-object rows."""
|
| 242 |
+
report = read_json("discrepancy", f"{dataset_id}.json")
|
| 243 |
+
if report is None:
|
| 244 |
+
return None
|
| 245 |
+
slim = {k: v for k, v in report.items() if k != "discrepancies"}
|
| 246 |
+
slim["sample_discrepancies"] = report["discrepancies"][:25]
|
| 247 |
+
return slim
|
sensorflow/safety/gates.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layered release gating + Safety Evidence Package.
|
| 2 |
+
|
| 3 |
+
Industry concept: staged release gates for AV perception stacks, producing an
|
| 4 |
+
auditable evidence trail structured after three families of standards:
|
| 5 |
+
|
| 6 |
+
- ISO 26262 (functional safety): deterministic, repeatable verification —
|
| 7 |
+
geometric plausibility checks, calibration validation, regression testing
|
| 8 |
+
against a pinned baseline with pinned thresholds.
|
| 9 |
+
- ISO 21448 (SOTIF): performance-limitation risk — ODD coverage of the
|
| 10 |
+
scenario space, under-covered cells, surrogate-safety (SSAM-style) analysis
|
| 11 |
+
of near-miss severity.
|
| 12 |
+
- UL 4600 (safety case): the Release Readiness Gate compiles all gate
|
| 13 |
+
evidence, methodology and lineage into a claim/evidence-structured Safety
|
| 14 |
+
Evidence Package (JSON + rendered markdown).
|
| 15 |
+
|
| 16 |
+
IMPORTANT WORDING: the package SUPPORTS a safety case by organizing
|
| 17 |
+
verifiable evidence. It does NOT certify or demonstrate compliance with any
|
| 18 |
+
standard, and several inputs are simulated (marked per-record).
|
| 19 |
+
|
| 20 |
+
Gates (evaluated in order, block-on-fail; every gate always produces an
|
| 21 |
+
evidence record even when an earlier gate blocked):
|
| 22 |
+
|
| 23 |
+
1. scenario_quality — geometric plausibility (evaluation.validation) +
|
| 24 |
+
multi-sensor calibration validation (safety.calibration)
|
| 25 |
+
2. coverage — ODD combinatorial coverage thresholds (safety.odd)
|
| 26 |
+
3. regression — model-vs-model compare (megaeval.analysis.compare_runs,
|
| 27 |
+
not duplicated here)
|
| 28 |
+
4. safety — surrogate-safety: aggregate CSI must not increase vs
|
| 29 |
+
baseline beyond tolerance (safety.ssam_ext)
|
| 30 |
+
5. release_readiness — all of the above + compiles the Safety Evidence Package
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
from datetime import datetime, timezone
|
| 36 |
+
from typing import Dict, List, Optional
|
| 37 |
+
|
| 38 |
+
from sensorflow.safety import calibration as calib_mod
|
| 39 |
+
from sensorflow.safety import odd as odd_mod
|
| 40 |
+
from sensorflow.safety import ssam_ext
|
| 41 |
+
from sensorflow.safety.store import read_json, write_json
|
| 42 |
+
|
| 43 |
+
DEFAULT_POLICY: Dict = {
|
| 44 |
+
"scenario_quality": {
|
| 45 |
+
"enabled": True,
|
| 46 |
+
"min_geometric_pass_rate": 0.70,
|
| 47 |
+
"geometric_sample_size": 24,
|
| 48 |
+
"block_on_miscalibration": True,
|
| 49 |
+
},
|
| 50 |
+
"coverage": {
|
| 51 |
+
"enabled": True,
|
| 52 |
+
"dims": ["weather", "lighting", "road_type"],
|
| 53 |
+
"min_samples": 50,
|
| 54 |
+
"max_ci_width": 0.25,
|
| 55 |
+
"min_coverage_rate": 0.60,
|
| 56 |
+
"min_production_weighted_coverage": 0.80,
|
| 57 |
+
},
|
| 58 |
+
"regression": {
|
| 59 |
+
"enabled": True,
|
| 60 |
+
# thresholds delegated to megaeval.analysis.DEFAULT_PROMOTION_POLICY;
|
| 61 |
+
# entries here override it (e.g. max_recall_drop)
|
| 62 |
+
"overrides": {},
|
| 63 |
+
},
|
| 64 |
+
"safety": {
|
| 65 |
+
"enabled": True,
|
| 66 |
+
"max_csi_increase_ratio": 0.15, # candidate CSI <= baseline * (1 + ratio)
|
| 67 |
+
"max_csi_absolute": None, # optional hard ceiling
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
_POLICY_FILE = ("gate_policy.json",)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _now() -> str:
|
| 75 |
+
return datetime.now(timezone.utc).isoformat()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _merge(base: Dict, override: Dict) -> Dict:
|
| 79 |
+
out = dict(base)
|
| 80 |
+
for k, v in (override or {}).items():
|
| 81 |
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
| 82 |
+
out[k] = _merge(out[k], v)
|
| 83 |
+
else:
|
| 84 |
+
out[k] = v
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_policy() -> Dict:
|
| 89 |
+
stored = read_json(*_POLICY_FILE) or {}
|
| 90 |
+
return _merge(DEFAULT_POLICY, stored)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def set_policy(overrides: Dict) -> Dict:
|
| 94 |
+
stored = _merge(read_json(*_POLICY_FILE) or {}, overrides or {})
|
| 95 |
+
write_json(stored, *_POLICY_FILE)
|
| 96 |
+
return get_policy()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _check(name: str, actual, threshold, passed: bool, direction: str = "") -> Dict:
|
| 100 |
+
return {"check": name, "actual": actual, "threshold": threshold,
|
| 101 |
+
"direction": direction, "passed": bool(passed)}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _gate_record(gate: str, name: str, standard_refs: List[str], checks: List[Dict],
|
| 105 |
+
evidence: Dict, skipped: bool = False, notes: str = "") -> Dict:
|
| 106 |
+
if skipped:
|
| 107 |
+
status = "SKIPPED"
|
| 108 |
+
else:
|
| 109 |
+
status = "PASS" if all(c["passed"] for c in checks) else "BLOCK"
|
| 110 |
+
return {"gate": gate, "name": name, "status": status,
|
| 111 |
+
"standard_refs": standard_refs, "checks": checks,
|
| 112 |
+
"evidence": evidence, "notes": notes, "evaluated_at": _now()}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ------------------------------------------------------------------ gate 1
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def scenario_quality_gate(policy: Dict, eval_store=None) -> Dict:
|
| 119 |
+
"""Geometric plausibility sample + calibration validation.
|
| 120 |
+
|
| 121 |
+
Geometric part: validates a deterministic sample of auto-label annotations
|
| 122 |
+
from the newest labeleval dataset (generating a small probe dataset if the
|
| 123 |
+
store is empty) through the existing strict validation engine. Calibration
|
| 124 |
+
part: latest calibration validation status, refreshed with a clean run if
|
| 125 |
+
none exists.
|
| 126 |
+
"""
|
| 127 |
+
p = policy["scenario_quality"]
|
| 128 |
+
if not p.get("enabled", True):
|
| 129 |
+
return _gate_record("scenario_quality", "Scenario Quality Gate",
|
| 130 |
+
["ISO 26262-style deterministic verification"],
|
| 131 |
+
[], {}, skipped=True, notes="disabled by policy")
|
| 132 |
+
|
| 133 |
+
from sensorflow.evaluation import synthetic
|
| 134 |
+
from sensorflow.evaluation.records import get_store
|
| 135 |
+
from sensorflow.evaluation.validation import validate_annotation
|
| 136 |
+
|
| 137 |
+
store = eval_store or get_store()
|
| 138 |
+
datasets = store.all("datasets")
|
| 139 |
+
probe_generated = False
|
| 140 |
+
if datasets:
|
| 141 |
+
ds = max(datasets, key=lambda d: d.created_at)
|
| 142 |
+
else:
|
| 143 |
+
ds = synthetic.generate_dataset(store, name="gate-probe", num_sequences=2,
|
| 144 |
+
frames_per_sequence=6, seed=101)
|
| 145 |
+
synthetic.generate_labels(store, ds, seed_offset=1)
|
| 146 |
+
probe_generated = True
|
| 147 |
+
|
| 148 |
+
anns = sorted(store.where("annotations", dataset_id=ds.dataset_id),
|
| 149 |
+
key=lambda a: a.annotation_id)
|
| 150 |
+
sample = anns[:max(1, int(p["geometric_sample_size"]))]
|
| 151 |
+
frames = {f.frame_id: f for f in store.where("frames", dataset_id=ds.dataset_id)}
|
| 152 |
+
points_cache: Dict[str, object] = {}
|
| 153 |
+
n_pass = 0
|
| 154 |
+
fail_hist: Dict[str, int] = {}
|
| 155 |
+
for ann in sample:
|
| 156 |
+
frame = frames.get(ann.frame_id)
|
| 157 |
+
if frame is None:
|
| 158 |
+
continue
|
| 159 |
+
if ann.frame_id not in points_cache:
|
| 160 |
+
points_cache[ann.frame_id] = synthetic.frame_points(store, frame)
|
| 161 |
+
res = validate_annotation(store, ann, frame, points=points_cache[ann.frame_id])
|
| 162 |
+
if res.passed:
|
| 163 |
+
n_pass += 1
|
| 164 |
+
else:
|
| 165 |
+
for c in res.checks:
|
| 166 |
+
if c.applicable and not c.passed:
|
| 167 |
+
fail_hist[c.gate] = fail_hist.get(c.gate, 0) + 1
|
| 168 |
+
pass_rate = n_pass / max(len(sample), 1)
|
| 169 |
+
|
| 170 |
+
calib = calib_mod.latest_status()
|
| 171 |
+
if calib is None:
|
| 172 |
+
calib = calib_mod.run_validation(mode="clean", persist=True)
|
| 173 |
+
calib_ok = (calib["status"] != "MISCALIBRATED"
|
| 174 |
+
if p["block_on_miscalibration"] else True)
|
| 175 |
+
|
| 176 |
+
checks = [
|
| 177 |
+
_check("geometric_pass_rate", round(pass_rate, 4),
|
| 178 |
+
p["min_geometric_pass_rate"], pass_rate >= p["min_geometric_pass_rate"], ">="),
|
| 179 |
+
_check("calibration_status", calib["status"], "not MISCALIBRATED", calib_ok, "=="),
|
| 180 |
+
]
|
| 181 |
+
return _gate_record(
|
| 182 |
+
"scenario_quality", "Scenario Quality Gate",
|
| 183 |
+
["ISO 26262-style deterministic verification (geometric plausibility, "
|
| 184 |
+
"calibration validation)"],
|
| 185 |
+
checks,
|
| 186 |
+
evidence={
|
| 187 |
+
"dataset_id": ds.dataset_id,
|
| 188 |
+
"probe_dataset_generated": probe_generated,
|
| 189 |
+
"annotations_sampled": len(sample),
|
| 190 |
+
"annotations_passed": n_pass,
|
| 191 |
+
"failing_check_histogram": fail_hist,
|
| 192 |
+
"calibration": {"status": calib["status"], "metrics": calib.get("metrics"),
|
| 193 |
+
"mode": calib.get("mode"), "diagnosis": calib.get("diagnosis")},
|
| 194 |
+
},
|
| 195 |
+
notes="geometric checks run on the strict validation engine; calibration "
|
| 196 |
+
"scenes are simulated (see safety.calibration)")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ------------------------------------------------------------------ gate 2
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def coverage_gate(mega_store, candidate, policy: Dict) -> Dict:
|
| 203 |
+
p = policy["coverage"]
|
| 204 |
+
if not p.get("enabled", True):
|
| 205 |
+
return _gate_record("coverage", "Coverage Gate",
|
| 206 |
+
["ISO 21448/SOTIF-style ODD coverage"],
|
| 207 |
+
[], {}, skipped=True, notes="disabled by policy")
|
| 208 |
+
cov = odd_mod.coverage_for_run(
|
| 209 |
+
mega_store, candidate, dims=p["dims"], min_samples=p["min_samples"],
|
| 210 |
+
max_ci_width=p["max_ci_width"], max_gaps=10)
|
| 211 |
+
s = cov["summary"]
|
| 212 |
+
checks = [
|
| 213 |
+
_check("coverage_rate", s["coverage_rate"], p["min_coverage_rate"],
|
| 214 |
+
s["coverage_rate"] >= p["min_coverage_rate"], ">="),
|
| 215 |
+
_check("production_weighted_coverage", s["production_weighted_coverage"],
|
| 216 |
+
p["min_production_weighted_coverage"],
|
| 217 |
+
s["production_weighted_coverage"] >= p["min_production_weighted_coverage"],
|
| 218 |
+
">="),
|
| 219 |
+
]
|
| 220 |
+
return _gate_record(
|
| 221 |
+
"coverage", "Coverage Gate",
|
| 222 |
+
["ISO 21448/SOTIF-style performance-limitation risk (ODD coverage per "
|
| 223 |
+
"ISO 34503-inspired taxonomy)"],
|
| 224 |
+
checks,
|
| 225 |
+
evidence={"dims": cov["dims"], "thresholds": cov["thresholds"],
|
| 226 |
+
"summary": s, "top_gaps": cov["gaps"][:5],
|
| 227 |
+
"fill_requests_available": len(cov["fill_requests"])},
|
| 228 |
+
notes="coverage over exact metric-cube counts; production shares use a "
|
| 229 |
+
"marginal-independence assumption")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ------------------------------------------------------------------ gate 3
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def regression_gate(mega_store, candidate, baseline, policy: Dict) -> Dict:
|
| 236 |
+
p = policy["regression"]
|
| 237 |
+
if not p.get("enabled", True):
|
| 238 |
+
return _gate_record("regression", "Regression Gate",
|
| 239 |
+
["ISO 26262-style regression verification"],
|
| 240 |
+
[], {}, skipped=True, notes="disabled by policy")
|
| 241 |
+
from sensorflow.megaeval.analysis import compare_runs
|
| 242 |
+
cmp = compare_runs(mega_store, candidate, baseline, policy=p.get("overrides") or None)
|
| 243 |
+
ok = cmp["recommendation"] == "PROMOTE"
|
| 244 |
+
checks = [_check("compare_recommendation", cmp["recommendation"], "PROMOTE", ok, "==")]
|
| 245 |
+
return _gate_record(
|
| 246 |
+
"regression", "Regression Gate",
|
| 247 |
+
["ISO 26262-style deterministic regression testing vs pinned baseline"],
|
| 248 |
+
checks,
|
| 249 |
+
evidence={"headline_deltas": cmp["headline_deltas"],
|
| 250 |
+
"regressions": cmp["regressions"],
|
| 251 |
+
"blockers": cmp["blockers"],
|
| 252 |
+
"policy": cmp["policy"],
|
| 253 |
+
"worst_cohorts": cmp["worst_cohorts"][:5]},
|
| 254 |
+
notes="delegates to megaeval model-vs-model compare (single source of truth)")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
# ------------------------------------------------------------------ gate 4
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def safety_gate(candidate, baseline, policy: Dict) -> Dict:
|
| 261 |
+
p = policy["safety"]
|
| 262 |
+
if not p.get("enabled", True):
|
| 263 |
+
return _gate_record("safety", "Safety Gate",
|
| 264 |
+
["ISO 21448/SOTIF-style surrogate safety"],
|
| 265 |
+
[], {}, skipped=True, notes="disabled by policy")
|
| 266 |
+
cand = ssam_ext.csi_for_run(candidate)
|
| 267 |
+
base = ssam_ext.csi_for_run(baseline)
|
| 268 |
+
cand_csi, base_csi = cand["aggregate_csi"], base["aggregate_csi"]
|
| 269 |
+
limit = base_csi * (1.0 + p["max_csi_increase_ratio"])
|
| 270 |
+
increase_ratio = ((cand_csi - base_csi) / base_csi) if base_csi > 1e-9 else (
|
| 271 |
+
0.0 if cand_csi <= 1e-9 else float("inf"))
|
| 272 |
+
checks = [
|
| 273 |
+
_check("aggregate_csi_vs_baseline", round(cand_csi, 4),
|
| 274 |
+
round(limit, 4), cand_csi <= limit + 1e-9, "<="),
|
| 275 |
+
]
|
| 276 |
+
if p.get("max_csi_absolute") is not None:
|
| 277 |
+
checks.append(_check("aggregate_csi_absolute", round(cand_csi, 4),
|
| 278 |
+
p["max_csi_absolute"], cand_csi <= p["max_csi_absolute"], "<="))
|
| 279 |
+
return _gate_record(
|
| 280 |
+
"safety", "Safety Gate",
|
| 281 |
+
["ISO 21448/SOTIF-style surrogate safety (FHWA SSAM: TTC/PET/DRAC/CSI)"],
|
| 282 |
+
checks,
|
| 283 |
+
evidence={
|
| 284 |
+
"candidate_csi": cand_csi, "baseline_csi": base_csi,
|
| 285 |
+
"csi_increase_ratio": (round(increase_ratio, 4)
|
| 286 |
+
if increase_ratio != float("inf") else "inf"),
|
| 287 |
+
"candidate_reaction_model": cand["reaction_model"],
|
| 288 |
+
"baseline_reaction_model": base["reaction_model"],
|
| 289 |
+
"candidate_min_ttc_s": cand["min_ttc_s"],
|
| 290 |
+
"candidate_scenarios": cand["scenarios"],
|
| 291 |
+
},
|
| 292 |
+
notes="CSI from a deterministic simulated scenario suite conditioned on the "
|
| 293 |
+
"model's perception profile (marked simulated); the SSAM formulas "
|
| 294 |
+
"themselves are real math")
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
# ------------------------------------------------------------------ orchestration
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
GATE_ORDER = ["scenario_quality", "coverage", "regression", "safety"]
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def evaluate_gates(mega_store, candidate, baseline,
|
| 304 |
+
policy_overrides: Optional[Dict] = None, eval_store=None) -> Dict:
|
| 305 |
+
"""Run all gates for candidate vs baseline; compile + persist the Safety
|
| 306 |
+
Evidence Package. Layered semantics: overall decision blocks on the first
|
| 307 |
+
failing gate, but every gate is still evaluated for evidence."""
|
| 308 |
+
policy = _merge(get_policy(), policy_overrides or {})
|
| 309 |
+
gates = [
|
| 310 |
+
scenario_quality_gate(policy, eval_store=eval_store),
|
| 311 |
+
coverage_gate(mega_store, candidate, policy),
|
| 312 |
+
regression_gate(mega_store, candidate, baseline, policy),
|
| 313 |
+
safety_gate(candidate, baseline, policy),
|
| 314 |
+
]
|
| 315 |
+
blocking = [g["gate"] for g in gates if g["status"] == "BLOCK"]
|
| 316 |
+
release_ready = not blocking
|
| 317 |
+
readiness = _gate_record(
|
| 318 |
+
"release_readiness", "Release Readiness Gate",
|
| 319 |
+
["UL 4600-style safety-case evidence compilation"],
|
| 320 |
+
[_check("all_gates_pass", f"{len(gates) - len(blocking)}/{len(gates)}",
|
| 321 |
+
f"{len(gates)}/{len(gates)}", release_ready, "==")],
|
| 322 |
+
evidence={"blocking_gates": blocking,
|
| 323 |
+
"first_blocking_gate": blocking[0] if blocking else None},
|
| 324 |
+
notes="compiles the Safety Evidence Package below")
|
| 325 |
+
gates.append(readiness)
|
| 326 |
+
|
| 327 |
+
package = build_evidence_package(mega_store, candidate, baseline, policy, gates,
|
| 328 |
+
release_ready, blocking)
|
| 329 |
+
write_json(package, "evidence", f"{candidate.run_id}.json")
|
| 330 |
+
from sensorflow.safety.store import safety_path
|
| 331 |
+
md = render_markdown(package)
|
| 332 |
+
with open(safety_path("evidence", f"{candidate.run_id}.md"), "w") as f:
|
| 333 |
+
f.write(md)
|
| 334 |
+
|
| 335 |
+
result = {
|
| 336 |
+
"candidate_run_id": candidate.run_id,
|
| 337 |
+
"baseline_run_id": baseline.run_id,
|
| 338 |
+
"decision": "RELEASE_READY" if release_ready else "BLOCKED",
|
| 339 |
+
"blocking_gates": blocking,
|
| 340 |
+
"gates": gates,
|
| 341 |
+
"policy": policy,
|
| 342 |
+
"evidence_package_id": package["package_id"],
|
| 343 |
+
"evaluated_at": _now(),
|
| 344 |
+
}
|
| 345 |
+
write_json(result, "gates", f"{candidate.run_id}.json")
|
| 346 |
+
return result
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def latest_gate_result(run_id: str) -> Optional[Dict]:
|
| 350 |
+
return read_json("gates", f"{run_id}.json")
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def load_evidence(run_id: str) -> Optional[Dict]:
|
| 354 |
+
return read_json("evidence", f"{run_id}.json")
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# ------------------------------------------------------------------ evidence package
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
DISCLAIMER = (
|
| 361 |
+
"This package organizes verifiable evidence to SUPPORT a safety case. It does "
|
| 362 |
+
"not certify, demonstrate or imply compliance with ISO 26262, ISO 21448, "
|
| 363 |
+
"UL 4600 or any other standard. Several evidence sources are simulated and "
|
| 364 |
+
"are marked as such on the individual records.")
|
| 365 |
+
|
| 366 |
+
STANDARD_MAPPINGS = [
|
| 367 |
+
{"standard": "ISO 26262 (functional safety)",
|
| 368 |
+
"style": "deterministic, repeatable verification with pinned thresholds",
|
| 369 |
+
"evidence": ["gates.scenario_quality (geometric plausibility, calibration)",
|
| 370 |
+
"gates.regression (model-vs-model compare vs pinned baseline)",
|
| 371 |
+
"lineage (pinned dataset/model/label/metric/code versions + seed)"]},
|
| 372 |
+
{"standard": "ISO 21448 / SOTIF (safety of the intended functionality)",
|
| 373 |
+
"style": "performance-limitation risk in the operational domain",
|
| 374 |
+
"evidence": ["gates.coverage + odd_coverage_summary (under-covered ODD cells, "
|
| 375 |
+
"risk-ranked gaps)",
|
| 376 |
+
"gates.safety + surrogate_safety_analysis (SSAM-style near-miss "
|
| 377 |
+
"severity, CSI vs baseline)"]},
|
| 378 |
+
{"standard": "UL 4600 (safety case for autonomous products)",
|
| 379 |
+
"style": "claim -> argument -> evidence structure",
|
| 380 |
+
"evidence": ["decision + gates[] (claims with thresholds, actuals and "
|
| 381 |
+
"pass/block outcomes)",
|
| 382 |
+
"sampling_methodology (statistical argument: stratified "
|
| 383 |
+
"risk-weighted review, Wilson CIs)",
|
| 384 |
+
"the package itself as the evidence index"]},
|
| 385 |
+
]
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def build_evidence_package(mega_store, candidate, baseline, policy: Dict,
|
| 389 |
+
gates: List[Dict], release_ready: bool,
|
| 390 |
+
blocking: List[str]) -> Dict:
|
| 391 |
+
cov_gate = next(g for g in gates if g["gate"] == "coverage")
|
| 392 |
+
reg_gate = next(g for g in gates if g["gate"] == "regression")
|
| 393 |
+
saf_gate = next(g for g in gates if g["gate"] == "safety")
|
| 394 |
+
|
| 395 |
+
review = None
|
| 396 |
+
try:
|
| 397 |
+
review = mega_store.artifacts(candidate.run_id).get("review")
|
| 398 |
+
except Exception:
|
| 399 |
+
pass
|
| 400 |
+
|
| 401 |
+
supplementary: Dict = {}
|
| 402 |
+
try:
|
| 403 |
+
from sensorflow.safety.scenario_db import get_db
|
| 404 |
+
supplementary["scenario_db"] = get_db().counts()
|
| 405 |
+
except Exception:
|
| 406 |
+
pass
|
| 407 |
+
try:
|
| 408 |
+
cal = calib_mod.latest_status()
|
| 409 |
+
if cal:
|
| 410 |
+
supplementary["calibration_status"] = {
|
| 411 |
+
"status": cal["status"], "metrics": cal.get("metrics")}
|
| 412 |
+
except Exception:
|
| 413 |
+
pass
|
| 414 |
+
|
| 415 |
+
return {
|
| 416 |
+
"package_id": f"sep-{candidate.run_id}",
|
| 417 |
+
"package_format": "sensorflow-safety-evidence/v1",
|
| 418 |
+
"generated_at": _now(),
|
| 419 |
+
"disclaimer": DISCLAIMER,
|
| 420 |
+
"candidate": {"run_id": candidate.run_id, "model_version": candidate.model_version,
|
| 421 |
+
"population_id": candidate.population_id,
|
| 422 |
+
"headline": candidate.headline},
|
| 423 |
+
"baseline": {"run_id": baseline.run_id, "model_version": baseline.model_version,
|
| 424 |
+
"headline": baseline.headline},
|
| 425 |
+
"decision": {"release_ready": release_ready, "blocking_gates": blocking},
|
| 426 |
+
"gates": gates,
|
| 427 |
+
"gate_policy": policy,
|
| 428 |
+
"odd_coverage_summary": cov_gate["evidence"],
|
| 429 |
+
"regression_analysis": reg_gate["evidence"],
|
| 430 |
+
"surrogate_safety_analysis": saf_gate["evidence"],
|
| 431 |
+
"sampling_methodology": {
|
| 432 |
+
"config": candidate.lineage.get("sampling_config"),
|
| 433 |
+
"confidence_intervals": "Wilson score intervals on per-cell and "
|
| 434 |
+
"review-sample proportions",
|
| 435 |
+
"review_artifact": ({"target_n": review.get("target_n"),
|
| 436 |
+
"achieved_n": review.get("achieved_n")}
|
| 437 |
+
if isinstance(review, dict) else None),
|
| 438 |
+
},
|
| 439 |
+
"lineage": {"candidate": candidate.lineage, "baseline": baseline.lineage},
|
| 440 |
+
"standard_mappings": STANDARD_MAPPINGS,
|
| 441 |
+
"supplementary": supplementary,
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def render_markdown(pkg: Dict) -> str:
|
| 446 |
+
d = pkg["decision"]
|
| 447 |
+
lines = [
|
| 448 |
+
f"# Safety Evidence Package — {pkg['candidate']['model_version']}",
|
| 449 |
+
"",
|
| 450 |
+
f"Package `{pkg['package_id']}` generated {pkg['generated_at']}",
|
| 451 |
+
"",
|
| 452 |
+
f"> {pkg['disclaimer']}",
|
| 453 |
+
"",
|
| 454 |
+
f"**Decision: {'RELEASE READY' if d['release_ready'] else 'BLOCKED'}**"
|
| 455 |
+
+ (f" — blocking gates: {', '.join(d['blocking_gates'])}" if d["blocking_gates"] else ""),
|
| 456 |
+
"",
|
| 457 |
+
f"Candidate `{pkg['candidate']['run_id']}` ({pkg['candidate']['model_version']}) "
|
| 458 |
+
f"vs baseline `{pkg['baseline']['run_id']}` ({pkg['baseline']['model_version']})",
|
| 459 |
+
"",
|
| 460 |
+
"## Gate results",
|
| 461 |
+
"",
|
| 462 |
+
"| Gate | Status | Checks (actual vs threshold) |",
|
| 463 |
+
"|---|---|---|",
|
| 464 |
+
]
|
| 465 |
+
for g in pkg["gates"]:
|
| 466 |
+
checks = "; ".join(
|
| 467 |
+
f"{c['check']}: {c['actual']} {c['direction'] or 'vs'} {c['threshold']} "
|
| 468 |
+
f"({'ok' if c['passed'] else 'FAIL'})" for c in g["checks"]) or "—"
|
| 469 |
+
lines.append(f"| {g['name']} | {g['status']} | {checks} |")
|
| 470 |
+
|
| 471 |
+
s = pkg["odd_coverage_summary"].get("summary", {})
|
| 472 |
+
lines += [
|
| 473 |
+
"",
|
| 474 |
+
"## ODD coverage (SOTIF-style)",
|
| 475 |
+
"",
|
| 476 |
+
f"- dims: {', '.join(pkg['odd_coverage_summary'].get('dims', []))}",
|
| 477 |
+
f"- adequate cells: {s.get('adequate_cells')}/{s.get('total_cells')} "
|
| 478 |
+
f"(coverage rate {s.get('coverage_rate')})",
|
| 479 |
+
f"- production-weighted coverage: {s.get('production_weighted_coverage')}",
|
| 480 |
+
f"- gap cells: {s.get('gap_cells')}",
|
| 481 |
+
"",
|
| 482 |
+
"## Surrogate safety (SSAM-style)",
|
| 483 |
+
"",
|
| 484 |
+
f"- candidate aggregate CSI: {pkg['surrogate_safety_analysis'].get('candidate_csi')}",
|
| 485 |
+
f"- baseline aggregate CSI: {pkg['surrogate_safety_analysis'].get('baseline_csi')}",
|
| 486 |
+
f"- increase ratio: {pkg['surrogate_safety_analysis'].get('csi_increase_ratio')}",
|
| 487 |
+
f"- candidate min TTC: {pkg['surrogate_safety_analysis'].get('candidate_min_ttc_s')} s",
|
| 488 |
+
"",
|
| 489 |
+
"## Regression analysis",
|
| 490 |
+
"",
|
| 491 |
+
]
|
| 492 |
+
for h in pkg["regression_analysis"].get("headline_deltas", []):
|
| 493 |
+
lines.append(f"- {h['metric']}: {h['baseline']} -> {h['candidate']} (Δ {h['delta']:+})")
|
| 494 |
+
blockers = pkg["regression_analysis"].get("blockers", [])
|
| 495 |
+
if blockers:
|
| 496 |
+
lines += ["", "Blockers:"] + [f"- {b}" for b in blockers]
|
| 497 |
+
|
| 498 |
+
lines += [
|
| 499 |
+
"",
|
| 500 |
+
"## Methodology",
|
| 501 |
+
"",
|
| 502 |
+
f"- sampling: {pkg['sampling_methodology'].get('config')}",
|
| 503 |
+
f"- CIs: {pkg['sampling_methodology'].get('confidence_intervals')}",
|
| 504 |
+
"",
|
| 505 |
+
"## Standard mappings (evidence organization, not compliance)",
|
| 506 |
+
"",
|
| 507 |
+
]
|
| 508 |
+
for m in pkg["standard_mappings"]:
|
| 509 |
+
lines.append(f"### {m['standard']}")
|
| 510 |
+
lines.append(f"_{m['style']}_")
|
| 511 |
+
lines += [f"- {e}" for e in m["evidence"]]
|
| 512 |
+
lines.append("")
|
| 513 |
+
|
| 514 |
+
lines += [
|
| 515 |
+
"## Lineage",
|
| 516 |
+
"",
|
| 517 |
+
"```json",
|
| 518 |
+
__import__("json").dumps(pkg["lineage"], indent=2),
|
| 519 |
+
"```",
|
| 520 |
+
"",
|
| 521 |
+
]
|
| 522 |
+
return "\n".join(lines)
|
sensorflow/safety/odd.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ODD coverage analysis (ISO 34503 / ASAM OpenODD-inspired).
|
| 2 |
+
|
| 3 |
+
Industry concept mapping
|
| 4 |
+
------------------------
|
| 5 |
+
ISO 34503 defines an Operational Design Domain taxonomy in three top-level
|
| 6 |
+
categories — scenery, environmental conditions, dynamic elements. ASAM OpenODD
|
| 7 |
+
standardizes machine-readable ODD definitions. This module:
|
| 8 |
+
|
| 9 |
+
1. exposes an ODD taxonomy built on the megaeval population dimensions (so
|
| 10 |
+
coverage is computed against the same vocabulary the metric cube uses),
|
| 11 |
+
2. computes *combinatorial coverage* of the ODD space for an evaluation run:
|
| 12 |
+
which cells have data, how much, and with what quality (recall + Wilson CI),
|
| 13 |
+
3. identifies statistically under-covered gaps (too few samples, or a recall
|
| 14 |
+
CI too wide to support any claim),
|
| 15 |
+
4. ranks gaps by risk = production-like frequency x performance deficit,
|
| 16 |
+
5. emits synthetic gap-filling scenario REQUESTS and can actually fulfil them
|
| 17 |
+
through the existing synthetic generator (sensorflow.evaluation.synthetic),
|
| 18 |
+
folding the generated objects back into coverage as a clearly-marked
|
| 19 |
+
synthetic supplement.
|
| 20 |
+
|
| 21 |
+
Honest markers: the "production-like distribution" is the product of the
|
| 22 |
+
population's per-dimension marginals (independence assumption, documented in
|
| 23 |
+
the response); gap filling uses the deterministic synthetic generator, and the
|
| 24 |
+
generator does not model road geometry — road_type on a supplement is a
|
| 25 |
+
recorded request attribute, not simulated physics.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import itertools
|
| 31 |
+
import math
|
| 32 |
+
from datetime import datetime, timezone
|
| 33 |
+
from typing import Dict, List, Optional, Tuple
|
| 34 |
+
|
| 35 |
+
from sensorflow.megaeval import cube as cube_mod
|
| 36 |
+
from sensorflow.megaeval import population as pop_mod
|
| 37 |
+
from sensorflow.megaeval.population import DIMENSIONS, DIM_NAMES
|
| 38 |
+
from sensorflow.megaeval.sampling import wilson_interval
|
| 39 |
+
from sensorflow.safety.store import read_json, write_json
|
| 40 |
+
|
| 41 |
+
DEFAULT_COVERAGE_DIMS = ["weather", "lighting", "road_type", "class"]
|
| 42 |
+
DEFAULT_MIN_SAMPLES = 50
|
| 43 |
+
DEFAULT_MAX_CI_WIDTH = 0.25
|
| 44 |
+
|
| 45 |
+
# ISO 34503-style categorization of the dimensions we can instrument.
|
| 46 |
+
ISO34503_CATEGORY = {
|
| 47 |
+
"road_type": "scenery",
|
| 48 |
+
"weather": "environmental_conditions",
|
| 49 |
+
"lighting": "environmental_conditions",
|
| 50 |
+
"occlusion": "environmental_conditions",
|
| 51 |
+
"class": "dynamic_elements",
|
| 52 |
+
"speed_band": "dynamic_elements",
|
| 53 |
+
"scenario": "dynamic_elements",
|
| 54 |
+
"distance_band": "dynamic_elements",
|
| 55 |
+
"sensor": "sensor_configuration",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# megaeval class vocabulary -> labeleval synthetic-generator class vocabulary.
|
| 59 |
+
_CLASS_TO_GENERATOR = {
|
| 60 |
+
"vehicle": "vehicle",
|
| 61 |
+
"pedestrian": "pedestrian",
|
| 62 |
+
"cyclist": "cyclist",
|
| 63 |
+
"motorcycle": "motorcycle",
|
| 64 |
+
"truck": "truck",
|
| 65 |
+
"bus": "truck", # generator has no bus prior; truck is the closest proxy
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _now() -> str:
|
| 70 |
+
return datetime.now(timezone.utc).isoformat()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def taxonomy() -> Dict:
|
| 74 |
+
"""Machine-readable ODD taxonomy (ISO 34503 categories, ASAM OpenODD-ish)."""
|
| 75 |
+
dims = {}
|
| 76 |
+
for name in DIM_NAMES:
|
| 77 |
+
dims[name] = {
|
| 78 |
+
"values": DIMENSIONS[name],
|
| 79 |
+
"iso34503_category": ISO34503_CATEGORY.get(name, "dynamic_elements"),
|
| 80 |
+
"instrumented": True,
|
| 81 |
+
"source": "megaeval population dimension",
|
| 82 |
+
}
|
| 83 |
+
# Declared but not instrumented in the current synthetic population.
|
| 84 |
+
dims["geography"] = {
|
| 85 |
+
"values": ["urban_california", "suburban_california", "highway_california",
|
| 86 |
+
"rural_california"],
|
| 87 |
+
"iso34503_category": "scenery",
|
| 88 |
+
"instrumented": False,
|
| 89 |
+
"source": "placeholder — the current synthetic population does not carry "
|
| 90 |
+
"a geography attribute; excluded from coverage computation",
|
| 91 |
+
}
|
| 92 |
+
return {
|
| 93 |
+
"standard_basis": "ISO 34503 ODD taxonomy structure (scenery / "
|
| 94 |
+
"environmental conditions / dynamic elements), "
|
| 95 |
+
"ASAM OpenODD-style machine-readable attributes",
|
| 96 |
+
"dimensions": dims,
|
| 97 |
+
"default_coverage_dims": DEFAULT_COVERAGE_DIMS,
|
| 98 |
+
"notes": [
|
| 99 |
+
"Coverage cells are the cartesian product of the selected dimensions.",
|
| 100 |
+
"Only instrumented dimensions may be used for coverage computation.",
|
| 101 |
+
],
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ------------------------------------------------------------------ core math
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _marginals(dim_counts: Optional[Dict], cube_df, dims: List[str]) -> Dict[str, Dict[str, float]]:
|
| 109 |
+
"""Per-dimension marginal probabilities of the production-like distribution.
|
| 110 |
+
|
| 111 |
+
Prefers the population dataset card (exact counts); falls back to the
|
| 112 |
+
cube's own n if no card is available (pure-cube unit tests).
|
| 113 |
+
"""
|
| 114 |
+
out: Dict[str, Dict[str, float]] = {}
|
| 115 |
+
for dim in dims:
|
| 116 |
+
if dim_counts and dim in dim_counts:
|
| 117 |
+
counts = {v: float(c) for v, c in dim_counts[dim].items()}
|
| 118 |
+
else:
|
| 119 |
+
grp = cube_df.groupby(dim, observed=True)["n"].sum()
|
| 120 |
+
counts = {DIMENSIONS[dim][int(code)]: float(n) for code, n in grp.items()}
|
| 121 |
+
total = sum(counts.values()) or 1.0
|
| 122 |
+
out[dim] = {v: counts.get(v, 0.0) / total for v in DIMENSIONS[dim]}
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def cell_id(cell: Dict[str, str], dims: List[str]) -> str:
|
| 127 |
+
return "|".join(f"{d}={cell[d]}" for d in dims)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def compute_coverage(
|
| 131 |
+
cube_df,
|
| 132 |
+
dims: Optional[List[str]] = None,
|
| 133 |
+
dim_counts: Optional[Dict] = None,
|
| 134 |
+
min_samples: int = DEFAULT_MIN_SAMPLES,
|
| 135 |
+
max_ci_width: float = DEFAULT_MAX_CI_WIDTH,
|
| 136 |
+
target_recall: Optional[float] = None,
|
| 137 |
+
supplements: Optional[List[Dict]] = None,
|
| 138 |
+
max_gaps: int = 50,
|
| 139 |
+
) -> Dict:
|
| 140 |
+
"""Combinatorial ODD coverage from a metric cube (pure function, exact math).
|
| 141 |
+
|
| 142 |
+
Per cell: n (ground-truth objects), tp/fn, recall with Wilson CI,
|
| 143 |
+
adequacy (n >= min_samples AND CI width <= max_ci_width), production share
|
| 144 |
+
(product of marginals — independence assumption), performance deficit
|
| 145 |
+
(target_recall - Wilson lower bound, floored at 0) and
|
| 146 |
+
risk = production_share x deficit.
|
| 147 |
+
"""
|
| 148 |
+
dims = [d for d in (dims or DEFAULT_COVERAGE_DIMS) if d in DIM_NAMES]
|
| 149 |
+
if not dims:
|
| 150 |
+
raise ValueError("no valid ODD dimensions selected")
|
| 151 |
+
n_cells = math.prod(len(DIMENSIONS[d]) for d in dims)
|
| 152 |
+
if n_cells > 2000:
|
| 153 |
+
raise ValueError(f"selected dimensions produce {n_cells} cells (max 2000); "
|
| 154 |
+
"choose fewer dimensions")
|
| 155 |
+
|
| 156 |
+
rows, _ = cube_mod.aggregate(cube_df, None, dims, ["n", "tp", "fn", "recall", "mean_iou"],
|
| 157 |
+
limit=2000)
|
| 158 |
+
observed = {tuple(r[d] for d in dims): r for r in rows}
|
| 159 |
+
|
| 160 |
+
overall_rows, _ = cube_mod.aggregate(cube_df, None, None, ["recall"], 1)
|
| 161 |
+
overall_recall = overall_rows[0].get("recall") if overall_rows else None
|
| 162 |
+
if target_recall is None:
|
| 163 |
+
target_recall = overall_recall if overall_recall is not None else 0.9
|
| 164 |
+
|
| 165 |
+
marg = _marginals(dim_counts, cube_df, dims)
|
| 166 |
+
|
| 167 |
+
# Index synthetic supplements by cell id (only those matching these dims).
|
| 168 |
+
supp_by_cell: Dict[str, Dict] = {}
|
| 169 |
+
for s in supplements or []:
|
| 170 |
+
c = s.get("cell") or {}
|
| 171 |
+
if set(c.keys()) == set(dims):
|
| 172 |
+
cid = cell_id(c, dims)
|
| 173 |
+
agg = supp_by_cell.setdefault(cid, {"n_added": 0, "tp_added": 0, "fn_added": 0,
|
| 174 |
+
"datasets": []})
|
| 175 |
+
agg["n_added"] += int(s.get("n_added", 0))
|
| 176 |
+
agg["tp_added"] += int(s.get("tp_added", 0))
|
| 177 |
+
agg["fn_added"] += int(s.get("fn_added", 0))
|
| 178 |
+
if s.get("dataset_id"):
|
| 179 |
+
agg["datasets"].append(s["dataset_id"])
|
| 180 |
+
|
| 181 |
+
cells: List[Dict] = []
|
| 182 |
+
for combo in itertools.product(*(DIMENSIONS[d] for d in dims)):
|
| 183 |
+
cell = dict(zip(dims, combo))
|
| 184 |
+
cid = cell_id(cell, dims)
|
| 185 |
+
r = observed.get(combo, {})
|
| 186 |
+
n = int(r.get("n", 0))
|
| 187 |
+
tp = int(r.get("tp", 0))
|
| 188 |
+
fn = int(r.get("fn", 0))
|
| 189 |
+
|
| 190 |
+
supp = supp_by_cell.get(cid)
|
| 191 |
+
if supp:
|
| 192 |
+
n += supp["n_added"]
|
| 193 |
+
tp += supp["tp_added"]
|
| 194 |
+
fn += supp["fn_added"]
|
| 195 |
+
|
| 196 |
+
gt_n = tp + fn
|
| 197 |
+
recall = (tp / gt_n) if gt_n > 0 else None
|
| 198 |
+
lo, hi = wilson_interval(tp, gt_n)
|
| 199 |
+
ci_width = hi - lo
|
| 200 |
+
share = math.prod(marg[d].get(cell[d], 0.0) for d in dims)
|
| 201 |
+
|
| 202 |
+
reasons = []
|
| 203 |
+
if n < min_samples:
|
| 204 |
+
reasons.append("insufficient_samples")
|
| 205 |
+
if ci_width > max_ci_width:
|
| 206 |
+
reasons.append("ci_too_wide")
|
| 207 |
+
adequate = not reasons
|
| 208 |
+
deficit = max(0.0, float(target_recall) - lo)
|
| 209 |
+
risk = share * deficit
|
| 210 |
+
|
| 211 |
+
rec = {
|
| 212 |
+
"cell_id": cid,
|
| 213 |
+
"cell": cell,
|
| 214 |
+
"n": n,
|
| 215 |
+
"tp": tp,
|
| 216 |
+
"fn": fn,
|
| 217 |
+
"recall": None if recall is None else round(recall, 4),
|
| 218 |
+
"mean_iou": r.get("mean_iou"),
|
| 219 |
+
"wilson_ci": [round(lo, 4), round(hi, 4)],
|
| 220 |
+
"ci_width": round(ci_width, 4),
|
| 221 |
+
"production_share": round(share, 6),
|
| 222 |
+
"adequate": adequate,
|
| 223 |
+
"is_gap": not adequate,
|
| 224 |
+
"gap_reasons": reasons,
|
| 225 |
+
"performance_deficit": round(deficit, 4),
|
| 226 |
+
"risk": round(risk, 6),
|
| 227 |
+
}
|
| 228 |
+
if supp:
|
| 229 |
+
rec["synthetic_supplement"] = supp
|
| 230 |
+
cells.append(rec)
|
| 231 |
+
|
| 232 |
+
populated = [c for c in cells if c["n"] > 0]
|
| 233 |
+
adequate_cells = [c for c in cells if c["adequate"]]
|
| 234 |
+
gaps = sorted((c for c in cells if c["is_gap"]), key=lambda c: -c["risk"])
|
| 235 |
+
|
| 236 |
+
total_share = sum(c["production_share"] for c in cells) or 1.0
|
| 237 |
+
weighted_cov = sum(c["production_share"] for c in adequate_cells) / total_share
|
| 238 |
+
|
| 239 |
+
fill_requests = [gap_fill_request(g, min_samples) for g in gaps[:max_gaps]]
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"dims": dims,
|
| 243 |
+
"thresholds": {"min_samples": min_samples, "max_ci_width": max_ci_width,
|
| 244 |
+
"target_recall": round(float(target_recall), 4)},
|
| 245 |
+
"summary": {
|
| 246 |
+
"total_cells": len(cells),
|
| 247 |
+
"populated_cells": len(populated),
|
| 248 |
+
"empty_cells": len(cells) - len(populated),
|
| 249 |
+
"adequate_cells": len(adequate_cells),
|
| 250 |
+
"gap_cells": len(gaps),
|
| 251 |
+
"coverage_rate": round(len(adequate_cells) / len(cells), 4),
|
| 252 |
+
"production_weighted_coverage": round(weighted_cov, 4),
|
| 253 |
+
"overall_recall": overall_recall,
|
| 254 |
+
},
|
| 255 |
+
"gaps": gaps[:max_gaps],
|
| 256 |
+
"fill_requests": fill_requests,
|
| 257 |
+
"cells": cells,
|
| 258 |
+
"method": "combinatorial cell coverage over the metric cube; recall CI = "
|
| 259 |
+
"Wilson; production share = product of population marginals "
|
| 260 |
+
"(independence assumption); risk = share x (target_recall - "
|
| 261 |
+
"Wilson recall lower bound)",
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def gap_fill_request(gap: Dict, min_samples: int) -> Dict:
|
| 266 |
+
"""A scenario REQUEST the synthetic generator can fulfil for one gap cell."""
|
| 267 |
+
cell = gap["cell"]
|
| 268 |
+
needed = max(0, min_samples - gap["n"])
|
| 269 |
+
cls = cell.get("class")
|
| 270 |
+
return {
|
| 271 |
+
"cell_id": gap["cell_id"],
|
| 272 |
+
"cell": cell,
|
| 273 |
+
"needed_samples": needed,
|
| 274 |
+
"risk": gap["risk"],
|
| 275 |
+
"generator": "sensorflow.evaluation.synthetic.generate_dataset",
|
| 276 |
+
"request": {
|
| 277 |
+
"weather": cell.get("weather"),
|
| 278 |
+
"time_of_day": ("night" if cell.get("lighting") == "night"
|
| 279 |
+
else cell.get("lighting", "day")),
|
| 280 |
+
"target_class": _CLASS_TO_GENERATOR.get(cls) if cls else None,
|
| 281 |
+
"target_class_note": ("bus mapped to truck proxy" if cls == "bus" else None),
|
| 282 |
+
"road_type": cell.get("road_type"),
|
| 283 |
+
"road_type_note": "recorded request attribute; the synthetic generator "
|
| 284 |
+
"does not model road geometry",
|
| 285 |
+
"num_sequences": 2,
|
| 286 |
+
"frames_per_sequence": max(10, min(40, needed)),
|
| 287 |
+
},
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
# ------------------------------------------------------------------ run plumbing
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def _supplements_key(run_id: str) -> Tuple[str, str]:
|
| 295 |
+
return ("odd", f"{run_id}.json")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def load_supplements(run_id: str) -> List[Dict]:
|
| 299 |
+
return read_json(*_supplements_key(run_id)) or []
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def coverage_for_run(store, run, dims: Optional[List[str]] = None,
|
| 303 |
+
min_samples: int = DEFAULT_MIN_SAMPLES,
|
| 304 |
+
max_ci_width: float = DEFAULT_MAX_CI_WIDTH,
|
| 305 |
+
target_recall: Optional[float] = None,
|
| 306 |
+
include_cells: bool = False,
|
| 307 |
+
max_gaps: int = 50) -> Dict:
|
| 308 |
+
"""ODD coverage for a published megaeval run (uses cube + dataset card)."""
|
| 309 |
+
art = store.artifacts(run.run_id)
|
| 310 |
+
meta = pop_mod.load_meta(run.population_id) or {}
|
| 311 |
+
result = compute_coverage(
|
| 312 |
+
art["cube"], dims=dims, dim_counts=meta.get("dim_counts"),
|
| 313 |
+
min_samples=min_samples, max_ci_width=max_ci_width,
|
| 314 |
+
target_recall=target_recall, supplements=load_supplements(run.run_id),
|
| 315 |
+
max_gaps=max_gaps)
|
| 316 |
+
result["run_id"] = run.run_id
|
| 317 |
+
result["population_id"] = run.population_id
|
| 318 |
+
result["model_version"] = run.model_version
|
| 319 |
+
if not include_cells:
|
| 320 |
+
result.pop("cells")
|
| 321 |
+
return result
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def fill_gap(store, run, cell: Dict[str, str], num_sequences: int = 2,
|
| 325 |
+
frames_per_sequence: int = 20, seed: Optional[int] = None) -> Dict:
|
| 326 |
+
"""Generate targeted synthetic data for a gap cell and re-compute coverage.
|
| 327 |
+
|
| 328 |
+
Actually wired: calls the existing synthetic generator + label generator on
|
| 329 |
+
the labeleval store, retargets the generated frames' conditions to the gap
|
| 330 |
+
cell, measures how many ground-truth objects of the target class were added
|
| 331 |
+
and how many the simulated model labeled, persists the supplement, and
|
| 332 |
+
returns before/after coverage for that cell.
|
| 333 |
+
"""
|
| 334 |
+
from sensorflow.evaluation import synthetic
|
| 335 |
+
from sensorflow.evaluation.process_units import ProcessMeter
|
| 336 |
+
from sensorflow.evaluation.records import get_store as get_legacy_store
|
| 337 |
+
|
| 338 |
+
dims = [d for d in DIM_NAMES if d in cell]
|
| 339 |
+
if not dims:
|
| 340 |
+
raise ValueError("cell must specify at least one known ODD dimension")
|
| 341 |
+
for d in dims:
|
| 342 |
+
if cell[d] not in DIMENSIONS[d]:
|
| 343 |
+
raise ValueError(f"unknown value {cell[d]!r} for dimension {d}")
|
| 344 |
+
cell = {d: cell[d] for d in dims}
|
| 345 |
+
|
| 346 |
+
before = coverage_for_run(store, run, dims=dims, include_cells=True)
|
| 347 |
+
cid = cell_id(cell, dims)
|
| 348 |
+
before_cell = next(c for c in before["cells"] if c["cell_id"] == cid)
|
| 349 |
+
|
| 350 |
+
legacy = get_legacy_store()
|
| 351 |
+
seed = seed if seed is not None else (run.seed + len(load_supplements(run.run_id))) % (2**31)
|
| 352 |
+
dataset = synthetic.generate_dataset(
|
| 353 |
+
legacy, name=f"odd-gap-{cid}", num_sequences=num_sequences,
|
| 354 |
+
frames_per_sequence=frames_per_sequence, seed=seed,
|
| 355 |
+
version="gap-fill", generated_from_model=run.model_version)
|
| 356 |
+
|
| 357 |
+
# Retarget conditions to the requested cell (deterministic post-adjustment;
|
| 358 |
+
# the generator itself does not take weather/lighting parameters).
|
| 359 |
+
weather = cell.get("weather")
|
| 360 |
+
lighting = cell.get("lighting")
|
| 361 |
+
for frame in legacy.where("frames", dataset_id=dataset.dataset_id):
|
| 362 |
+
if weather:
|
| 363 |
+
frame.weather = weather
|
| 364 |
+
if weather != "clear" and "adverse_weather" not in frame.scenario_tags:
|
| 365 |
+
frame.scenario_tags.append("adverse_weather")
|
| 366 |
+
if lighting:
|
| 367 |
+
frame.time_of_day = "night" if lighting == "night" else lighting
|
| 368 |
+
if lighting == "night" and "night_glare" not in frame.scenario_tags:
|
| 369 |
+
frame.scenario_tags.append("night_glare")
|
| 370 |
+
if "odd_gap_fill" not in frame.scenario_tags:
|
| 371 |
+
frame.scenario_tags.append("odd_gap_fill")
|
| 372 |
+
legacy.put("frames", frame)
|
| 373 |
+
|
| 374 |
+
annotations = synthetic.generate_labels(legacy, dataset,
|
| 375 |
+
model_version=run.model_version)
|
| 376 |
+
labeled_gt = {a.matched_gt_id for a in annotations if a.matched_gt_id}
|
| 377 |
+
|
| 378 |
+
target_class = _CLASS_TO_GENERATOR.get(cell.get("class", ""), None)
|
| 379 |
+
n_added = tp_added = 0
|
| 380 |
+
for frame in legacy.where("frames", dataset_id=dataset.dataset_id):
|
| 381 |
+
for gt in frame.gt_boxes:
|
| 382 |
+
if target_class is not None and gt.class_name != target_class:
|
| 383 |
+
continue
|
| 384 |
+
n_added += 1
|
| 385 |
+
if gt.gt_id in labeled_gt:
|
| 386 |
+
tp_added += 1
|
| 387 |
+
fn_added = n_added - tp_added
|
| 388 |
+
|
| 389 |
+
supplement = {
|
| 390 |
+
"cell_id": cid,
|
| 391 |
+
"cell": cell,
|
| 392 |
+
"dataset_id": dataset.dataset_id,
|
| 393 |
+
"n_added": n_added,
|
| 394 |
+
"tp_added": tp_added,
|
| 395 |
+
"fn_added": fn_added,
|
| 396 |
+
"seed": seed,
|
| 397 |
+
"source": "synthetic",
|
| 398 |
+
"created_at": _now(),
|
| 399 |
+
"note": "synthetic supplement generated by sensorflow.evaluation.synthetic; "
|
| 400 |
+
"counted separately from the immutable evaluation population",
|
| 401 |
+
}
|
| 402 |
+
supplements = load_supplements(run.run_id)
|
| 403 |
+
supplements.append(supplement)
|
| 404 |
+
write_json(supplements, *_supplements_key(run.run_id))
|
| 405 |
+
|
| 406 |
+
ProcessMeter(legacy, run_id=run.run_id).record("odd_gap_fill", n_added, factor=1.2)
|
| 407 |
+
legacy.audit("odd_gap_filled", "Dataset", dataset.dataset_id,
|
| 408 |
+
f"cell={cid} n_added={n_added}")
|
| 409 |
+
legacy.save()
|
| 410 |
+
|
| 411 |
+
# Register the generated scenario in the scenario database.
|
| 412 |
+
try:
|
| 413 |
+
from sensorflow.safety import scenario_db
|
| 414 |
+
scenario_db.get_db().add_gap_fill(run.run_id, cell, dataset.dataset_id, n_added)
|
| 415 |
+
except Exception:
|
| 416 |
+
pass
|
| 417 |
+
|
| 418 |
+
after = coverage_for_run(store, run, dims=dims, include_cells=True)
|
| 419 |
+
after_cell = next(c for c in after["cells"] if c["cell_id"] == cid)
|
| 420 |
+
after.pop("cells")
|
| 421 |
+
|
| 422 |
+
return {
|
| 423 |
+
"run_id": run.run_id,
|
| 424 |
+
"cell_id": cid,
|
| 425 |
+
"cell": cell,
|
| 426 |
+
"generated_dataset_id": dataset.dataset_id,
|
| 427 |
+
"objects_added": n_added,
|
| 428 |
+
"labeled_added": tp_added,
|
| 429 |
+
"supplement": supplement,
|
| 430 |
+
"cell_before": before_cell,
|
| 431 |
+
"cell_after": after_cell,
|
| 432 |
+
"coverage_after": after,
|
| 433 |
+
}
|
sensorflow/safety/scenario_db.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Curated scenario database (Safety Pool-inspired, local).
|
| 2 |
+
|
| 3 |
+
Industry concept: shared scenario databases (Safety Pool, StreetWise, ASAM
|
| 4 |
+
OpenSCENARIO catalogs) curate reusable test scenarios with taxonomy tags, ODD
|
| 5 |
+
attributes, severity and provenance. This module keeps a local, JSON-persisted
|
| 6 |
+
equivalent under runs/safety/scenario_db.json:
|
| 7 |
+
|
| 8 |
+
- record: id, scenario type taxonomy, ODD tags, source
|
| 9 |
+
(mined | synthetic | discrepancy | rare_event), severity, provenance /
|
| 10 |
+
lineage, evidence refs
|
| 11 |
+
- auto-population from the existing rare-event store, discrepancy mining
|
| 12 |
+
reports and ODD gap-filling
|
| 13 |
+
- search/filter + JSON bundle export
|
| 14 |
+
|
| 15 |
+
Record ids are deterministic hashes of their provenance so repeated
|
| 16 |
+
auto-population is idempotent.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import hashlib
|
| 22 |
+
import json
|
| 23 |
+
import threading
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
from typing import Dict, List, Literal, Optional
|
| 26 |
+
|
| 27 |
+
from pydantic import BaseModel, Field
|
| 28 |
+
|
| 29 |
+
from sensorflow.safety.store import read_json, write_json
|
| 30 |
+
|
| 31 |
+
SourceType = Literal["mined", "synthetic", "discrepancy", "rare_event"]
|
| 32 |
+
SEVERITIES = ["low", "medium", "high", "critical"]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _now() -> str:
|
| 36 |
+
return datetime.now(timezone.utc).isoformat()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _det_id(*parts: str) -> str:
|
| 40 |
+
return "scn-" + hashlib.sha256("|".join(parts).encode()).hexdigest()[:12]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ScenarioRecord(BaseModel):
|
| 44 |
+
scenario_id: str
|
| 45 |
+
scenario_type: str
|
| 46 |
+
odd_tags: Dict[str, str] = Field(default_factory=dict)
|
| 47 |
+
source: SourceType = "mined"
|
| 48 |
+
severity: str = "medium"
|
| 49 |
+
description: str = ""
|
| 50 |
+
provenance: Dict[str, str] = Field(default_factory=dict)
|
| 51 |
+
evidence_refs: List[str] = Field(default_factory=list)
|
| 52 |
+
created_at: str = Field(default_factory=_now)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ScenarioDB:
|
| 56 |
+
"""JSON-persisted scenario catalog with idempotent auto-population."""
|
| 57 |
+
|
| 58 |
+
def __init__(self):
|
| 59 |
+
self._lock = threading.RLock()
|
| 60 |
+
self._records: Dict[str, ScenarioRecord] = {}
|
| 61 |
+
self._load()
|
| 62 |
+
|
| 63 |
+
def _load(self) -> None:
|
| 64 |
+
raw = read_json("scenario_db.json") or {}
|
| 65 |
+
for sid, payload in raw.get("records", {}).items():
|
| 66 |
+
try:
|
| 67 |
+
self._records[sid] = ScenarioRecord.model_validate(payload)
|
| 68 |
+
except Exception:
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
def _save(self) -> None:
|
| 72 |
+
write_json({"records": {sid: r.model_dump() for sid, r in self._records.items()},
|
| 73 |
+
"updated_at": _now()}, "scenario_db.json")
|
| 74 |
+
|
| 75 |
+
# ---- ingestion
|
| 76 |
+
|
| 77 |
+
def add(self, record: ScenarioRecord) -> ScenarioRecord:
|
| 78 |
+
with self._lock:
|
| 79 |
+
self._records[record.scenario_id] = record
|
| 80 |
+
self._save()
|
| 81 |
+
return record
|
| 82 |
+
|
| 83 |
+
def add_from_rare_events(self, store) -> int:
|
| 84 |
+
"""Idempotently import every rare event as a mined/rare_event scenario."""
|
| 85 |
+
added = 0
|
| 86 |
+
with self._lock:
|
| 87 |
+
for ev in store.all("rare_events"):
|
| 88 |
+
sid = _det_id("rare_event", ev.event_id)
|
| 89 |
+
if sid in self._records:
|
| 90 |
+
continue
|
| 91 |
+
frame = store.get("frames", ev.evidence_frames[0]) if ev.evidence_frames else None
|
| 92 |
+
odd_tags = {}
|
| 93 |
+
if frame is not None:
|
| 94 |
+
odd_tags = {"weather": frame.weather, "lighting": frame.time_of_day}
|
| 95 |
+
self._records[sid] = ScenarioRecord(
|
| 96 |
+
scenario_id=sid,
|
| 97 |
+
scenario_type=ev.scenario_type,
|
| 98 |
+
odd_tags=odd_tags,
|
| 99 |
+
source="rare_event",
|
| 100 |
+
severity=ev.severity,
|
| 101 |
+
description=ev.description,
|
| 102 |
+
provenance={"event_id": ev.event_id, "dataset_id": ev.dataset_id},
|
| 103 |
+
evidence_refs=[f"rare_event:{ev.event_id}"]
|
| 104 |
+
+ [f"frame:{f}" for f in ev.evidence_frames[:5]],
|
| 105 |
+
)
|
| 106 |
+
added += 1
|
| 107 |
+
if added:
|
| 108 |
+
self._save()
|
| 109 |
+
return added
|
| 110 |
+
|
| 111 |
+
def add_from_discrepancies(self, report: Dict) -> int:
|
| 112 |
+
"""Import discrepancy-mining findings (high/critical severity only)."""
|
| 113 |
+
added = 0
|
| 114 |
+
with self._lock:
|
| 115 |
+
for d in report.get("discrepancies", []):
|
| 116 |
+
if d["severity"] not in ("high", "critical"):
|
| 117 |
+
continue
|
| 118 |
+
sid = _det_id("discrepancy", d["dataset_id"], d["gt_id"], d["type"])
|
| 119 |
+
if sid in self._records:
|
| 120 |
+
continue
|
| 121 |
+
self._records[sid] = ScenarioRecord(
|
| 122 |
+
scenario_id=sid,
|
| 123 |
+
scenario_type=f"discrepancy_{d['type'].lower()}",
|
| 124 |
+
odd_tags={"weather": d["weather"], "lighting": d["time_of_day"],
|
| 125 |
+
"actor_class": d["class_name"]},
|
| 126 |
+
source="discrepancy",
|
| 127 |
+
severity=d["severity"],
|
| 128 |
+
description=f"{d['type']} on {d['class_name']} "
|
| 129 |
+
f"({d['weather']}/{d['time_of_day']}) — online vs "
|
| 130 |
+
f"offline auto-label diff",
|
| 131 |
+
provenance={"dataset_id": d["dataset_id"], "frame_id": d["frame_id"],
|
| 132 |
+
"gt_id": d["gt_id"], "discrepancy_id": d["discrepancy_id"]},
|
| 133 |
+
evidence_refs=[f"frame:{d['frame_id']}", f"gt:{d['gt_id']}"],
|
| 134 |
+
)
|
| 135 |
+
added += 1
|
| 136 |
+
if added:
|
| 137 |
+
self._save()
|
| 138 |
+
return added
|
| 139 |
+
|
| 140 |
+
def add_gap_fill(self, run_id: str, cell: Dict[str, str], dataset_id: str,
|
| 141 |
+
n_objects: int) -> ScenarioRecord:
|
| 142 |
+
sid = _det_id("gap_fill", run_id, dataset_id)
|
| 143 |
+
rec = ScenarioRecord(
|
| 144 |
+
scenario_id=sid,
|
| 145 |
+
scenario_type="odd_gap_fill",
|
| 146 |
+
odd_tags={k: str(v) for k, v in cell.items()},
|
| 147 |
+
source="synthetic",
|
| 148 |
+
severity="medium",
|
| 149 |
+
description=f"Synthetic gap-filling scenario for ODD cell "
|
| 150 |
+
f"{'|'.join(f'{k}={v}' for k, v in cell.items())} "
|
| 151 |
+
f"({n_objects} objects)",
|
| 152 |
+
provenance={"run_id": run_id, "dataset_id": dataset_id},
|
| 153 |
+
evidence_refs=[f"dataset:{dataset_id}", f"odd_supplement:{run_id}"],
|
| 154 |
+
)
|
| 155 |
+
return self.add(rec)
|
| 156 |
+
|
| 157 |
+
# ---- queries
|
| 158 |
+
|
| 159 |
+
def search(self, scenario_type: Optional[str] = None, source: Optional[str] = None,
|
| 160 |
+
severity: Optional[str] = None, odd_tags: Optional[Dict[str, str]] = None,
|
| 161 |
+
text: Optional[str] = None, limit: int = 100) -> List[ScenarioRecord]:
|
| 162 |
+
with self._lock:
|
| 163 |
+
records = list(self._records.values())
|
| 164 |
+
out = []
|
| 165 |
+
for r in records:
|
| 166 |
+
if scenario_type and scenario_type.lower() not in r.scenario_type.lower():
|
| 167 |
+
continue
|
| 168 |
+
if source and r.source != source:
|
| 169 |
+
continue
|
| 170 |
+
if severity and r.severity != severity:
|
| 171 |
+
continue
|
| 172 |
+
if odd_tags and any(r.odd_tags.get(k) != v for k, v in odd_tags.items()):
|
| 173 |
+
continue
|
| 174 |
+
if text and text.lower() not in json.dumps(r.model_dump()).lower():
|
| 175 |
+
continue
|
| 176 |
+
out.append(r)
|
| 177 |
+
sev_rank = {s: i for i, s in enumerate(SEVERITIES)}
|
| 178 |
+
out.sort(key=lambda r: (-sev_rank.get(r.severity, 0), r.created_at))
|
| 179 |
+
return out[:max(1, min(limit, 1000))]
|
| 180 |
+
|
| 181 |
+
def counts(self) -> Dict:
|
| 182 |
+
with self._lock:
|
| 183 |
+
records = list(self._records.values())
|
| 184 |
+
by_source: Dict[str, int] = {}
|
| 185 |
+
by_severity: Dict[str, int] = {}
|
| 186 |
+
by_type: Dict[str, int] = {}
|
| 187 |
+
for r in records:
|
| 188 |
+
by_source[r.source] = by_source.get(r.source, 0) + 1
|
| 189 |
+
by_severity[r.severity] = by_severity.get(r.severity, 0) + 1
|
| 190 |
+
by_type[r.scenario_type] = by_type.get(r.scenario_type, 0) + 1
|
| 191 |
+
return {"total": len(records), "by_source": by_source,
|
| 192 |
+
"by_severity": by_severity, "by_type": by_type}
|
| 193 |
+
|
| 194 |
+
def export_bundle(self, **search_kwargs) -> Dict:
|
| 195 |
+
records = self.search(limit=search_kwargs.pop("limit", 1000), **search_kwargs)
|
| 196 |
+
return {
|
| 197 |
+
"bundle_format": "sensorflow-scenario-bundle/v1",
|
| 198 |
+
"exported_at": _now(),
|
| 199 |
+
"filters": {k: v for k, v in search_kwargs.items() if v},
|
| 200 |
+
"count": len(records),
|
| 201 |
+
"scenarios": [r.model_dump() for r in records],
|
| 202 |
+
"note": "local Safety Pool-inspired scenario catalog; scenario records "
|
| 203 |
+
"reference platform evidence (frames, rare events, datasets)",
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
_DB: Optional[ScenarioDB] = None
|
| 208 |
+
_DB_LOCK = threading.Lock()
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def get_db() -> ScenarioDB:
|
| 212 |
+
global _DB
|
| 213 |
+
with _DB_LOCK:
|
| 214 |
+
if _DB is None:
|
| 215 |
+
_DB = ScenarioDB()
|
| 216 |
+
return _DB
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def reset_db() -> None:
|
| 220 |
+
"""Test hook (also needed after set_safety_root)."""
|
| 221 |
+
global _DB
|
| 222 |
+
with _DB_LOCK:
|
| 223 |
+
_DB = None
|
sensorflow/safety/semantic.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Neuro-symbolic semantic scenario mining (lightweight, two-stage).
|
| 2 |
+
|
| 3 |
+
Industry concept: natural-language scenario search over fleet data (Voxel51 /
|
| 4 |
+
Scale Nucleus semantic search, Applied Intuition scenario mining). Two stages:
|
| 5 |
+
|
| 6 |
+
1. SYMBOLIC filter — structured attributes (container ODD dimensions, error
|
| 7 |
+
rates, dynamics) restrict the candidate set exactly.
|
| 8 |
+
2. REASONING scorer — if a local Ollama endpoint is reachable (reusing the
|
| 9 |
+
copilot plumbing in sensorflow/evaluation/copilot.py) it contributes a
|
| 10 |
+
natural-language rationale for the top candidates; otherwise (and always,
|
| 11 |
+
for the actual ranking) a DETERMINISTIC rule-based scorer maps concept
|
| 12 |
+
keywords to structured evidence via an explicit lexicon. Every result
|
| 13 |
+
carries per-stage explanations of why it matched.
|
| 14 |
+
|
| 15 |
+
Hybrid retrieval: the rule score is blended with cosine similarity over the
|
| 16 |
+
run's existing 32-d structural container embeddings (centroid of the top rule
|
| 17 |
+
matches acts as the query anchor), combining symbolic and dense signals.
|
| 18 |
+
|
| 19 |
+
Honest marker: the "reasoning" stage is a keyword lexicon over structured
|
| 20 |
+
evidence unless Ollama is reachable; rankings are deterministic either way
|
| 21 |
+
(the LLM adds rationale text, it never reorders results).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import re
|
| 27 |
+
from typing import Dict, List, Optional, Tuple
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
from sensorflow.megaeval import population as pop_mod
|
| 32 |
+
from sensorflow.megaeval.population import DIMENSIONS
|
| 33 |
+
|
| 34 |
+
# Each lexicon entry: (regex over the concept text, human-readable meaning,
|
| 35 |
+
# scorer(row) -> (score in [0,1], evidence string)).
|
| 36 |
+
_LEXICON: List[Tuple[str, str, object]] = []
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _rule(pattern: str, meaning: str):
|
| 40 |
+
def deco(fn):
|
| 41 |
+
_LEXICON.append((pattern, meaning, fn))
|
| 42 |
+
return fn
|
| 43 |
+
return deco
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@_rule(r"pedestrian|vru|vulnerable|person|cyclist|bike", "vulnerable road users present")
|
| 47 |
+
def _vru(row):
|
| 48 |
+
share = row["safety_n"] / max(row["n_objects"], 1)
|
| 49 |
+
return min(1.0, share * 4.0), f"safety-critical VRU share {share:.0%}"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@_rule(r"night|dark|glare|low light", "night / low-illumination context")
|
| 53 |
+
def _night(row):
|
| 54 |
+
if row["lighting"] == "night":
|
| 55 |
+
return 1.0, "lighting=night"
|
| 56 |
+
if row["lighting"] == "dusk":
|
| 57 |
+
return 0.5, "lighting=dusk"
|
| 58 |
+
return 0.0, ""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@_rule(r"rain|wet|fog|snow|weather|storm", "adverse weather context")
|
| 62 |
+
def _weather(row):
|
| 63 |
+
return (1.0, f"weather={row['weather']}") if row["weather"] != "clear" else (0.0, "")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@_rule(r"hesitant|erratic|unusual|anomal|unpredict|jerky", "anomalous / erratic behavior evidence")
|
| 67 |
+
def _anom(row):
|
| 68 |
+
rate = row["anomalies"] / max(row["n_objects"], 1)
|
| 69 |
+
return min(1.0, rate * 8.0), f"anomaly rate {rate:.0%}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@_rule(r"occlu|hidden|blocked|obstruct", "heavy occlusion context")
|
| 73 |
+
def _occl(row):
|
| 74 |
+
return (1.0, "scenario=occlusion_heavy") if row["scenario"] == "occlusion_heavy" else (0.0, "")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@_rule(r"near[ -]?miss|close call|conflict|collision|ttc", "near-miss / extreme-TTC scenario")
|
| 78 |
+
def _nearmiss(row):
|
| 79 |
+
if row["scenario"] in ("near_miss", "extreme_ttc"):
|
| 80 |
+
return 1.0, f"scenario={row['scenario']}"
|
| 81 |
+
return 0.0, ""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@_rule(r"intersection|junction|crossing", "intersection scenery")
|
| 85 |
+
def _intersection(row):
|
| 86 |
+
return (1.0, "road_type=intersection") if row["road_type"] == "intersection" else (0.0, "")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@_rule(r"urban|city|street", "urban scenery")
|
| 90 |
+
def _urban(row):
|
| 91 |
+
return (1.0, "road_type=urban") if row["road_type"] == "urban" else (0.0, "")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@_rule(r"highway|freeway|motorway", "highway scenery")
|
| 95 |
+
def _highway(row):
|
| 96 |
+
return (1.0, "road_type=highway") if row["road_type"] == "highway" else (0.0, "")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@_rule(r"rural|country", "rural scenery")
|
| 100 |
+
def _rural(row):
|
| 101 |
+
return (1.0, "road_type=rural") if row["road_type"] == "rural" else (0.0, "")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@_rule(r"lane edge|lane|curb|edge|merge", "lane-boundary interaction (proxy)")
|
| 105 |
+
def _lane(row):
|
| 106 |
+
# Proxy: the population has no lane geometry; urban/intersection scenes are
|
| 107 |
+
# where lane-edge interactions concentrate. Explained as a proxy.
|
| 108 |
+
if row["road_type"] in ("urban", "intersection"):
|
| 109 |
+
return 0.5, f"road_type={row['road_type']} (lane-edge proxy — no lane geometry instrumented)"
|
| 110 |
+
return 0.0, ""
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@_rule(r"miss|fail|undetected|false negative|fn\b", "model misses (FN) evidence")
|
| 114 |
+
def _fn(row):
|
| 115 |
+
rate = row["fn"] / max(row["n_objects"], 1)
|
| 116 |
+
return min(1.0, rate * 5.0), f"FN rate {rate:.0%}"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@_rule(r"false positive|ghost|hallucinat|fp\b", "false-positive evidence")
|
| 120 |
+
def _fp(row):
|
| 121 |
+
rate = row["fp"] / max(row["n_objects"], 1)
|
| 122 |
+
return min(1.0, rate * 5.0), f"FP rate {rate:.0%}"
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@_rule(r"sensor|degraded|dropout|noise", "sensor-degraded scenario")
|
| 126 |
+
def _sensor(row):
|
| 127 |
+
return (1.0, "scenario=sensor_degraded") if row["scenario"] == "sensor_degraded" else (0.0, "")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@_rule(r"risk|critical|danger|safety|severe", "high composite risk")
|
| 131 |
+
def _risk(row):
|
| 132 |
+
return float(row["risk_score"]), f"risk_score {row['risk_score']:.2f}"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _container_rows(store, run) -> List[Dict]:
|
| 136 |
+
art = store.artifacts(run.run_id)
|
| 137 |
+
df = art["containers"]
|
| 138 |
+
rows = []
|
| 139 |
+
for _, r in df.iterrows():
|
| 140 |
+
rows.append({
|
| 141 |
+
"container_id": int(r["container_id"]),
|
| 142 |
+
**{dim: DIMENSIONS[dim][int(r[dim])] for dim in pop_mod.CONTAINER_DIMS},
|
| 143 |
+
"n_objects": int(r["n_objects"]),
|
| 144 |
+
"fn": int(r["fn"]), "fp": int(r["fp"]),
|
| 145 |
+
"anomalies": int(r["anomalies"]),
|
| 146 |
+
"safety_n": int(r["safety_n"]),
|
| 147 |
+
"risk_score": float(r["risk_score"]),
|
| 148 |
+
})
|
| 149 |
+
return rows
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _symbolic_filter(rows: List[Dict], filters: Optional[Dict]) -> Tuple[List[Dict], Dict]:
|
| 153 |
+
before = len(rows)
|
| 154 |
+
applied = {}
|
| 155 |
+
for dim, values in (filters or {}).items():
|
| 156 |
+
if dim not in pop_mod.CONTAINER_DIMS:
|
| 157 |
+
continue
|
| 158 |
+
vals = values if isinstance(values, list) else [values]
|
| 159 |
+
rows = [r for r in rows if r[dim] in vals]
|
| 160 |
+
applied[dim] = vals
|
| 161 |
+
return rows, {"stage": "symbolic_filter", "applied_filters": applied,
|
| 162 |
+
"candidates_before": before, "candidates_after": len(rows)}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _rule_score(concept: str, row: Dict) -> Tuple[float, List[Dict]]:
|
| 166 |
+
matched = []
|
| 167 |
+
total = 0.0
|
| 168 |
+
weight_sum = 0.0
|
| 169 |
+
for pattern, meaning, fn in _LEXICON:
|
| 170 |
+
if not re.search(pattern, concept, flags=re.IGNORECASE):
|
| 171 |
+
continue
|
| 172 |
+
score, evidence = fn(row)
|
| 173 |
+
weight_sum += 1.0
|
| 174 |
+
total += score
|
| 175 |
+
if score > 0:
|
| 176 |
+
matched.append({"term": pattern, "meaning": meaning,
|
| 177 |
+
"evidence": evidence, "contribution": round(score, 4)})
|
| 178 |
+
if weight_sum == 0:
|
| 179 |
+
return float(row["risk_score"]), [{
|
| 180 |
+
"term": "(fallback)", "meaning": "no lexicon term matched the concept",
|
| 181 |
+
"evidence": f"ranked by composite risk_score {row['risk_score']:.2f}",
|
| 182 |
+
"contribution": round(float(row["risk_score"]), 4)}]
|
| 183 |
+
return total / weight_sum, matched
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _llm_rationale(concept: str, top_rows: List[Dict], timeout_s: float = 2.0) -> Optional[Dict]:
|
| 187 |
+
"""Optional Ollama pass (copilot plumbing); never reorders results."""
|
| 188 |
+
try:
|
| 189 |
+
import json as _json
|
| 190 |
+
|
| 191 |
+
import httpx
|
| 192 |
+
|
| 193 |
+
from sensorflow.evaluation.copilot import OLLAMA_ENDPOINTS
|
| 194 |
+
prompt = (
|
| 195 |
+
"You are a scenario-mining assistant. Concept query: "
|
| 196 |
+
f"{concept!r}. Candidate scenes (structured evidence):\n"
|
| 197 |
+
f"{_json.dumps(top_rows[:6], indent=1)[:2500]}\n"
|
| 198 |
+
"In 3 sentences, explain which candidates best match the concept and why."
|
| 199 |
+
)
|
| 200 |
+
for ep in OLLAMA_ENDPOINTS:
|
| 201 |
+
try:
|
| 202 |
+
res = httpx.post(ep["url"], json={
|
| 203 |
+
"model": ep["model"],
|
| 204 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 205 |
+
"stream": False,
|
| 206 |
+
}, timeout=timeout_s)
|
| 207 |
+
if res.status_code == 200:
|
| 208 |
+
text = res.json().get("message", {}).get("content", "")
|
| 209 |
+
if text:
|
| 210 |
+
return {"provider": ep["url"], "rationale": text}
|
| 211 |
+
except Exception:
|
| 212 |
+
continue
|
| 213 |
+
except Exception:
|
| 214 |
+
pass
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def search_containers(store, run, concept: str, filters: Optional[Dict] = None,
|
| 219 |
+
k: int = 12, use_llm: Optional[bool] = None) -> Dict:
|
| 220 |
+
"""Two-stage neuro-symbolic search over a run's containers.
|
| 221 |
+
|
| 222 |
+
Ranking = 0.65 * deterministic rule score + 0.35 * embedding similarity to
|
| 223 |
+
the centroid of the top rule matches (hybrid retrieval). LLM (if enabled
|
| 224 |
+
and reachable) adds rationale text only.
|
| 225 |
+
"""
|
| 226 |
+
rows = _container_rows(store, run)
|
| 227 |
+
rows, stage1 = _symbolic_filter(rows, filters)
|
| 228 |
+
|
| 229 |
+
scored = []
|
| 230 |
+
for row in rows:
|
| 231 |
+
score, matched = _rule_score(concept, row)
|
| 232 |
+
scored.append((score, row, matched))
|
| 233 |
+
scored.sort(key=lambda t: (-t[0], t[1]["container_id"]))
|
| 234 |
+
|
| 235 |
+
# Hybrid: embedding similarity to the centroid of the top rule matches.
|
| 236 |
+
art = store.artifacts(run.run_id)
|
| 237 |
+
emb_ids, emb = art.get("emb_ids"), art.get("emb")
|
| 238 |
+
emb_sim: Dict[int, float] = {}
|
| 239 |
+
if emb is not None and scored:
|
| 240 |
+
anchor_ids = [t[1]["container_id"] for t in scored[:10] if t[0] > 0]
|
| 241 |
+
pos = np.where(np.isin(emb_ids, anchor_ids))[0]
|
| 242 |
+
if pos.size:
|
| 243 |
+
centroid = emb[pos].mean(axis=0)
|
| 244 |
+
norm = np.linalg.norm(centroid)
|
| 245 |
+
if norm > 1e-9:
|
| 246 |
+
sims = emb @ (centroid / norm)
|
| 247 |
+
emb_sim = {int(cid): float(s) for cid, s in zip(emb_ids, sims)}
|
| 248 |
+
|
| 249 |
+
results = []
|
| 250 |
+
for rule_score, row, matched in scored:
|
| 251 |
+
sim = emb_sim.get(row["container_id"], 0.0)
|
| 252 |
+
sim01 = (sim + 1.0) / 2.0
|
| 253 |
+
final = 0.65 * rule_score + 0.35 * sim01 if emb_sim else rule_score
|
| 254 |
+
results.append({
|
| 255 |
+
**row,
|
| 256 |
+
"score": round(final, 4),
|
| 257 |
+
"rule_score": round(rule_score, 4),
|
| 258 |
+
"embedding_similarity": round(sim, 4) if emb_sim else None,
|
| 259 |
+
"explanations": {
|
| 260 |
+
"stage1_symbolic": ("passed structured filters"
|
| 261 |
+
if stage1["applied_filters"] else "no filters applied"),
|
| 262 |
+
"stage2_reasoning": matched,
|
| 263 |
+
},
|
| 264 |
+
})
|
| 265 |
+
results.sort(key=lambda r: (-r["score"], r["container_id"]))
|
| 266 |
+
results = results[:max(1, min(k, 100))]
|
| 267 |
+
|
| 268 |
+
llm = None
|
| 269 |
+
if use_llm or use_llm is None:
|
| 270 |
+
# auto mode: short-timeout attempt; deterministic ranking is unaffected
|
| 271 |
+
if use_llm: # explicit request gets a slightly longer timeout
|
| 272 |
+
llm = _llm_rationale(concept, results, timeout_s=6.0)
|
| 273 |
+
else:
|
| 274 |
+
llm = _llm_rationale(concept, results, timeout_s=1.5)
|
| 275 |
+
|
| 276 |
+
return {
|
| 277 |
+
"concept": concept,
|
| 278 |
+
"run_id": run.run_id,
|
| 279 |
+
"target": "containers",
|
| 280 |
+
"stage1": stage1,
|
| 281 |
+
"stage2": {
|
| 282 |
+
"stage": "reasoning_scorer",
|
| 283 |
+
"provider": (llm or {}).get("provider", "offline_deterministic"),
|
| 284 |
+
"note": "deterministic lexicon over structured evidence; LLM (when "
|
| 285 |
+
"reachable) adds rationale text and never reorders results",
|
| 286 |
+
},
|
| 287 |
+
"hybrid": {"rule_weight": 0.65, "embedding_weight": 0.35,
|
| 288 |
+
"embedding_source": "32-d structural container embeddings"},
|
| 289 |
+
"llm_rationale": (llm or {}).get("rationale"),
|
| 290 |
+
"results": results,
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def search_scenarios(concept: str, filters: Optional[Dict] = None, k: int = 12) -> Dict:
|
| 295 |
+
"""Same two-stage idea over the scenario database (odd_tags as attributes)."""
|
| 296 |
+
from sensorflow.safety.scenario_db import get_db
|
| 297 |
+
records = get_db().search(limit=1000, odd_tags=None)
|
| 298 |
+
stage1_before = len(records)
|
| 299 |
+
filters = filters or {}
|
| 300 |
+
for key, val in filters.items():
|
| 301 |
+
vals = val if isinstance(val, list) else [val]
|
| 302 |
+
records = [r for r in records
|
| 303 |
+
if r.odd_tags.get(key) in vals or getattr(r, key, None) in vals]
|
| 304 |
+
|
| 305 |
+
results = []
|
| 306 |
+
for r in records:
|
| 307 |
+
row = { # adapt scenario record to the lexicon's row shape
|
| 308 |
+
"lighting": r.odd_tags.get("lighting", ""),
|
| 309 |
+
"weather": r.odd_tags.get("weather", ""),
|
| 310 |
+
"road_type": r.odd_tags.get("road_type", ""),
|
| 311 |
+
"scenario": r.scenario_type,
|
| 312 |
+
"n_objects": 1, "fn": 0, "fp": 0, "anomalies": 0,
|
| 313 |
+
"safety_n": 1 if r.odd_tags.get("actor_class") in
|
| 314 |
+
("pedestrian", "cyclist", "motorcycle") else 0,
|
| 315 |
+
"risk_score": {"low": 0.2, "medium": 0.45, "high": 0.7,
|
| 316 |
+
"critical": 0.95}.get(r.severity, 0.4),
|
| 317 |
+
}
|
| 318 |
+
score, matched = _rule_score(concept, row)
|
| 319 |
+
text_bonus = 0.15 if re.search(r"\w", concept) and any(
|
| 320 |
+
w in r.description.lower() for w in re.findall(r"[a-z]{4,}", concept.lower())) else 0.0
|
| 321 |
+
results.append({
|
| 322 |
+
"scenario_id": r.scenario_id,
|
| 323 |
+
"scenario_type": r.scenario_type,
|
| 324 |
+
"source": r.source,
|
| 325 |
+
"severity": r.severity,
|
| 326 |
+
"odd_tags": r.odd_tags,
|
| 327 |
+
"description": r.description,
|
| 328 |
+
"score": round(min(1.0, score + text_bonus), 4),
|
| 329 |
+
"explanations": {"stage1_symbolic": filters or "no filters applied",
|
| 330 |
+
"stage2_reasoning": matched},
|
| 331 |
+
})
|
| 332 |
+
results.sort(key=lambda x: (-x["score"], x["scenario_id"]))
|
| 333 |
+
return {
|
| 334 |
+
"concept": concept,
|
| 335 |
+
"target": "scenarios",
|
| 336 |
+
"stage1": {"stage": "symbolic_filter", "applied_filters": filters,
|
| 337 |
+
"candidates_before": stage1_before, "candidates_after": len(results)},
|
| 338 |
+
"stage2": {"stage": "reasoning_scorer", "provider": "offline_deterministic"},
|
| 339 |
+
"results": results[:max(1, min(k, 100))],
|
| 340 |
+
}
|
sensorflow/safety/ssam_ext.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extended SSAM surrogate safety measures (FHWA Surrogate Safety Assessment Model).
|
| 2 |
+
|
| 3 |
+
Extends the platform's existing TTC/PET/severity SSAM logic (app_backend
|
| 4 |
+
/api/ssam/*, kept intact) into a shared analysis module operating on TRJ-like
|
| 5 |
+
trajectory data:
|
| 6 |
+
|
| 7 |
+
- TTC via conflict-point projection: each vehicle is a rectangle polygon
|
| 8 |
+
projected along its trajectory (constant-velocity extrapolation) over a
|
| 9 |
+
look-ahead interval; TTC is the first projected time at which the two
|
| 10 |
+
rectangles' separation reaches zero.
|
| 11 |
+
- PET via a zone-grid proximity function: actual footprints are rasterized
|
| 12 |
+
into a spatial grid; PET is the minimum positive time gap between one road
|
| 13 |
+
user leaving a cell and another entering it (classic SSAM definition).
|
| 14 |
+
- DRAC (deceleration rate to avoid crash) = closing_speed^2 / (2 * gap).
|
| 15 |
+
- DeltaS = max relative speed differential during a conflict (severity proxy).
|
| 16 |
+
- MaxS = max absolute speed of either participant during a conflict.
|
| 17 |
+
- Collision probability over the look-ahead horizon:
|
| 18 |
+
p(t) = clip(1 - TTC(t)/TTC_threshold, 0, 1); the conflict-level probability
|
| 19 |
+
is the max instantaneous value, with the time integral reported as exposure.
|
| 20 |
+
- CSI (Conflict Severity Index, time-weighted): per conflict,
|
| 21 |
+
CSI = sum_t [ 0.5 * mu * DeltaS(t)^2 * p(t) * dt ]
|
| 22 |
+
where mu is the reduced mass proxy of the pair (kinetic-energy proxy of the
|
| 23 |
+
relative motion). Aggregate CSI for a scenario suite is the sum across
|
| 24 |
+
conflicts; the release Safety Gate blocks candidates whose aggregate CSI
|
| 25 |
+
rises vs baseline beyond tolerance.
|
| 26 |
+
|
| 27 |
+
Honest markers: trajectories are synthetic (generate_trajectories) and the
|
| 28 |
+
model-conditioned reaction-time mapping in csi_for_run is a deterministic
|
| 29 |
+
simulation of how perception quality affects downstream conflict severity —
|
| 30 |
+
it is not a physics-validated driver model.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import math
|
| 36 |
+
from typing import Dict, List, Optional, Tuple
|
| 37 |
+
|
| 38 |
+
import numpy as np
|
| 39 |
+
|
| 40 |
+
from sensorflow.metrics.perception_3d import bev_iou
|
| 41 |
+
from sensorflow.safety.store import read_json, write_json
|
| 42 |
+
|
| 43 |
+
DEFAULT_PARAMS = {
|
| 44 |
+
"ttc_threshold_s": 1.5, # FHWA SSAM default TTC conflict threshold
|
| 45 |
+
"pet_threshold_s": 5.0, # FHWA SSAM default PET conflict threshold
|
| 46 |
+
"look_ahead_s": 3.0,
|
| 47 |
+
"projection_dt_s": 0.05,
|
| 48 |
+
"grid_cell_m": 0.5,
|
| 49 |
+
"rear_end_angle_deg": 30.0, # SSAM conflict-type angle conventions
|
| 50 |
+
"crossing_angle_deg": 85.0,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Unit-less mass proxies for the kinetic-energy severity term.
|
| 54 |
+
MASS_PROXY = {
|
| 55 |
+
"car": 1.0, "vehicle": 1.0, "truck": 2.5, "bus": 2.2,
|
| 56 |
+
"motorcycle": 0.25, "cyclist": 0.10, "pedestrian": 0.08,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------ geometry
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _velocity(state: Dict) -> Tuple[float, float]:
|
| 64 |
+
return (state["speed"] * math.cos(state["heading"]),
|
| 65 |
+
state["speed"] * math.sin(state["heading"]))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _support_extent(length: float, width: float, heading: float,
|
| 69 |
+
line_angle: float) -> float:
|
| 70 |
+
"""Half-extent of a rectangle along a line direction (support function)."""
|
| 71 |
+
phi = line_angle - heading
|
| 72 |
+
return (length / 2) * abs(math.cos(phi)) + (width / 2) * abs(math.sin(phi))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def rect_gap(pa: Tuple[float, float], ha: float, la: float, wa: float,
|
| 76 |
+
pb: Tuple[float, float], hb: float, lb: float, wb: float) -> float:
|
| 77 |
+
"""Separation between two rectangles along the center line (support-function
|
| 78 |
+
approximation; exact for aligned rear-end geometry, conservative otherwise)."""
|
| 79 |
+
dx, dy = pb[0] - pa[0], pb[1] - pa[1]
|
| 80 |
+
dist = math.hypot(dx, dy)
|
| 81 |
+
if dist < 1e-9:
|
| 82 |
+
return 0.0
|
| 83 |
+
ang = math.atan2(dy, dx)
|
| 84 |
+
gap = dist - _support_extent(la, wa, ha, ang) - _support_extent(lb, wb, hb, ang)
|
| 85 |
+
return max(0.0, gap)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def rects_overlap(pa, ha, la, wa, pb, hb, lb, wb) -> bool:
|
| 89 |
+
"""Exact rotated-rectangle overlap via the shared BEV IoU clipper."""
|
| 90 |
+
la, wa = max(la, 0.05), max(wa, 0.05)
|
| 91 |
+
lb, wb = max(lb, 0.05), max(wb, 0.05)
|
| 92 |
+
return bev_iou([pa[0], pa[1], 0, la, wa, 0, ha],
|
| 93 |
+
[pb[0], pb[1], 0, lb, wb, 0, hb]) > 1e-12
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def projected_ttc(state_a: Dict, dims_a: Tuple[float, float],
|
| 97 |
+
state_b: Dict, dims_b: Tuple[float, float],
|
| 98 |
+
look_ahead: float, dt: float) -> Optional[float]:
|
| 99 |
+
"""Conflict-point projection TTC: first projected time (constant-velocity
|
| 100 |
+
rectangle projection) at which the separation reaches zero.
|
| 101 |
+
|
| 102 |
+
Scans the look-ahead interval and linearly interpolates the zero crossing
|
| 103 |
+
of the gap, so linear closing geometry yields near-exact TTC values.
|
| 104 |
+
"""
|
| 105 |
+
vax, vay = _velocity(state_a)
|
| 106 |
+
vbx, vby = _velocity(state_b)
|
| 107 |
+
la, wa = dims_a
|
| 108 |
+
lb, wb = dims_b
|
| 109 |
+
prev_tau, prev_gap = 0.0, None
|
| 110 |
+
tau = 0.0
|
| 111 |
+
while tau <= look_ahead + 1e-9:
|
| 112 |
+
pa = (state_a["x"] + vax * tau, state_a["y"] + vay * tau)
|
| 113 |
+
pb = (state_b["x"] + vbx * tau, state_b["y"] + vby * tau)
|
| 114 |
+
gap = rect_gap(pa, state_a["heading"], la, wa, pb, state_b["heading"], lb, wb)
|
| 115 |
+
if gap <= 1e-9 or rects_overlap(pa, state_a["heading"], la, wa,
|
| 116 |
+
pb, state_b["heading"], lb, wb):
|
| 117 |
+
if prev_gap is None or prev_gap <= 1e-9:
|
| 118 |
+
return tau
|
| 119 |
+
frac = prev_gap / max(prev_gap - gap, 1e-9)
|
| 120 |
+
return prev_tau + frac * (tau - prev_tau)
|
| 121 |
+
prev_tau, prev_gap = tau, gap
|
| 122 |
+
tau += dt
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def drac(state_a: Dict, dims_a: Tuple[float, float],
|
| 127 |
+
state_b: Dict, dims_b: Tuple[float, float]) -> Optional[float]:
|
| 128 |
+
"""Deceleration Rate to Avoid Crash = closing_speed^2 / (2 * gap)."""
|
| 129 |
+
pa, pb = (state_a["x"], state_a["y"]), (state_b["x"], state_b["y"])
|
| 130 |
+
dx, dy = pb[0] - pa[0], pb[1] - pa[1]
|
| 131 |
+
dist = math.hypot(dx, dy)
|
| 132 |
+
if dist < 1e-9:
|
| 133 |
+
return None
|
| 134 |
+
vax, vay = _velocity(state_a)
|
| 135 |
+
vbx, vby = _velocity(state_b)
|
| 136 |
+
# d|pb-pa|/dt ; negative when the pair is closing.
|
| 137 |
+
range_rate = ((dx * (vbx - vax)) + (dy * (vby - vay))) / dist
|
| 138 |
+
closing = -range_rate
|
| 139 |
+
if closing <= 0:
|
| 140 |
+
return None
|
| 141 |
+
gap = rect_gap(pa, state_a["heading"], *dims_a, pb, state_b["heading"], *dims_b)
|
| 142 |
+
return closing ** 2 / (2 * max(gap, 0.1))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def collision_probability(ttc: Optional[float], ttc_threshold: float) -> float:
|
| 146 |
+
"""Instantaneous collision probability proxy: 1 - TTC/threshold, clipped."""
|
| 147 |
+
if ttc is None:
|
| 148 |
+
return 0.0
|
| 149 |
+
return float(np.clip(1.0 - ttc / ttc_threshold, 0.0, 1.0))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _conflict_type(heading_a: float, heading_b: float, params: Dict) -> str:
|
| 153 |
+
diff = abs(heading_a - heading_b) % (2 * math.pi)
|
| 154 |
+
diff = min(diff, 2 * math.pi - diff)
|
| 155 |
+
deg = math.degrees(diff)
|
| 156 |
+
if deg < params["rear_end_angle_deg"]:
|
| 157 |
+
return "rear_end"
|
| 158 |
+
if deg > params["crossing_angle_deg"]:
|
| 159 |
+
return "crossing"
|
| 160 |
+
return "lane_change"
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ------------------------------------------------------------------ zone-grid PET
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _footprint_cells(state: Dict, length: float, width: float, cell: float) -> List[Tuple[int, int]]:
|
| 167 |
+
"""Grid cells whose centers fall inside the vehicle rectangle footprint."""
|
| 168 |
+
x, y, h = state["x"], state["y"], state["heading"]
|
| 169 |
+
half_diag = math.hypot(length, width) / 2 + cell
|
| 170 |
+
cells = []
|
| 171 |
+
i0 = int(math.floor((x - half_diag) / cell))
|
| 172 |
+
i1 = int(math.ceil((x + half_diag) / cell))
|
| 173 |
+
j0 = int(math.floor((y - half_diag) / cell))
|
| 174 |
+
j1 = int(math.ceil((y + half_diag) / cell))
|
| 175 |
+
c, s = math.cos(-h), math.sin(-h)
|
| 176 |
+
hl, hw = max(length / 2, cell / 2), max(width / 2, cell / 2)
|
| 177 |
+
for i in range(i0, i1 + 1):
|
| 178 |
+
for j in range(j0, j1 + 1):
|
| 179 |
+
cx, cy = (i + 0.5) * cell - x, (j + 0.5) * cell - y
|
| 180 |
+
rx = cx * c - cy * s
|
| 181 |
+
ry = cx * s + cy * c
|
| 182 |
+
if abs(rx) <= hl and abs(ry) <= hw:
|
| 183 |
+
cells.append((i, j))
|
| 184 |
+
return cells
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def zone_grid_pet(traj_a: Dict, traj_b: Dict, cell: float) -> Optional[float]:
|
| 188 |
+
"""Post-encroachment time via zone-grid occupancy intervals.
|
| 189 |
+
|
| 190 |
+
PET = min over shared cells of the positive gap between one vehicle leaving
|
| 191 |
+
the cell and the other entering it.
|
| 192 |
+
"""
|
| 193 |
+
def occupancy(traj) -> Dict[Tuple[int, int], List[float]]:
|
| 194 |
+
occ: Dict[Tuple[int, int], List[float]] = {}
|
| 195 |
+
for st in traj["states"]:
|
| 196 |
+
for c in _footprint_cells(st, traj.get("length", 0.0),
|
| 197 |
+
traj.get("width", 0.0), cell):
|
| 198 |
+
span = occ.setdefault(c, [st["t"], st["t"]])
|
| 199 |
+
span[0] = min(span[0], st["t"])
|
| 200 |
+
span[1] = max(span[1], st["t"])
|
| 201 |
+
return occ
|
| 202 |
+
|
| 203 |
+
occ_a, occ_b = occupancy(traj_a), occupancy(traj_b)
|
| 204 |
+
pets = []
|
| 205 |
+
for c in set(occ_a) & set(occ_b):
|
| 206 |
+
(a0, a1), (b0, b1) = occ_a[c], occ_b[c]
|
| 207 |
+
if b0 > a1:
|
| 208 |
+
pets.append(b0 - a1)
|
| 209 |
+
elif a0 > b1:
|
| 210 |
+
pets.append(a0 - b1)
|
| 211 |
+
# overlapping occupancy => simultaneous presence (PET 0 handled by TTC path)
|
| 212 |
+
else:
|
| 213 |
+
pets.append(0.0)
|
| 214 |
+
return min(pets) if pets else None
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ------------------------------------------------------------------ analysis
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def analyze_trajectories(trajectories: List[Dict], params: Optional[Dict] = None) -> Dict:
|
| 221 |
+
"""Full SSAM-extended analysis: conflicts + surrogate measures + CSI."""
|
| 222 |
+
p = {**DEFAULT_PARAMS, **(params or {})}
|
| 223 |
+
conflicts: List[Dict] = []
|
| 224 |
+
total_time = 0.0
|
| 225 |
+
for traj in trajectories:
|
| 226 |
+
ts = [s["t"] for s in traj["states"]]
|
| 227 |
+
if len(ts) >= 2:
|
| 228 |
+
total_time = max(total_time, ts[-1] - ts[0])
|
| 229 |
+
|
| 230 |
+
for i in range(len(trajectories)):
|
| 231 |
+
for j in range(i + 1, len(trajectories)):
|
| 232 |
+
conflicts.extend(_analyze_pair(trajectories[i], trajectories[j], p))
|
| 233 |
+
|
| 234 |
+
agg = {
|
| 235 |
+
"num_conflicts": len(conflicts),
|
| 236 |
+
"by_type": {},
|
| 237 |
+
"min_ttc_s": None,
|
| 238 |
+
"min_pet_s": None,
|
| 239 |
+
"max_drac_mps2": None,
|
| 240 |
+
"max_delta_s_mps": None,
|
| 241 |
+
"max_s_mps": None,
|
| 242 |
+
"max_collision_probability": None,
|
| 243 |
+
"aggregate_csi": round(sum(c["csi"] for c in conflicts), 6),
|
| 244 |
+
"mean_csi_per_conflict": (round(sum(c["csi"] for c in conflicts) / len(conflicts), 6)
|
| 245 |
+
if conflicts else 0.0),
|
| 246 |
+
"observed_duration_s": round(total_time, 3),
|
| 247 |
+
}
|
| 248 |
+
for c in conflicts:
|
| 249 |
+
agg["by_type"][c["conflict_type"]] = agg["by_type"].get(c["conflict_type"], 0) + 1
|
| 250 |
+
for key, src, fn in (("min_ttc_s", "min_ttc_s", min), ("min_pet_s", "pet_s", min),
|
| 251 |
+
("max_drac_mps2", "max_drac_mps2", max),
|
| 252 |
+
("max_delta_s_mps", "delta_s_mps", max),
|
| 253 |
+
("max_s_mps", "max_s_mps", max),
|
| 254 |
+
("max_collision_probability", "collision_probability", max)):
|
| 255 |
+
v = c.get(src)
|
| 256 |
+
if v is None:
|
| 257 |
+
continue
|
| 258 |
+
agg[key] = v if agg[key] is None else fn(agg[key], v)
|
| 259 |
+
|
| 260 |
+
return {"params": p, "conflicts": conflicts, "aggregate": agg,
|
| 261 |
+
"measures_glossary": {
|
| 262 |
+
"TTC": "time-to-collision via rectangle conflict-point projection",
|
| 263 |
+
"PET": "post-encroachment time via zone-grid occupancy",
|
| 264 |
+
"DRAC": "deceleration rate to avoid crash = closing^2/(2*gap)",
|
| 265 |
+
"DeltaS": "max relative speed differential during conflict",
|
| 266 |
+
"MaxS": "max absolute speed during conflict",
|
| 267 |
+
"CSI": "time-weighted sum of 0.5*mu*DeltaS^2 * P(collision) * dt",
|
| 268 |
+
}}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _analyze_pair(traj_a: Dict, traj_b: Dict, p: Dict) -> List[Dict]:
|
| 272 |
+
dims_a = (traj_a.get("length", 0.0), traj_a.get("width", 0.0))
|
| 273 |
+
dims_b = (traj_b.get("length", 0.0), traj_b.get("width", 0.0))
|
| 274 |
+
mass_a = MASS_PROXY.get(traj_a.get("vehicle_type", "car"), 1.0)
|
| 275 |
+
mass_b = MASS_PROXY.get(traj_b.get("vehicle_type", "car"), 1.0)
|
| 276 |
+
mu = (mass_a * mass_b) / max(mass_a + mass_b, 1e-9) # reduced mass proxy
|
| 277 |
+
|
| 278 |
+
by_t_b = {round(s["t"], 6): s for s in traj_b["states"]}
|
| 279 |
+
samples = []
|
| 280 |
+
for sa in traj_a["states"]:
|
| 281 |
+
sb = by_t_b.get(round(sa["t"], 6))
|
| 282 |
+
if sb is None:
|
| 283 |
+
continue
|
| 284 |
+
ttc = projected_ttc(sa, dims_a, sb, dims_b, p["look_ahead_s"], p["projection_dt_s"])
|
| 285 |
+
samples.append((sa, sb, ttc))
|
| 286 |
+
|
| 287 |
+
pet = zone_grid_pet(traj_a, traj_b, p["grid_cell_m"])
|
| 288 |
+
|
| 289 |
+
# Contiguous samples with TTC below threshold form one conflict event.
|
| 290 |
+
events: List[List[Tuple[Dict, Dict, float]]] = []
|
| 291 |
+
current: List[Tuple[Dict, Dict, float]] = []
|
| 292 |
+
for sa, sb, ttc in samples:
|
| 293 |
+
active = ttc is not None and ttc <= p["ttc_threshold_s"]
|
| 294 |
+
if active:
|
| 295 |
+
current.append((sa, sb, ttc))
|
| 296 |
+
elif current:
|
| 297 |
+
events.append(current)
|
| 298 |
+
current = []
|
| 299 |
+
if current:
|
| 300 |
+
events.append(current)
|
| 301 |
+
|
| 302 |
+
ts = sorted(s["t"] for s in traj_a["states"])
|
| 303 |
+
dt = (ts[1] - ts[0]) if len(ts) >= 2 else 0.1
|
| 304 |
+
|
| 305 |
+
conflicts = []
|
| 306 |
+
for ev in events:
|
| 307 |
+
delta_s = max_s = 0.0
|
| 308 |
+
max_drac_v: Optional[float] = None
|
| 309 |
+
min_ttc = None
|
| 310 |
+
max_p = 0.0
|
| 311 |
+
csi = 0.0
|
| 312 |
+
exposure = 0.0
|
| 313 |
+
for sa, sb, ttc in ev:
|
| 314 |
+
vax, vay = _velocity(sa)
|
| 315 |
+
vbx, vby = _velocity(sb)
|
| 316 |
+
ds = math.hypot(vax - vbx, vay - vby)
|
| 317 |
+
delta_s = max(delta_s, ds)
|
| 318 |
+
max_s = max(max_s, sa["speed"], sb["speed"])
|
| 319 |
+
d = drac(sa, dims_a, sb, dims_b)
|
| 320 |
+
if d is not None:
|
| 321 |
+
max_drac_v = d if max_drac_v is None else max(max_drac_v, d)
|
| 322 |
+
min_ttc = ttc if min_ttc is None else min(min_ttc, ttc)
|
| 323 |
+
prob = collision_probability(ttc, p["ttc_threshold_s"])
|
| 324 |
+
max_p = max(max_p, prob)
|
| 325 |
+
csi += 0.5 * mu * ds ** 2 * prob * dt
|
| 326 |
+
exposure += prob * dt
|
| 327 |
+
sa0, sb0, ttc0 = ev[0]
|
| 328 |
+
min_sample = min(ev, key=lambda e: e[2])
|
| 329 |
+
sa_m, sb_m, ttc_m = min_sample
|
| 330 |
+
vax, vay = _velocity(sa_m)
|
| 331 |
+
vbx, vby = _velocity(sb_m)
|
| 332 |
+
cp = ((sa_m["x"] + sb_m["x"]) / 2 + (vax + vbx) / 2 * ttc_m,
|
| 333 |
+
(sa_m["y"] + sb_m["y"]) / 2 + (vay + vby) / 2 * ttc_m)
|
| 334 |
+
conflicts.append({
|
| 335 |
+
"vehicle_a": traj_a["vehicle_id"],
|
| 336 |
+
"vehicle_b": traj_b["vehicle_id"],
|
| 337 |
+
"conflict_type": _conflict_type(sa_m["heading"], sb_m["heading"], p),
|
| 338 |
+
"t_start_s": round(sa0["t"], 3),
|
| 339 |
+
"t_end_s": round(ev[-1][0]["t"], 3),
|
| 340 |
+
"duration_s": round(ev[-1][0]["t"] - sa0["t"] + dt, 3),
|
| 341 |
+
"min_ttc_s": round(min_ttc, 4),
|
| 342 |
+
"pet_s": None if pet is None else round(pet, 4),
|
| 343 |
+
"max_drac_mps2": None if max_drac_v is None else round(max_drac_v, 4),
|
| 344 |
+
"delta_s_mps": round(delta_s, 4),
|
| 345 |
+
"max_s_mps": round(max_s, 4),
|
| 346 |
+
"collision_probability": round(max_p, 4),
|
| 347 |
+
"probability_exposure_s": round(exposure, 4),
|
| 348 |
+
"csi": round(csi, 6),
|
| 349 |
+
"mass_proxy_reduced": round(mu, 4),
|
| 350 |
+
"conflict_point": [round(cp[0], 2), round(cp[1], 2)],
|
| 351 |
+
})
|
| 352 |
+
return conflicts
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
# ------------------------------------------------------------------ synthetic TRJ data
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def _states(xy_fn, speed_fn, heading: float, duration: float, dt: float) -> List[Dict]:
|
| 359 |
+
out = []
|
| 360 |
+
t = 0.0
|
| 361 |
+
while t <= duration + 1e-9:
|
| 362 |
+
x, y = xy_fn(t)
|
| 363 |
+
out.append({"t": round(t, 4), "x": round(x, 4), "y": round(y, 4),
|
| 364 |
+
"speed": round(max(speed_fn(t), 0.0), 4), "heading": heading})
|
| 365 |
+
t += dt
|
| 366 |
+
return out
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _braking_profile(v0: float, t_brake: float, decel: float):
|
| 370 |
+
"""Position/speed along a 1-D line: constant v0, then braking at decel."""
|
| 371 |
+
t_stop = t_brake + v0 / decel
|
| 372 |
+
|
| 373 |
+
def pos(t: float) -> float:
|
| 374 |
+
if t <= t_brake:
|
| 375 |
+
return v0 * t
|
| 376 |
+
tb = min(t, t_stop) - t_brake
|
| 377 |
+
return v0 * t_brake + v0 * tb - 0.5 * decel * tb ** 2
|
| 378 |
+
|
| 379 |
+
def speed(t: float) -> float:
|
| 380 |
+
if t <= t_brake:
|
| 381 |
+
return v0
|
| 382 |
+
return max(0.0, v0 - decel * (t - t_brake))
|
| 383 |
+
|
| 384 |
+
return pos, speed
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def scenario_crossing(reaction_delay_s: float = 0.6, duration: float = 8.0,
|
| 388 |
+
dt: float = 0.1, seed: int = 0) -> List[Dict]:
|
| 389 |
+
"""Ego approaches a crossing pedestrian; brakes reaction_delay after t_detect=1s."""
|
| 390 |
+
rng = np.random.default_rng(seed)
|
| 391 |
+
v_ego = 12.5 + float(rng.uniform(-0.3, 0.3))
|
| 392 |
+
ped_v = 1.9 + float(rng.uniform(-0.1, 0.1))
|
| 393 |
+
pos, speed = _braking_profile(v_ego, 1.0 + reaction_delay_s, 5.0)
|
| 394 |
+
ego = {"vehicle_id": "ego", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 395 |
+
"states": _states(lambda t: (-42.0 + pos(t), 0.0), speed, 0.0, duration, dt)}
|
| 396 |
+
ped = {"vehicle_id": "ped-1", "vehicle_type": "pedestrian", "length": 0.6, "width": 0.6,
|
| 397 |
+
"states": _states(lambda t: (0.0, -6.5 + ped_v * t), lambda t: ped_v,
|
| 398 |
+
math.pi / 2, duration, dt)}
|
| 399 |
+
return [ego, ped]
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def scenario_rear_end(reaction_delay_s: float = 0.6, duration: float = 8.0,
|
| 403 |
+
dt: float = 0.1, seed: int = 0) -> List[Dict]:
|
| 404 |
+
"""Lead vehicle brakes hard at t=1s; ego brakes reaction_delay later."""
|
| 405 |
+
rng = np.random.default_rng(seed + 100)
|
| 406 |
+
v = 15.0 + float(rng.uniform(-0.4, 0.4))
|
| 407 |
+
lead_pos, lead_speed = _braking_profile(v, 1.0, 6.0)
|
| 408 |
+
ego_pos, ego_speed = _braking_profile(v, 1.0 + reaction_delay_s, 5.5)
|
| 409 |
+
lead = {"vehicle_id": "lead", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 410 |
+
"states": _states(lambda t: (18.0 + lead_pos(t), 0.0), lead_speed, 0.0, duration, dt)}
|
| 411 |
+
ego = {"vehicle_id": "ego", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 412 |
+
"states": _states(lambda t: (0.0 + ego_pos(t), 0.0), ego_speed, 0.0, duration, dt)}
|
| 413 |
+
return [ego, lead]
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def scenario_lane_change(reaction_delay_s: float = 0.6, duration: float = 8.0,
|
| 417 |
+
dt: float = 0.1, seed: int = 0) -> List[Dict]:
|
| 418 |
+
"""Cyclist merges toward the ego lane at a shallow (lane-change) angle."""
|
| 419 |
+
rng = np.random.default_rng(seed + 200)
|
| 420 |
+
v_ego = 11.0 + float(rng.uniform(-0.3, 0.3))
|
| 421 |
+
v_cyc = 4.5
|
| 422 |
+
heading = math.radians(35.0) # oblique merge toward the ego lane
|
| 423 |
+
pos, speed = _braking_profile(v_ego, 0.8 + reaction_delay_s, 4.5)
|
| 424 |
+
ego = {"vehicle_id": "ego", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 425 |
+
"states": _states(lambda t: (-14.0 + pos(t), 0.0), speed, 0.0, duration, dt)}
|
| 426 |
+
cyc = {"vehicle_id": "cyclist-1", "vehicle_type": "cyclist", "length": 1.8, "width": 0.6,
|
| 427 |
+
"states": _states(lambda t: (2.0 + v_cyc * math.cos(heading) * t,
|
| 428 |
+
-3.0 + v_cyc * math.sin(heading) * t),
|
| 429 |
+
lambda t: v_cyc, heading, duration, dt)}
|
| 430 |
+
return [ego, cyc]
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
_SCENARIOS = {
|
| 434 |
+
"crossing": scenario_crossing,
|
| 435 |
+
"rear_end": scenario_rear_end,
|
| 436 |
+
"lane_change": scenario_lane_change,
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def generate_trajectories(seed: int = 0, scenario: str = "mixed",
|
| 441 |
+
reaction_delay_s: float = 0.6,
|
| 442 |
+
duration: float = 8.0, dt: float = 0.1) -> List[Dict]:
|
| 443 |
+
"""Deterministic synthetic TRJ-like trajectory data to exercise the analyzer."""
|
| 444 |
+
if scenario in _SCENARIOS:
|
| 445 |
+
return _SCENARIOS[scenario](reaction_delay_s, duration, dt, seed)
|
| 446 |
+
if scenario != "mixed":
|
| 447 |
+
raise ValueError(f"unknown scenario {scenario!r}; expected one of "
|
| 448 |
+
f"{sorted(_SCENARIOS)} or 'mixed'")
|
| 449 |
+
out: List[Dict] = []
|
| 450 |
+
for name, fn in _SCENARIOS.items():
|
| 451 |
+
for traj in fn(reaction_delay_s, duration, dt, seed):
|
| 452 |
+
traj = dict(traj)
|
| 453 |
+
traj["vehicle_id"] = f"{name}:{traj['vehicle_id']}"
|
| 454 |
+
out.append(traj)
|
| 455 |
+
return out
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
# ------------------------------------------------------------------ run-level CSI
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def reaction_delay_for_model(model_version: str, overrides: Optional[Dict] = None) -> Dict:
|
| 462 |
+
"""Deterministic mapping: perception skill profile -> downstream reaction delay.
|
| 463 |
+
|
| 464 |
+
SIMULATED: a weaker perception stack (higher miss penalties) is modeled as a
|
| 465 |
+
longer effective detection-to-brake delay, which the surrogate-safety
|
| 466 |
+
scenarios convert into more severe conflicts. Marked simulated; the mapping
|
| 467 |
+
is monotonic and seeded by the same model_params used by megaeval runs.
|
| 468 |
+
"""
|
| 469 |
+
from sensorflow.megaeval.runs import model_params
|
| 470 |
+
params = model_params(model_version, overrides or {})
|
| 471 |
+
perception_score = params["base_detect"] - (
|
| 472 |
+
params["night_penalty"] + params["rain_penalty"]
|
| 473 |
+
+ params["occlusion_penalty"] + params["vru_penalty"])
|
| 474 |
+
deficit = float(np.clip(1.0 - perception_score, 0.0, 1.0))
|
| 475 |
+
delay = 0.45 + 1.6 * deficit
|
| 476 |
+
return {"model_version": model_version, "perception_score": round(perception_score, 4),
|
| 477 |
+
"perception_deficit": round(deficit, 4),
|
| 478 |
+
"reaction_delay_s": round(delay, 4), "simulated": True}
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def csi_for_run(run, seeds: Tuple[int, ...] = (0, 1, 2), force: bool = False) -> Dict:
|
| 482 |
+
"""Aggregate CSI for a megaeval evaluation run (cached under runs/safety/ssam/).
|
| 483 |
+
|
| 484 |
+
Runs the three surrogate-safety scenarios (crossing / rear-end / lane-change)
|
| 485 |
+
with the model-conditioned reaction delay and sums CSI across all conflicts.
|
| 486 |
+
"""
|
| 487 |
+
cached = None if force else read_json("ssam", f"{run.run_id}.json")
|
| 488 |
+
if cached is not None:
|
| 489 |
+
return cached
|
| 490 |
+
|
| 491 |
+
reaction = reaction_delay_for_model(run.model_version, run.overrides)
|
| 492 |
+
scenario_rows = []
|
| 493 |
+
total_csi = 0.0
|
| 494 |
+
total_conflicts = 0
|
| 495 |
+
min_ttc = None
|
| 496 |
+
for name in _SCENARIOS:
|
| 497 |
+
for seed in seeds:
|
| 498 |
+
trajs = generate_trajectories(seed=seed, scenario=name,
|
| 499 |
+
reaction_delay_s=reaction["reaction_delay_s"])
|
| 500 |
+
res = analyze_trajectories(trajs)
|
| 501 |
+
agg = res["aggregate"]
|
| 502 |
+
total_csi += agg["aggregate_csi"]
|
| 503 |
+
total_conflicts += agg["num_conflicts"]
|
| 504 |
+
if agg["min_ttc_s"] is not None:
|
| 505 |
+
min_ttc = agg["min_ttc_s"] if min_ttc is None else min(min_ttc, agg["min_ttc_s"])
|
| 506 |
+
scenario_rows.append({"scenario": name, "seed": seed,
|
| 507 |
+
"num_conflicts": agg["num_conflicts"],
|
| 508 |
+
"min_ttc_s": agg["min_ttc_s"],
|
| 509 |
+
"max_drac_mps2": agg["max_drac_mps2"],
|
| 510 |
+
"aggregate_csi": agg["aggregate_csi"]})
|
| 511 |
+
|
| 512 |
+
summary = {
|
| 513 |
+
"run_id": run.run_id,
|
| 514 |
+
"model_version": run.model_version,
|
| 515 |
+
"reaction_model": reaction,
|
| 516 |
+
"scenarios": scenario_rows,
|
| 517 |
+
"total_conflicts": total_conflicts,
|
| 518 |
+
"min_ttc_s": min_ttc,
|
| 519 |
+
"aggregate_csi": round(total_csi, 6),
|
| 520 |
+
"simulated": True,
|
| 521 |
+
"method": "deterministic surrogate-safety scenario suite; CSI = "
|
| 522 |
+
"time-weighted kinetic-energy proxy x collision probability",
|
| 523 |
+
}
|
| 524 |
+
write_json(summary, "ssam", f"{run.run_id}.json")
|
| 525 |
+
return summary
|
sensorflow/safety/store.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filesystem layout + JSON helpers for the safety layer.
|
| 2 |
+
|
| 3 |
+
All safety artifacts persist under runs/safety/ (relocatable for tests via
|
| 4 |
+
set_safety_root, mirroring megaeval's set_mega_root pattern):
|
| 5 |
+
|
| 6 |
+
runs/safety/
|
| 7 |
+
gate_policy.json configurable release-gate thresholds
|
| 8 |
+
odd/{run_id}.json synthetic gap-fill supplements per evaluation run
|
| 9 |
+
ssam/{run_id}.json cached surrogate-safety (CSI) summaries per run
|
| 10 |
+
calibration/status.json latest multi-sensor calibration validation
|
| 11 |
+
discrepancy/{dataset}.json online-vs-offline mining results
|
| 12 |
+
evidence/{run_id}.json/.md safety evidence packages
|
| 13 |
+
scenario_db.json curated scenario database
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import threading
|
| 21 |
+
from typing import Any, Optional
|
| 22 |
+
|
| 23 |
+
SAFETY_ROOT = os.path.join("runs", "safety")
|
| 24 |
+
|
| 25 |
+
_LOCK = threading.RLock()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def set_safety_root(path: str) -> None:
|
| 29 |
+
"""Test hook: relocate all safety-layer storage."""
|
| 30 |
+
global SAFETY_ROOT
|
| 31 |
+
SAFETY_ROOT = str(path)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def safety_path(*parts: str) -> str:
|
| 35 |
+
"""Absolute-ish path under the safety root; parent dirs are created."""
|
| 36 |
+
path = os.path.join(SAFETY_ROOT, *parts)
|
| 37 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 38 |
+
return path
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def read_json(*parts: str) -> Optional[Any]:
|
| 42 |
+
path = os.path.join(SAFETY_ROOT, *parts)
|
| 43 |
+
if not os.path.exists(path):
|
| 44 |
+
return None
|
| 45 |
+
with _LOCK:
|
| 46 |
+
try:
|
| 47 |
+
with open(path) as f:
|
| 48 |
+
return json.load(f)
|
| 49 |
+
except Exception:
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def write_json(payload: Any, *parts: str) -> str:
|
| 54 |
+
path = safety_path(*parts)
|
| 55 |
+
with _LOCK:
|
| 56 |
+
tmp = path + ".tmp"
|
| 57 |
+
with open(tmp, "w") as f:
|
| 58 |
+
json.dump(payload, f, indent=2)
|
| 59 |
+
os.replace(tmp, path)
|
| 60 |
+
return path
|
sensorflow/seqeval/DESIGN.md
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# seqeval — Efficient Sequential Regression Detection: Technical Design
|
| 2 |
+
|
| 3 |
+
**Goal.** Detect small model regressions (~2 percentage points on specific
|
| 4 |
+
classes/conditions) on every model update WITHOUT evaluating the full corpus,
|
| 5 |
+
with statistical validity under (a) sequential looks, (b) clustered
|
| 6 |
+
(non-independent) frames, (c) stratified non-proportional sampling, and
|
| 7 |
+
(d) many simultaneous hypotheses.
|
| 8 |
+
|
| 9 |
+
This document records the design *and the judgment calls*: which candidate
|
| 10 |
+
techniques were adopted, which were rejected, and why. The reference
|
| 11 |
+
implementation lives in this package and is exercised by `tests/test_seqeval/`.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## 1. Problem formulation
|
| 16 |
+
|
| 17 |
+
For each metric m (recall is the driver metric in v1) and each population
|
| 18 |
+
stratum h, let Δ_h = m_candidate(h) − m_baseline(h) measured on the same data.
|
| 19 |
+
|
| 20 |
+
With a **practical-significance margin** δ (default 0.005 = 0.5pp):
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
H0 (no material regression): Δ_h ≥ −δ
|
| 24 |
+
H1 (material regression): Δ_h < −δ
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
plus the separate equivalence-style claim used for PASS: reject `Δ_h ≤ −δ`
|
| 28 |
+
in favor of `Δ_h > −δ`.
|
| 29 |
+
|
| 30 |
+
**Absolute vs relative deltas.** The default driver is **absolute percentage
|
| 31 |
+
points**, for three reasons:
|
| 32 |
+
|
| 33 |
+
1. Safety cost is absolute: a 2pp recall drop means the same number of extra
|
| 34 |
+
missed pedestrians whether baseline recall was 0.98 or 0.80. Relative
|
| 35 |
+
deltas overstate changes on strong baselines (0.98→0.96 is "only −2%
|
| 36 |
+
relative") and understate them on weak ones.
|
| 37 |
+
2. Absolute deltas keep Bernoulli variance algebra and margin semantics simple
|
| 38 |
+
and uniform across strata; relative margins make the null hypothesis depend
|
| 39 |
+
on the (estimated) baseline value, contaminating the test with baseline
|
| 40 |
+
estimation error.
|
| 41 |
+
3. Absolute margins are what promotion policies elsewhere in this platform
|
| 42 |
+
already use (`megaeval.analysis.DEFAULT_PROMOTION_POLICY` is expressed in
|
| 43 |
+
absolute drops), so the gate composes with existing policy language.
|
| 44 |
+
|
| 45 |
+
Relative deltas are still *reported* in every evidence record (`rel_delta`)
|
| 46 |
+
because they are useful for human interpretation, especially for rare classes
|
| 47 |
+
with weak baselines. They are never the decision variable.
|
| 48 |
+
|
| 49 |
+
**Practical vs statistical significance.** With ~320k objects, a 0.05pp drop
|
| 50 |
+
can be "statistically significant" yet operationally meaningless. Hence the
|
| 51 |
+
margin δ: nothing is declared REGRESSION unless the evidence shows the drop
|
| 52 |
+
exceeds δ, and nothing is declared PASS unless the evidence shows any drop is
|
| 53 |
+
smaller than δ. Statistical significance against a point null of exactly zero
|
| 54 |
+
is deliberately not used anywhere.
|
| 55 |
+
|
| 56 |
+
## 2. The statistical unit: clusters, not frames
|
| 57 |
+
|
| 58 |
+
Frames/objects are not independent: objects in one container (scene/track)
|
| 59 |
+
share weather, lighting, scenario, scene-level hardness and — critically —
|
| 60 |
+
correlated model failures (one bad scene produces many misses). Treating
|
| 61 |
+
objects as iid understates variance by the design effect
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
DEFF = 1 + (m̄ − 1) · ICC (m̄ = size-weighted mean cluster size)
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
and every "significant" result computed that way is anti-conservative.
|
| 68 |
+
|
| 69 |
+
Adopted design:
|
| 70 |
+
|
| 71 |
+
* **Inference unit = cluster**: all sampled objects of a stratum in one
|
| 72 |
+
container. Per-cluster mean paired differences (bounded in [−1, 1]) are the
|
| 73 |
+
observations fed to the confidence sequences and e-processes. This is valid
|
| 74 |
+
under *arbitrary* within-cluster correlation — no correlation model is
|
| 75 |
+
assumed — and whole clusters are sampled so the unit is never split
|
| 76 |
+
(`planner.py` selects container groups, not objects).
|
| 77 |
+
* **Diagnostics, not corrections**: ICC (one-way ANOVA estimator), DEFF and
|
| 78 |
+
n_eff = n/DEFF are computed per node (`units.py`) and attached to every
|
| 79 |
+
evidence record. They are reporting artifacts; validity does not depend on
|
| 80 |
+
them because clustering is handled structurally.
|
| 81 |
+
* An interesting empirical note the machinery surfaces: pairing itself removes
|
| 82 |
+
most cluster correlation (the shared scene effect hits both models and
|
| 83 |
+
cancels in d_i), so ICC on paired differences is small (~0.07 in the worked
|
| 84 |
+
example, DEFF ≈ 1.2) even when ICC on raw outcomes is large. We keep the
|
| 85 |
+
cluster unit anyway — the residual correlation is real and free to handle.
|
| 86 |
+
* **Clustered bootstrap** (resampling clusters, not objects) is the prescribed
|
| 87 |
+
fallback for metrics that don't decompose into per-object outcomes (§3). It
|
| 88 |
+
is not needed for the v1 recall driver.
|
| 89 |
+
|
| 90 |
+
Rejected: variance-inflation-only corrections (divide n by an assumed DEFF≈2
|
| 91 |
+
and pretend units are iid). Rejected because ICC varies by stratum and model
|
| 92 |
+
pair; a fixed inflation is either wasteful or invalid, and it composes poorly
|
| 93 |
+
with anytime-valid methods whose guarantees are per-observation.
|
| 94 |
+
|
| 95 |
+
## 3. Paired evaluation as the core design
|
| 96 |
+
|
| 97 |
+
Both models are evaluated on the **same sampled units** (`paired.py`), and all
|
| 98 |
+
inference is on the per-unit difference d_i = c_i − b_i ∈ {−1, 0, +1}.
|
| 99 |
+
|
| 100 |
+
Why this is the single biggest efficiency lever: for two independent samples,
|
| 101 |
+
Var(m̂_c − m̂_b) ��� 2·p(1−p)/n (~0.18/n at p=0.9). For paired evaluation,
|
| 102 |
+
Var(d) = ν − Δ², where ν = P(models disagree) — the discordance rate. Adjacent
|
| 103 |
+
model versions agree on almost everything (measured ν ≈ 0.03–0.06 in the
|
| 104 |
+
harness), so pairing cuts required samples by roughly 3–10×. The paired
|
| 105 |
+
design also removes the between-unit difficulty variance *and* the shared
|
| 106 |
+
scene effects (§2).
|
| 107 |
+
|
| 108 |
+
**McNemar correspondence.** For binary per-object outcomes the information in
|
| 109 |
+
the paired design is exactly the discordant counts: n01 (baseline right,
|
| 110 |
+
candidate wrong) vs n10 (the reverse) — McNemar's setup. We do not use the
|
| 111 |
+
classical McNemar *test* (it is fixed-n); instead the same discordant-pair
|
| 112 |
+
information drives (a) the sequential e-process on d_i and (b) the
|
| 113 |
+
Beta-Binomial posterior of §14, which is literally a Bayesian McNemar. The
|
| 114 |
+
counts n01/n10 are reported in attribution because they are the most
|
| 115 |
+
interpretable summary of "what changed".
|
| 116 |
+
|
| 117 |
+
**Which metrics get which treatment:**
|
| 118 |
+
|
| 119 |
+
| Metric | Per-object outcome? | Sequential treatment |
|
| 120 |
+
|---|---|---|
|
| 121 |
+
| recall (per-GT-object TP/FN) | yes, Bernoulli | confidence sequence / e-process on paired d_i ✔ (v1 driver) |
|
| 122 |
+
| precision (per-prediction) | yes, but the unit set differs per model (each model's own predictions); pair at the cluster level | CS on cluster-level paired precision differences |
|
| 123 |
+
| safety_recall, per-stratum recall | yes | same as recall ✔ |
|
| 124 |
+
| mean IoU (matched pairs) | bounded continuous per object | CS on paired IoU differences (empirical-Bernstein handles [0,1] naturally) |
|
| 125 |
+
| AP / mAP / AUC | **no** — rank statistics over the whole set; not a mean of per-object terms | **paired clustered bootstrap** at checkpoints (resample clusters, recompute both models' AP on the same resample, CI on the difference). No anytime-valid guarantee: schedule at *pre-registered* checkpoints (end of each budget stage) with alpha-spending across the (few, fixed) looks |
|
| 126 |
+
|
| 127 |
+
This split is a deliberate honesty boundary: Bernoulli-style anytime-valid
|
| 128 |
+
treatment is used exactly where a metric is a mean of bounded per-unit terms;
|
| 129 |
+
composite rank metrics get bootstrap at a small number of pre-committed looks
|
| 130 |
+
rather than pretending they support continuous monitoring.
|
| 131 |
+
|
| 132 |
+
## 4. Sampling design: hybrid Neyman + risk floors + safety minimums
|
| 133 |
+
|
| 134 |
+
Candidates considered:
|
| 135 |
+
|
| 136 |
+
* **Proportional allocation** — matches the population, but starves exactly
|
| 137 |
+
the strata we care about (pedestrian|night is ~5% of objects; a proportional
|
| 138 |
+
10k sample gives ~500 units, hopeless for 2pp detection). Rejected as the
|
| 139 |
+
primary rule.
|
| 140 |
+
* **Equal allocation** — maximal per-stratum power for fixed budget, but
|
| 141 |
+
wastes budget on huge easy strata and makes population-level estimates
|
| 142 |
+
needlessly noisy. Rejected.
|
| 143 |
+
* **Risk-weighted only** (as in `megaeval.sampling`) — right instinct, no
|
| 144 |
+
optimality story; can be arbitrarily far from variance-optimal. Rejected as
|
| 145 |
+
the *sole* rule, kept as a multiplier.
|
| 146 |
+
* **Neyman allocation** n_h ∝ N_h·σ_h — variance-optimal for the *population*
|
| 147 |
+
estimate, but per-stratum guarantees are incidental, and σ_h must not come
|
| 148 |
+
from candidate outcomes (bias firewall, §13). Adopted as the baseline using
|
| 149 |
+
a candidate-independent PRIOR variance profile (documented rates by
|
| 150 |
+
class/condition; a pilot baseline-only estimate would also qualify).
|
| 151 |
+
|
| 152 |
+
**Adopted hybrid** (`planner.py`), in order: Neyman shares from prior σ_h →
|
| 153 |
+
multiplicative risk weights (VRU ×1.5, night ×1.3) → mandatory minimum per
|
| 154 |
+
stratum (200) → pre-registered safety-critical strata get a floor sized for
|
| 155 |
+
the target MDE (2500 by default, §10) → truncate at N_h, select whole
|
| 156 |
+
clusters in a seeded shuffled order.
|
| 157 |
+
|
| 158 |
+
**Unbiasedness under oversampling.** Every sampled object carries the
|
| 159 |
+
Horvitz–Thompson weight w_h = N_h/n_h. Population and class-level estimators
|
| 160 |
+
are weight-corrected (weighted cluster means feed the overall/class/difficulty
|
| 161 |
+
nodes), so the classic stratified identity `E[Σ_h W_h p̂_h] = p` holds and
|
| 162 |
+
oversampling safety strata does not bias headline estimates. Per-stratum
|
| 163 |
+
(leaf) inference is self-weighting and needs no correction.
|
| 164 |
+
|
| 165 |
+
## 5. Multi-stage pipeline
|
| 166 |
+
|
| 167 |
+
```
|
| 168 |
+
sanity (≈500 units) → stratified screening (~15% of plan)
|
| 169 |
+
→ sequential confirmation (batched to 100% of plan, anytime-valid)
|
| 170 |
+
→ targeted escalation (reserve clusters, suspect/safety strata only)
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
* **Sanity**: fingerprints resolved, frozen plan hashed, smoke batch checks
|
| 174 |
+
for degenerate outputs (rate ∉ (0.2, 1], discordance ≥ 0.5 ⇒ abort — a
|
| 175 |
+
broken export should fail in seconds, not consume the budget).
|
| 176 |
+
* **Screening** flags "suspect" strata early (e-value ≥ 1.5 — soft evidence,
|
| 177 |
+
*never* a decision) to prioritize escalation later. Screening consumes the
|
| 178 |
+
same frozen plan; it does not select units based on outcomes (§13).
|
| 179 |
+
* **Sequential confirmation** processes the remaining plan in batches; all
|
| 180 |
+
decisions (REGRESSION / PASS) can fire at any batch boundary because the
|
| 181 |
+
underlying processes are anytime-valid.
|
| 182 |
+
* **Escalation** pulls from each stratum's pre-frozen reserve (shuffled at
|
| 183 |
+
plan time) for strata still undecided that are suspect or safety-primary,
|
| 184 |
+
up to a hard cap. Escalation to *full* evaluation of a stratum is the
|
| 185 |
+
terminal step of the same mechanism (the reserve is the rest of the
|
| 186 |
+
stratum).
|
| 187 |
+
|
| 188 |
+
## 6. Sequential methodology: anytime-valid confidence sequences, not CUSUM
|
| 189 |
+
|
| 190 |
+
**Why repeated fixed-sample CIs are wrong.** A 95% CI computed at n has 5%
|
| 191 |
+
miscoverage *at that single n*. Recompute after every batch and stop on the
|
| 192 |
+
first exclusion of the null and the type-I error inflates monotonically —
|
| 193 |
+
by the law of the iterated logarithm the statistic crosses any fixed-n
|
| 194 |
+
boundary infinitely often under the null, so "peek until significant" has
|
| 195 |
+
error probability → 1. Evaluation gates that check after every batch are
|
| 196 |
+
exactly this setting.
|
| 197 |
+
|
| 198 |
+
**Adopted: confidence sequences / e-processes** (`sequential.py`):
|
| 199 |
+
|
| 200 |
+
* Empirical-Bernstein confidence sequence (Waudby-Smith & Ramdas 2023,
|
| 201 |
+
predictable plug-in): a CS valid *simultaneously over all n*, so the gate
|
| 202 |
+
may look after every batch and stop whenever it likes. Empirical-Bernstein
|
| 203 |
+
rather than Hoeffding because paired differences have tiny variance
|
| 204 |
+
(ν ≪ 1/4) and EB width scales with the realized variance; a Hoeffding CS
|
| 205 |
+
would be ~3–4× wider here (Hoeffding rejected for power, kept as a mental
|
| 206 |
+
baseline).
|
| 207 |
+
* One-sided **betting e-processes** for the two directed nulls. The wealth
|
| 208 |
+
process K_t = Π(1 − λ_t(X_t − m)) is a nonnegative supermartingale under
|
| 209 |
+
H0: μ ≥ m for any predictable λ_t in the admissible range; Ville's
|
| 210 |
+
inequality gives P(∃t: K_t ≥ 1/α) ≤ α. Bet sizing = max(empirical-Bernstein
|
| 211 |
+
recipe, aGRAPA/approximate-Kelly λ ≈ drift/(σ̂² + drift²)), clipped —
|
| 212 |
+
sizing affects power only, never validity. E-values (not just CIs) are kept
|
| 213 |
+
because they compose: they feed e-BH in the hierarchy (§8) and multiply
|
| 214 |
+
across independent looks/runs if evidence is ever pooled.
|
| 215 |
+
* Batch-incremental updates, sticky decisions, per-batch trajectory recording
|
| 216 |
+
(`log e vs n` with the 1/α boundary) for the dashboard.
|
| 217 |
+
|
| 218 |
+
**Where CUSUM legitimately fits — and why not here.** CUSUM/Page tests are
|
| 219 |
+
designed for *change-point detection in an ongoing stream*: an unknown time
|
| 220 |
+
at which a monitored process shifts, with guarantees phrased as average run
|
| 221 |
+
length (ARL) to false alarm and detection delay. That is the right tool for
|
| 222 |
+
**production drift monitoring** (a deployed model's live metric degrading at
|
| 223 |
+
an unknown time — a reasonable future addition next to
|
| 224 |
+
`sensorflow/evaluation/regression.py`). It is the wrong tool for **gated A/B
|
| 225 |
+
evaluation**, where there is no change-point: the candidate is a fixed,
|
| 226 |
+
already-different model, the hypothesis is about a fixed mean difference, ARL
|
| 227 |
+
semantics don't map to per-release type-I error, and CUSUM provides neither
|
| 228 |
+
confidence intervals for effect size nor e-values for hierarchical multiple
|
| 229 |
+
testing. CUSUM is therefore explicitly rejected for the gate.
|
| 230 |
+
(Group-sequential designs — O'Brien-Fleming etc. — were also considered:
|
| 231 |
+
valid, but they require pre-committing to a fixed number of looks and a
|
| 232 |
+
maximum n, which fights the budget-staged, stop-any-time controller; the
|
| 233 |
+
anytime-valid framework strictly dominates for this use.)
|
| 234 |
+
|
| 235 |
+
## 7. Mandatory three-outcome decisions
|
| 236 |
+
|
| 237 |
+
Non-detection is NEVER equivalence. Each node ends in exactly one of:
|
| 238 |
+
|
| 239 |
+
* **REGRESSION** — the regression e-process crossed 1/α_allocated: confirmed
|
| 240 |
+
drop beyond the margin. Gate: block.
|
| 241 |
+
* **PASS** — the equivalence e-process crossed 1/α_pass: confirmed that any
|
| 242 |
+
drop is smaller than the margin (this is a one-sided equivalence /
|
| 243 |
+
non-inferiority claim, the sequential analogue of a TOST bound). Gate:
|
| 244 |
+
allow, but only when the overall node AND every pre-registered safety
|
| 245 |
+
primary individually PASS.
|
| 246 |
+
* **INSUFFICIENT_EVIDENCE** — budget exhausted first. Gate: expand within
|
| 247 |
+
budget if possible, else report with explicit language: *"not proven
|
| 248 |
+
equivalent — do not treat as a pass."* (`controller.INSUFFICIENT_LANGUAGE`
|
| 249 |
+
is exactly that sentence; the tiny-budget test asserts it.)
|
| 250 |
+
|
| 251 |
+
## 8. Hierarchical multiple testing
|
| 252 |
+
|
| 253 |
+
Testing ~28 nodes (overall, 6 classes, 18 class×condition strata, 3 difficulty
|
| 254 |
+
bands) at α each would give a family error of ~1−0.95²⁸ ≈ 76%. Naive
|
| 255 |
+
Bonferroni over everything destroys power for the one node that matters.
|
| 256 |
+
Adopted structure (`hierarchy.py`):
|
| 257 |
+
|
| 258 |
+
```
|
| 259 |
+
Level 1 overall α·0.30 single test
|
| 260 |
+
Safety pre-registered primaries α·0.30 split equally, tested individually
|
| 261 |
+
Level 2 class α·0.15 e-BH within level
|
| 262 |
+
Level 3 class × condition strata α·0.15 e-BH within level (non-safety)
|
| 263 |
+
Level 4 difficulty bands α·0.10 e-BH within level
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
Budgets sum to α, so the union bound gives family-wise control across levels;
|
| 267 |
+
within a level, **e-BH** (BH on e-values) controls FDR *under arbitrary
|
| 268 |
+
dependence* — mandatory here because levels re-aggregate the same units and
|
| 269 |
+
strata share scenes. e-BH composes with optional stopping because an
|
| 270 |
+
e-process value at any stopping time is a valid e-value (this is the reason
|
| 271 |
+
the implementation carries e-values and not just CSs).
|
| 272 |
+
|
| 273 |
+
**Gatekeeping and the masking trap.** Classic top-down gatekeeping ("test
|
| 274 |
+
children only where the parent rejected") was **rejected** for the regression
|
| 275 |
+
direction: a 2pp pedestrian-night drop inside a globally *improved* model is
|
| 276 |
+
invisible at level 1, and a gate that only descends on level-1 rejection would
|
| 277 |
+
never see it. Instead every level has its own reserved budget, and
|
| 278 |
+
**safety-critical strata are pre-registered primary hypotheses** with
|
| 279 |
+
dedicated alpha, tested individually and exempt from e-BH masking by
|
| 280 |
+
better-behaved siblings. The power test in the suite is exactly this case:
|
| 281 |
+
overall metric up ~0.3pp, pedestrian-night down 2pp — and the stratum node
|
| 282 |
+
must fire. Alpha propagation is top-down only in the *reporting* sense
|
| 283 |
+
(attribution descends into difficulty bands of regressed strata); decisions
|
| 284 |
+
never require a parent rejection.
|
| 285 |
+
|
| 286 |
+
## 9. Rare-class policy
|
| 287 |
+
|
| 288 |
+
* **Minimum counts**: every non-empty stratum gets ≥ 200 sampled objects
|
| 289 |
+
(or all of N_h if smaller) — below that, even 10pp effects are undetectable
|
| 290 |
+
and the honest answer is INSUFFICIENT_EVIDENCE, which the three-outcome
|
| 291 |
+
logic produces naturally.
|
| 292 |
+
* **Oversampling with weight correction**: rare + risky strata get risk
|
| 293 |
+
multipliers and floors (§4); HT weights keep population estimates unbiased.
|
| 294 |
+
* **Per-class thresholds**: the margin δ and alpha allocation are policy
|
| 295 |
+
fields; safety primaries effectively run at tighter per-node alpha with far
|
| 296 |
+
larger minimum samples. (A per-stratum δ_h map is a straightforward policy
|
| 297 |
+
extension; v1 keeps a single δ for legibility.)
|
| 298 |
+
* **Escalation**: rare strata that end INSUFFICIENT but suspect consume their
|
| 299 |
+
reserve up to the whole stratum. If a stratum is exhausted and still
|
| 300 |
+
undecided, the ledger says so explicitly (stopping_reason
|
| 301 |
+
`escalation_exhausted`) — the decision is a visible budget/detectability
|
| 302 |
+
statement, not silence.
|
| 303 |
+
|
| 304 |
+
## 10. Sample sizes and power (with the design effect)
|
| 305 |
+
|
| 306 |
+
Fixed-n planning numbers for a Bernoulli recall metric at baseline p₀ = 0.9,
|
| 307 |
+
one-sided α = 0.05, power 0.9 ((z₀.₉₅+z₀.₉)² ≈ 8.57). Paired variance uses
|
| 308 |
+
Var(d) ≈ ν − Δ² with measured-typical discordance ν ≈ 0.04 + Δ:
|
| 309 |
+
|
| 310 |
+
| detectable Δ | unpaired, per model arm | paired, iid units | paired, DEFF = 2 | paired, DEFF = 2, anytime (~×1.35) |
|
| 311 |
+
|---:|---:|---:|---:|---:|
|
| 312 |
+
| 1pp | 16,100 | 4,280 | 8,550 | ~11,500 |
|
| 313 |
+
| 2pp | 4,150 | 1,280 | 2,550 | ~3,450 |
|
| 314 |
+
| 5pp | 745 | 300 | 600 | ~810 |
|
| 315 |
+
| 10pp | 214 | 111 | 223 | ~300 |
|
| 316 |
+
|
| 317 |
+
Notes: (i) pairing beats doubling the corpus — the unpaired column is per
|
| 318 |
+
*arm*; (ii) the anytime-valid overhead (~30–40% at these scales, shrinking
|
| 319 |
+
with the e-process's adaptive bets) buys the right to stop at any time, which
|
| 320 |
+
in expectation *saves* samples whenever the effect is larger than the MDE
|
| 321 |
+
(the 10pp test stops ~15× before budget); (iii) measured DEFF on paired
|
| 322 |
+
differences was ≈1.2, not 2 — the table's DEFF=2 column is the conservative
|
| 323 |
+
planning figure requested, the ledger reports the realized one. The default
|
| 324 |
+
safety floor (2,500) is the 2pp row with the realized design effect plus
|
| 325 |
+
head-room.
|
| 326 |
+
|
| 327 |
+
## 11. Compute-budget staging
|
| 328 |
+
|
| 329 |
+
Default policy on a 320k corpus: plan ≈ 25k objects, staged
|
| 330 |
+
`[15%, 40%, 100%]` of plan (≈ 3.8k → 10k → 25k) + escalation reserve, i.e. a
|
| 331 |
+
10K→50K→250K→full ladder scaled to plan size. Advancement rules: advance
|
| 332 |
+
while any node is undecided and budget remains; stop immediately on any
|
| 333 |
+
confirmed REGRESSION (sticky, anytime-valid) or when overall + all safety
|
| 334 |
+
primaries PASS with nothing undecided; escalate only suspect/safety strata;
|
| 335 |
+
terminal state otherwise INSUFFICIENT_EVIDENCE with per-stratum sample
|
| 336 |
+
accounting in the ledger.
|
| 337 |
+
|
| 338 |
+
## 12. Prediction caching
|
| 339 |
+
|
| 340 |
+
Baseline predictions are computed once per (dataset fingerprint, model
|
| 341 |
+
fingerprint) and reused across every candidate update
|
| 342 |
+
(`paired.PredictionCache`, disk + memory). Fingerprints cover everything
|
| 343 |
+
predictions depend on — dataset identity/seed/size, model version + effects
|
| 344 |
+
config — so **invalidation is by construction**: any change produces a new
|
| 345 |
+
key; nothing stale can ever be served. A compute counter proves in tests that
|
| 346 |
+
the second candidate evaluation performs zero baseline recomputation. In a
|
| 347 |
+
real deployment the fingerprint inputs become dataset content hash + model
|
| 348 |
+
checkpoint digest + inference config (thresholds, NMS, quantization), and the
|
| 349 |
+
cache stores per-unit predictions rather than a success vector.
|
| 350 |
+
|
| 351 |
+
## 13. Sampling-bias prevention
|
| 352 |
+
|
| 353 |
+
* The plan is built by a function that **cannot see candidate outcomes** (no
|
| 354 |
+
such parameter exists), from population structure + a prior variance
|
| 355 |
+
profile only, then persisted with seed, config and a content hash over the
|
| 356 |
+
selected and reserve unit ids. The hash is recorded in every run's lineage;
|
| 357 |
+
two candidates with opposite outcomes provably receive byte-identical plans
|
| 358 |
+
(asserted in tests).
|
| 359 |
+
* Escalation draws from a reserve whose order was frozen at plan time, so
|
| 360 |
+
even adaptive *budget* decisions never become adaptive *selection*.
|
| 361 |
+
* **If outcome-adaptive sampling is ever added** (e.g. active sampling of
|
| 362 |
+
high-disagreement regions), estimates must be corrected with
|
| 363 |
+
inverse-propensity weights w_i = 1/π_i under recorded, everywhere-positive
|
| 364 |
+
selection probabilities π_i, and CS validity must be re-established for the
|
| 365 |
+
weighted observations (bounded w_i·d_i, e.g. via truncated propensities).
|
| 366 |
+
Without that correction, adaptive selection biases Δ̂ toward whatever the
|
| 367 |
+
selector chases. v1 deliberately does not do adaptive selection.
|
| 368 |
+
|
| 369 |
+
## 14. Bayesian complement
|
| 370 |
+
|
| 371 |
+
Alongside every frequentist decision, `sequential.py` computes a Beta-Binomial
|
| 372 |
+
posterior P(Δ < −δ | data) from the discordant pairs (θ = P(regression pair |
|
| 373 |
+
discordant) with Beta(1,1) prior; Δ = ν(1−2θ)). Operational comparison:
|
| 374 |
+
|
| 375 |
+
* *For* the posterior: a single monotone number ("99.99% probable this is a
|
| 376 |
+
real regression") that program managers read correctly without training;
|
| 377 |
+
natural for a decision-theoretic cost tradeoff.
|
| 378 |
+
* *Against* (as the gate): posteriors have no frequentist stopping guarantee
|
| 379 |
+
under continuous monitoring without committing to a prior + loss and
|
| 380 |
+
accepting their audit burden; e-values give the same "accumulating
|
| 381 |
+
evidence" narrative *with* an error guarantee any auditor can check
|
| 382 |
+
(e ≥ 1/α), and they compose across strata (e-BH) and across looks by
|
| 383 |
+
multiplication.
|
| 384 |
+
|
| 385 |
+
Verdict: **e-values gate, posterior explains.** Both are in the ledger.
|
| 386 |
+
|
| 387 |
+
## 15. Failure modes and mitigations
|
| 388 |
+
|
| 389 |
+
| Failure mode | Mitigation in this design |
|
| 390 |
+
|---|---|
|
| 391 |
+
| Peeking inflates type-I | everything anytime-valid; fixed-n intervals never gate (§6) |
|
| 392 |
+
| Correlated frames overstate n | cluster inference units; DEFF/n_eff in every record (§2) |
|
| 393 |
+
| Masking: overall improves, stratum regresses | reserved per-level alpha + pre-registered safety primaries (§8) |
|
| 394 |
+
| Multiplicity across strata | hierarchical budgets + e-BH within level (§8) |
|
| 395 |
+
| "No signal" read as "safe" | three-outcome logic; INSUFFICIENT carries "not proven equivalent" (§7) |
|
| 396 |
+
| Outcome-dependent sampling bias | frozen hashed plans, frozen reserves, no adaptive selection (§13) |
|
| 397 |
+
| Rare strata silently undecidable | floors, oversampling+weights, escalation, explicit exhaustion reason (§9) |
|
| 398 |
+
| Stale baseline predictions | fingerprint-keyed cache, invalidation by construction (§12) |
|
| 399 |
+
| Broken candidate export burns budget | sanity smoke stage aborts in ~500 units (§5) |
|
| 400 |
+
| Trivial-magnitude "significance" | practical margin δ in both null hypotheses (§1) |
|
| 401 |
+
| Irreproducible decisions | seeds + fingerprints + plan hash + config in lineage; end-to-end determinism asserted in tests |
|
| 402 |
+
| Metric doesn't decompose per object (mAP) | pre-registered checkpoint paired clustered bootstrap, not fake sequential (§3) |
|
| 403 |
+
|
| 404 |
+
## 16. Final recommendation (defaults shipped in `controller.DEFAULT_POLICY`)
|
| 405 |
+
|
| 406 |
+
Paired, cluster-unit, stratified evaluation against a frozen hybrid
|
| 407 |
+
Neyman/risk-floor plan; empirical-Bernstein confidence sequences + betting
|
| 408 |
+
e-processes with a 0.5pp practical margin; three-outcome decisions; hierarchical
|
| 409 |
+
alpha budget (30% overall / 30% safety primaries / 15% class / 15% stratum /
|
| 410 |
+
10% difficulty) with e-BH within levels; staged budget 15%→40%→100% of a ~25k
|
| 411 |
+
plan with targeted escalation; fingerprint-keyed baseline cache; Beta-Binomial
|
| 412 |
+
posterior as explanatory complement. CUSUM reserved for future production
|
| 413 |
+
drift monitoring, not release gating.
|
| 414 |
+
|
| 415 |
+
## 17. Worked example: planted 2pp pedestrian-night regression (real run)
|
| 416 |
+
|
| 417 |
+
Setup: population `320k` objects / 26,666 containers (megaeval synthetic,
|
| 418 |
+
seed 42). Baseline `model-v41`. Candidate `model-v42` with planted effects
|
| 419 |
+
{pedestrian|night: −2pp, global: +0.4pp} — the masking case. Default policy
|
| 420 |
+
(δ = 0.5pp, α = 0.05, plan target 24k). Ground truth measured on the full
|
| 421 |
+
corpus afterwards: overall Δ = **+0.28pp** (candidate looks *better*
|
| 422 |
+
headline-wise), pedestrian|night Δ = **−1.63pp** realized (N = 17,561,
|
| 423 |
+
baseline recall 0.801).
|
| 424 |
+
|
| 425 |
+
Run (deterministic, reproducible from lineage):
|
| 426 |
+
|
| 427 |
+
* Plan: 25,699 objects frozen across 18 strata; pedestrian|night allocated
|
| 428 |
+
2,501 (floor-driven; weight w = 7.02) + 15,060-object reserve.
|
| 429 |
+
* Decision: **REGRESSION** at 26,705 objects total = **8.4% of the corpus**
|
| 430 |
+
(~12× cheaper than a full paired evaluation), stopping reason
|
| 431 |
+
`regression_confirmed`, during escalation of the suspect safety primary.
|
| 432 |
+
* Pedestrian|night node at stop: n = 3,507 objects in 1,195 clusters
|
| 433 |
+
(ICC 0.065, DEFF 1.20, n_eff 2,923); Δ̂ = −1.94pp,
|
| 434 |
+
95% CS [−3.97pp, −0.48pp]; discordant pairs n01 = 110 vs n10 = 42;
|
| 435 |
+
log e-value **7.50** vs boundary log(1/α_safety) = **4.89**
|
| 436 |
+
(e ≈ 1,808 ≥ 133); Beta-Binomial P(Δ < −δ | data) = 0.99999.
|
| 437 |
+
* Evidence trajectory (n, log e): (129, 1.5) → (1002, 1.6) → (2501, 2.8) →
|
| 438 |
+
(3006, 4.9) → (3507, 7.5) — the dashboard plots exactly this against the
|
| 439 |
+
4.89 boundary.
|
| 440 |
+
* Overall node: Δ̂ = +0.03pp, CS [−0.51pp, +0.38pp] — correctly *not*
|
| 441 |
+
regressed and *not yet* provably equivalent; the hierarchy is what surfaced
|
| 442 |
+
the stratum despite the healthy headline.
|
| 443 |
+
* Ledger: 28 node records, each with the full §15-proof field set; lineage
|
| 444 |
+
carries plan hash, fingerprints, seed and the entire statistical config.
|
| 445 |
+
|
| 446 |
+
The same engine with a null candidate (no planted effect) across 24 Monte
|
| 447 |
+
Carlo repetitions produced **zero** false REGRESSION verdicts (α = 0.05), and
|
| 448 |
+
with a planted 10pp effect stopped at ~2.8k samples — 11% of plan, <1% of the
|
| 449 |
+
corpus (both asserted in `tests/test_seqeval/`).
|
sensorflow/seqeval/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""seqeval — budgeted, anytime-valid sequential regression detection.
|
| 2 |
+
|
| 3 |
+
Detects small (~2pp) per-stratum model regressions without evaluating the full
|
| 4 |
+
corpus, with validity under sequential looks, clustering, stratification and
|
| 5 |
+
multiple comparisons. See DESIGN.md in this package for the full statistical
|
| 6 |
+
design and its justifications.
|
| 7 |
+
|
| 8 |
+
Module map:
|
| 9 |
+
units.py statistical units: clusters, ICC, design effect
|
| 10 |
+
planner.py frozen stratified sampling plans (Neyman + risk floors)
|
| 11 |
+
paired.py paired harness + fingerprint-keyed prediction cache
|
| 12 |
+
sequential.py empirical-Bernstein confidence sequences, e-processes,
|
| 13 |
+
three-outcome decisions, Beta-Binomial complement
|
| 14 |
+
hierarchy.py hierarchical gatekeeping with e-BH within levels
|
| 15 |
+
attribution.py the regression map
|
| 16 |
+
ledger.py machine-readable evidence records + lineage
|
| 17 |
+
controller.py sanity -> screening -> sequential -> escalation state machine
|
| 18 |
+
api.py REST surface under /api/seqeval
|
| 19 |
+
|
| 20 |
+
Entry point for the safety Regression Gate:
|
| 21 |
+
from sensorflow.seqeval import evaluate_regression
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from sensorflow.seqeval.controller import evaluate_regression # noqa: F401
|
sensorflow/seqeval/api.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI router for the sequential regression-detection engine.
|
| 2 |
+
|
| 3 |
+
Endpoints (prefix /api/seqeval):
|
| 4 |
+
POST /runs start a sequential regression evaluation
|
| 5 |
+
GET /runs list runs
|
| 6 |
+
GET /runs/{id} state: stage, samples vs budget, per-node
|
| 7 |
+
confidence sequences, decisions, and the
|
| 8 |
+
sequential-evidence trajectories (points +
|
| 9 |
+
stopping boundaries) for the dashboard chart
|
| 10 |
+
GET /runs/{id}/evidence machine-readable evidence ledger + lineage
|
| 11 |
+
GET /runs/{id}/attribution the regression map
|
| 12 |
+
GET /policy default statistical policy (documented)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Dict, Optional
|
| 18 |
+
|
| 19 |
+
from fastapi import APIRouter, HTTPException
|
| 20 |
+
from pydantic import BaseModel, Field
|
| 21 |
+
|
| 22 |
+
from sensorflow.seqeval import hierarchy as hierarchy_mod
|
| 23 |
+
from sensorflow.seqeval import ledger as ledger_mod
|
| 24 |
+
from sensorflow.seqeval.controller import (DEFAULT_POLICY, get_seqeval_store,
|
| 25 |
+
merged_policy)
|
| 26 |
+
|
| 27 |
+
router = APIRouter(prefix="/api/seqeval")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ModelSpec(BaseModel):
|
| 31 |
+
model_version: str
|
| 32 |
+
effects: Dict[str, float] = Field(
|
| 33 |
+
default_factory=dict,
|
| 34 |
+
description="Per-stratum detection deltas, e.g. {'pedestrian|night': -0.02};"
|
| 35 |
+
" '__global__' applies everywhere (synthetic model harness).")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class StartRunRequest(BaseModel):
|
| 39 |
+
population_id: str
|
| 40 |
+
baseline: ModelSpec
|
| 41 |
+
candidate: ModelSpec
|
| 42 |
+
policy: Dict = Field(default_factory=dict)
|
| 43 |
+
seed: Optional[int] = None
|
| 44 |
+
sync: bool = Field(True, description="Run to completion before returning "
|
| 45 |
+
"(seconds); false = background thread.")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@router.post("/runs")
|
| 49 |
+
def start_run(req: StartRunRequest):
|
| 50 |
+
store = get_seqeval_store()
|
| 51 |
+
try:
|
| 52 |
+
run = store.create_run(req.population_id, req.baseline.model_dump(),
|
| 53 |
+
req.candidate.model_dump(),
|
| 54 |
+
policy=req.policy or None, seed=req.seed)
|
| 55 |
+
except KeyError as e:
|
| 56 |
+
raise HTTPException(404, str(e))
|
| 57 |
+
if req.sync:
|
| 58 |
+
store.execute_sync(run)
|
| 59 |
+
else:
|
| 60 |
+
store.start_async(run)
|
| 61 |
+
return run.to_dict(include_trajectories=False)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/runs")
|
| 65 |
+
def list_runs():
|
| 66 |
+
return {"runs": get_seqeval_store().list_states()}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _state_or_404(run_id: str, include_trajectories: bool = True) -> Dict:
|
| 70 |
+
state = get_seqeval_store().get_state(run_id, include_trajectories=include_trajectories)
|
| 71 |
+
if state is None:
|
| 72 |
+
raise HTTPException(404, f"Unknown seqeval run {run_id}")
|
| 73 |
+
return state
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/runs/{run_id}")
|
| 77 |
+
def get_run(run_id: str):
|
| 78 |
+
return _state_or_404(run_id)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("/runs/{run_id}/evidence")
|
| 82 |
+
def get_evidence(run_id: str):
|
| 83 |
+
_state_or_404(run_id, include_trajectories=False)
|
| 84 |
+
ledger = ledger_mod.EvidenceLedger(run_id)
|
| 85 |
+
return {"run_id": run_id, "records": ledger.records(),
|
| 86 |
+
"lineage": ledger.lineage(),
|
| 87 |
+
"required_fields": ledger_mod.REQUIRED_FIELDS}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/runs/{run_id}/attribution")
|
| 91 |
+
def get_attribution(run_id: str):
|
| 92 |
+
state = _state_or_404(run_id, include_trajectories=False)
|
| 93 |
+
store = get_seqeval_store()
|
| 94 |
+
run = store.runs.get(run_id)
|
| 95 |
+
if run is not None and run.attribution is not None:
|
| 96 |
+
att = run.attribution
|
| 97 |
+
else:
|
| 98 |
+
att = state.get("attribution")
|
| 99 |
+
if att is None:
|
| 100 |
+
raise HTTPException(409, f"Run {run_id} has no attribution yet "
|
| 101 |
+
f"(status={state.get('status')})")
|
| 102 |
+
return {"run_id": run_id, "decision": state.get("decision"),
|
| 103 |
+
"gate": state.get("gate"), **att}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.get("/policy")
|
| 107 |
+
def get_policy():
|
| 108 |
+
return {
|
| 109 |
+
"default_policy": merged_policy(),
|
| 110 |
+
"decision_semantics": {
|
| 111 |
+
"REGRESSION": "block: anytime-valid evidence of a drop beyond the "
|
| 112 |
+
"practical margin in at least one tested node",
|
| 113 |
+
"PASS": "allow: overall + all pre-registered safety primaries proven "
|
| 114 |
+
"within the margin (equivalence-style claim)",
|
| 115 |
+
"INSUFFICIENT_EVIDENCE": "expand budget or report: NOT proven "
|
| 116 |
+
"equivalent; never treated as a pass",
|
| 117 |
+
},
|
| 118 |
+
"test_method": ledger_mod.TEST_METHOD,
|
| 119 |
+
"multiple_testing_method": hierarchy_mod.MULTIPLE_TESTING_METHOD,
|
| 120 |
+
"notes": [
|
| 121 |
+
"sampling plans are frozen (hashed) before candidate outcomes are seen",
|
| 122 |
+
"inference units are container clusters, not frames/objects",
|
| 123 |
+
"baseline predictions are cached by dataset+model fingerprints",
|
| 124 |
+
],
|
| 125 |
+
}
|
sensorflow/seqeval/attribution.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression attribution: turn node states into a decision-first regression map.
|
| 2 |
+
|
| 3 |
+
On detection the question changes from "is there a regression?" to "WHERE and
|
| 4 |
+
HOW BIG?". Every node with data yields a row carrying the affected class /
|
| 5 |
+
condition / difficulty band, baseline and candidate values, absolute and
|
| 6 |
+
relative deltas, the anytime-valid CI, raw and effective sample sizes, the
|
| 7 |
+
e-values and the decision. The regression map is the REGRESSION-decided subset
|
| 8 |
+
sorted by magnitude — the payload a release manager (or the safety Regression
|
| 9 |
+
Gate) acts on.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Dict, List
|
| 15 |
+
|
| 16 |
+
from sensorflow.seqeval.hierarchy import LEVELS, HierarchyController
|
| 17 |
+
from sensorflow.seqeval.sequential import DECISION_REGRESSION
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def node_row(node, alpha_allocated: float) -> Dict:
|
| 21 |
+
t = node.test
|
| 22 |
+
clustering = node.clustering()
|
| 23 |
+
base_rate = t.sum_baseline / t.n_objects if t.n_objects else None
|
| 24 |
+
cand_rate = t.sum_candidate / t.n_objects if t.n_objects else None
|
| 25 |
+
abs_delta = None if base_rate is None else cand_rate - base_rate
|
| 26 |
+
rel_delta = (None if not base_rate else abs_delta / base_rate)
|
| 27 |
+
lo, hi = t.delta_interval()
|
| 28 |
+
return {
|
| 29 |
+
"node": node.key,
|
| 30 |
+
"level": node.level,
|
| 31 |
+
"level_name": LEVELS[node.level],
|
| 32 |
+
"safety_primary": node.safety_primary,
|
| 33 |
+
"suspect": node.suspect,
|
| 34 |
+
"metric": "recall",
|
| 35 |
+
"n": t.n_objects,
|
| 36 |
+
"n_clusters": clustering["n_clusters"],
|
| 37 |
+
"n_effective": clustering["n_effective"],
|
| 38 |
+
"icc": clustering["icc"],
|
| 39 |
+
"design_effect": clustering["design_effect"],
|
| 40 |
+
"baseline_value": None if base_rate is None else round(base_rate, 5),
|
| 41 |
+
"candidate_value": None if cand_rate is None else round(cand_rate, 5),
|
| 42 |
+
"abs_delta": None if abs_delta is None else round(abs_delta, 5),
|
| 43 |
+
"rel_delta": None if rel_delta is None else round(rel_delta, 5),
|
| 44 |
+
"delta_ci": [round(lo, 5), round(hi, 5)],
|
| 45 |
+
"e_regression": round(t.e_reg.e_value, 4),
|
| 46 |
+
"e_pass": round(t.e_pass.e_value, 4),
|
| 47 |
+
"bayes_p_regression": t.bayes_p_regression(),
|
| 48 |
+
"alpha_allocated": round(alpha_allocated, 6),
|
| 49 |
+
"decision": t.decision,
|
| 50 |
+
"decided_at_n": t.decided_at_n,
|
| 51 |
+
"discordant_pairs": {"regressions_n01": t.n01, "improvements_n10": t.n10},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_attribution(hier: HierarchyController) -> Dict:
|
| 56 |
+
rows: List[Dict] = []
|
| 57 |
+
for node in hier.nodes.values():
|
| 58 |
+
if node.test.n_objects == 0:
|
| 59 |
+
continue
|
| 60 |
+
rows.append(node_row(node, hier.alpha_for(node)))
|
| 61 |
+
regressions = [r for r in rows if r["decision"] == DECISION_REGRESSION]
|
| 62 |
+
regressions.sort(key=lambda r: r["abs_delta"] if r["abs_delta"] is not None else 0.0)
|
| 63 |
+
affected_strata = [r["node"] for r in regressions if r["level"] == 3]
|
| 64 |
+
return {
|
| 65 |
+
"regression_map": regressions,
|
| 66 |
+
"affected_strata": affected_strata,
|
| 67 |
+
"all_nodes": sorted(rows, key=lambda r: (r["level"], r["node"])),
|
| 68 |
+
"note": ("regression_map lists nodes with a confirmed anytime-valid "
|
| 69 |
+
"regression, most severe first; deltas are candidate minus "
|
| 70 |
+
"baseline in absolute metric points"),
|
| 71 |
+
}
|
sensorflow/seqeval/controller.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression-controller state machine: sanity -> screening -> sequential
|
| 2 |
+
confirmation -> targeted escalation, with CI/CD-style gate outcomes.
|
| 3 |
+
|
| 4 |
+
Outcomes (three-outcome by construction, never "no news is good news"):
|
| 5 |
+
REGRESSION -> gate "block": evidence + affected strata attached.
|
| 6 |
+
PASS -> gate "allow": overall AND every pre-registered
|
| 7 |
+
safety primary reached the equivalence-style claim
|
| 8 |
+
(drop confidently smaller than the margin).
|
| 9 |
+
INSUFFICIENT_EVIDENCE -> gate "expand_or_report": budget exhausted before
|
| 10 |
+
either claim; reported with explicit
|
| 11 |
+
"not proven equivalent" language.
|
| 12 |
+
|
| 13 |
+
`evaluate_regression(...)` at the bottom is the clean entry point the safety
|
| 14 |
+
Regression Gate (sensorflow/safety/) can call; the REST surface lives in
|
| 15 |
+
api.py.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import hashlib
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import threading
|
| 24 |
+
import uuid
|
| 25 |
+
from typing import Dict, List, Optional
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
from sensorflow.megaeval import population as pop_mod
|
| 30 |
+
from sensorflow.seqeval import attribution as attribution_mod
|
| 31 |
+
from sensorflow.seqeval import hierarchy as hierarchy_mod
|
| 32 |
+
from sensorflow.seqeval import ledger as ledger_mod
|
| 33 |
+
from sensorflow.seqeval import paired as paired_mod
|
| 34 |
+
from sensorflow.seqeval import planner as planner_mod
|
| 35 |
+
from sensorflow.seqeval.sequential import (DECISION_INSUFFICIENT, DECISION_PASS,
|
| 36 |
+
DECISION_REGRESSION, approx_mde)
|
| 37 |
+
|
| 38 |
+
DEFAULT_POLICY: Dict = {
|
| 39 |
+
"metric": "recall",
|
| 40 |
+
"delta_margin": 0.005, # practical-significance margin, absolute points
|
| 41 |
+
"alpha": 0.05, # family alpha (regression direction)
|
| 42 |
+
"alpha_pass": 0.05, # per-required-node equivalence alpha
|
| 43 |
+
"alpha_shares": dict(hierarchy_mod.DEFAULT_ALPHA_SHARES),
|
| 44 |
+
"condition_dim": "lighting",
|
| 45 |
+
"safety_primaries": ["pedestrian|night", "cyclist|night"],
|
| 46 |
+
"target_n": 24_000,
|
| 47 |
+
"min_per_stratum": 200,
|
| 48 |
+
"safety_floor": 2500,
|
| 49 |
+
"stage_fractions": [0.15, 0.40, 1.0], # screening, confirmation, full plan
|
| 50 |
+
"batches_per_stage": 3,
|
| 51 |
+
"screening_e_threshold": 1.5,
|
| 52 |
+
"stop_on_regression": True,
|
| 53 |
+
"escalation": {"enabled": True, "max_extra_per_stratum": 4000,
|
| 54 |
+
"batch_objects": 500},
|
| 55 |
+
"plan_seed": 12345,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
GATE_FOR_DECISION = {
|
| 59 |
+
DECISION_REGRESSION: "block",
|
| 60 |
+
DECISION_PASS: "allow",
|
| 61 |
+
DECISION_INSUFFICIENT: "expand_or_report",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
INSUFFICIENT_LANGUAGE = ("Budget exhausted before either claim could be made: the "
|
| 65 |
+
"candidate is NOT PROVEN EQUIVALENT to the baseline within "
|
| 66 |
+
"the practical margin, and no regression beyond the margin "
|
| 67 |
+
"was confirmed. Do not treat this as a pass.")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def merged_policy(overrides: Optional[Dict] = None) -> Dict:
|
| 71 |
+
policy = json.loads(json.dumps(DEFAULT_POLICY))
|
| 72 |
+
for k, v in (overrides or {}).items():
|
| 73 |
+
if isinstance(v, dict) and isinstance(policy.get(k), dict):
|
| 74 |
+
policy[k].update(v)
|
| 75 |
+
else:
|
| 76 |
+
policy[k] = v
|
| 77 |
+
return policy
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class SequentialRegressionRun:
|
| 81 |
+
def __init__(self, population_id: str, baseline: paired_mod.SimulatedModel,
|
| 82 |
+
candidate: paired_mod.SimulatedModel, policy: Optional[Dict] = None,
|
| 83 |
+
seed: Optional[int] = None):
|
| 84 |
+
self.run_id = f"seq-{uuid.uuid4().hex[:10]}"
|
| 85 |
+
self.population_id = population_id
|
| 86 |
+
self.baseline = baseline
|
| 87 |
+
self.candidate = candidate
|
| 88 |
+
self.policy = merged_policy(policy)
|
| 89 |
+
if seed is None:
|
| 90 |
+
seed = int(hashlib.sha256(
|
| 91 |
+
f"{population_id}|{baseline.fingerprint()}|{candidate.fingerprint()}"
|
| 92 |
+
.encode()).hexdigest()[:8], 16)
|
| 93 |
+
self.seed = int(seed)
|
| 94 |
+
self.status = "created" # created|running|done|failed
|
| 95 |
+
self.stage = "pending" # sanity|screening|sequential|escalation|done
|
| 96 |
+
self.decision = DECISION_INSUFFICIENT
|
| 97 |
+
self.stopping_reason: Optional[str] = None
|
| 98 |
+
self.error: Optional[str] = None
|
| 99 |
+
self.created_at = ledger_mod.now_iso()
|
| 100 |
+
self.finished_at: Optional[str] = None
|
| 101 |
+
self.samples_used = 0
|
| 102 |
+
self.escalation_used = 0
|
| 103 |
+
self.sanity: Dict = {}
|
| 104 |
+
self.plan: Optional[Dict] = None
|
| 105 |
+
self.hier: Optional[hierarchy_mod.HierarchyController] = None
|
| 106 |
+
self.attribution: Optional[Dict] = None
|
| 107 |
+
self.ledger = ledger_mod.EvidenceLedger(self.run_id)
|
| 108 |
+
self.message: Optional[str] = None
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------- state
|
| 111 |
+
|
| 112 |
+
def gate(self) -> str:
|
| 113 |
+
return GATE_FOR_DECISION[self.decision]
|
| 114 |
+
|
| 115 |
+
def to_dict(self, include_trajectories: bool = True) -> Dict:
|
| 116 |
+
nodes = []
|
| 117 |
+
trajectories = {}
|
| 118 |
+
if self.hier is not None:
|
| 119 |
+
for key, node in self.hier.nodes.items():
|
| 120 |
+
if node.test.n_objects == 0:
|
| 121 |
+
continue
|
| 122 |
+
snap = node.test.snapshot()
|
| 123 |
+
snap.update({"node": key, "level": node.level,
|
| 124 |
+
"safety_primary": node.safety_primary,
|
| 125 |
+
"suspect": node.suspect,
|
| 126 |
+
"alpha_allocated": round(self.hier.alpha_for(node), 6),
|
| 127 |
+
"e_threshold_regression": round(1.0 / self.hier.alpha_for(node), 2),
|
| 128 |
+
"e_threshold_pass": round(1.0 / self.hier.alpha_pass, 2)})
|
| 129 |
+
nodes.append(snap)
|
| 130 |
+
if include_trajectories:
|
| 131 |
+
trajectories[key] = {
|
| 132 |
+
"points": node.test.trajectory,
|
| 133 |
+
"boundaries": {
|
| 134 |
+
"log_e_regression": round(float(np.log(1.0 / self.hier.alpha_for(node))), 4),
|
| 135 |
+
"log_e_pass": round(float(np.log(1.0 / self.hier.alpha_pass)), 4),
|
| 136 |
+
"delta_margin": -self.policy["delta_margin"],
|
| 137 |
+
},
|
| 138 |
+
}
|
| 139 |
+
plan_summary = None
|
| 140 |
+
if self.plan is not None:
|
| 141 |
+
plan_summary = {k: v for k, v in self.plan.items() if k != "_arrays"}
|
| 142 |
+
out = {
|
| 143 |
+
"run_id": self.run_id,
|
| 144 |
+
"population_id": self.population_id,
|
| 145 |
+
"baseline": self.baseline.to_dict(),
|
| 146 |
+
"candidate": self.candidate.to_dict(),
|
| 147 |
+
"status": self.status,
|
| 148 |
+
"stage": self.stage,
|
| 149 |
+
"decision": self.decision,
|
| 150 |
+
"gate": self.gate(),
|
| 151 |
+
"message": self.message,
|
| 152 |
+
"stopping_reason": self.stopping_reason,
|
| 153 |
+
"error": self.error,
|
| 154 |
+
"seed": self.seed,
|
| 155 |
+
"policy": self.policy,
|
| 156 |
+
"sanity": self.sanity,
|
| 157 |
+
"budget": {
|
| 158 |
+
"planned_total": plan_summary["total_allocated"] if plan_summary else None,
|
| 159 |
+
"samples_used": self.samples_used,
|
| 160 |
+
"escalation_used": self.escalation_used,
|
| 161 |
+
"full_population": plan_summary["population_objects"] if plan_summary else None,
|
| 162 |
+
"fraction_of_population": (round(self.samples_used / plan_summary["population_objects"], 5)
|
| 163 |
+
if plan_summary else None),
|
| 164 |
+
},
|
| 165 |
+
"plan": plan_summary,
|
| 166 |
+
"nodes": sorted(nodes, key=lambda r: (r["level"], r["node"])),
|
| 167 |
+
"created_at": self.created_at,
|
| 168 |
+
"finished_at": self.finished_at,
|
| 169 |
+
}
|
| 170 |
+
if include_trajectories:
|
| 171 |
+
out["trajectories"] = trajectories
|
| 172 |
+
return out
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class SeqevalStore:
|
| 176 |
+
def __init__(self):
|
| 177 |
+
self.lock = threading.RLock()
|
| 178 |
+
self.runs: Dict[str, SequentialRegressionRun] = {}
|
| 179 |
+
self._persisted: Dict[str, Dict] = {}
|
| 180 |
+
self._load_persisted()
|
| 181 |
+
|
| 182 |
+
def _load_persisted(self) -> None:
|
| 183 |
+
base = os.path.join(ledger_mod.seq_root(), "runs")
|
| 184 |
+
if not os.path.isdir(base):
|
| 185 |
+
return
|
| 186 |
+
for rid in sorted(os.listdir(base)):
|
| 187 |
+
path = os.path.join(base, rid, "run.json")
|
| 188 |
+
if not os.path.exists(path):
|
| 189 |
+
continue
|
| 190 |
+
try:
|
| 191 |
+
with open(path) as f:
|
| 192 |
+
self._persisted[rid] = json.load(f)
|
| 193 |
+
except Exception:
|
| 194 |
+
continue
|
| 195 |
+
|
| 196 |
+
def create_run(self, population_id: str, baseline: Dict, candidate: Dict,
|
| 197 |
+
policy: Optional[Dict] = None, seed: Optional[int] = None
|
| 198 |
+
) -> SequentialRegressionRun:
|
| 199 |
+
if pop_mod.load_meta(population_id) is None:
|
| 200 |
+
raise KeyError(f"Unknown population {population_id}")
|
| 201 |
+
run = SequentialRegressionRun(
|
| 202 |
+
population_id,
|
| 203 |
+
paired_mod.SimulatedModel(baseline.get("model_version", "baseline"),
|
| 204 |
+
baseline.get("effects")),
|
| 205 |
+
paired_mod.SimulatedModel(candidate.get("model_version", "candidate"),
|
| 206 |
+
candidate.get("effects")),
|
| 207 |
+
policy=policy, seed=seed)
|
| 208 |
+
with self.lock:
|
| 209 |
+
self.runs[run.run_id] = run
|
| 210 |
+
return run
|
| 211 |
+
|
| 212 |
+
def get_state(self, run_id: str, include_trajectories: bool = True) -> Optional[Dict]:
|
| 213 |
+
with self.lock:
|
| 214 |
+
run = self.runs.get(run_id)
|
| 215 |
+
if run is not None:
|
| 216 |
+
return run.to_dict(include_trajectories=include_trajectories)
|
| 217 |
+
state = self._persisted.get(run_id)
|
| 218 |
+
if state is not None and not include_trajectories:
|
| 219 |
+
state = {k: v for k, v in state.items() if k != "trajectories"}
|
| 220 |
+
return state
|
| 221 |
+
|
| 222 |
+
def list_states(self) -> List[Dict]:
|
| 223 |
+
seen = set()
|
| 224 |
+
out = []
|
| 225 |
+
with self.lock:
|
| 226 |
+
for run in self.runs.values():
|
| 227 |
+
seen.add(run.run_id)
|
| 228 |
+
out.append(run.to_dict(include_trajectories=False))
|
| 229 |
+
for rid, state in self._persisted.items():
|
| 230 |
+
if rid not in seen:
|
| 231 |
+
out.append({k: v for k, v in state.items() if k != "trajectories"})
|
| 232 |
+
out.sort(key=lambda s: s.get("created_at") or "", reverse=True)
|
| 233 |
+
return out
|
| 234 |
+
|
| 235 |
+
def save_run(self, run: SequentialRegressionRun) -> None:
|
| 236 |
+
d = ledger_mod.run_dir(run.run_id)
|
| 237 |
+
os.makedirs(d, exist_ok=True)
|
| 238 |
+
with open(os.path.join(d, "run.json"), "w") as f:
|
| 239 |
+
json.dump(run.to_dict(include_trajectories=True), f)
|
| 240 |
+
|
| 241 |
+
def start_async(self, run: SequentialRegressionRun) -> None:
|
| 242 |
+
threading.Thread(target=self.execute_sync, args=(run,), daemon=True).start()
|
| 243 |
+
|
| 244 |
+
# ---------------------------------------------------------------- engine
|
| 245 |
+
|
| 246 |
+
def execute_sync(self, run: SequentialRegressionRun) -> None:
|
| 247 |
+
try:
|
| 248 |
+
run.status = "running"
|
| 249 |
+
meta = pop_mod.load_meta(run.population_id)
|
| 250 |
+
policy = run.policy
|
| 251 |
+
|
| 252 |
+
# ---- stage: sanity + frozen plan ----------------------------
|
| 253 |
+
run.stage = "sanity"
|
| 254 |
+
plan = planner_mod.build_plan(
|
| 255 |
+
meta, target_n=policy["target_n"], seed=policy["plan_seed"],
|
| 256 |
+
condition_dim=policy["condition_dim"],
|
| 257 |
+
safety_primaries=policy["safety_primaries"],
|
| 258 |
+
min_per_stratum=policy["min_per_stratum"],
|
| 259 |
+
safety_floor=policy["safety_floor"])
|
| 260 |
+
run.plan = plan
|
| 261 |
+
frame = paired_mod.load_frame(meta)
|
| 262 |
+
|
| 263 |
+
all_ids = np.concatenate([plan["_arrays"][f"s{s}_ids"]
|
| 264 |
+
for s in sorted(int(k) for k in plan["strata"])])
|
| 265 |
+
smoke = all_ids[:min(500, all_ids.size)]
|
| 266 |
+
outcomes = paired_mod.paired_outcomes(meta, run.baseline, run.candidate,
|
| 267 |
+
smoke, policy["condition_dim"])
|
| 268 |
+
b_rate = float(outcomes["baseline"].mean())
|
| 269 |
+
c_rate = float(outcomes["candidate"].mean())
|
| 270 |
+
discordance = float(np.mean(outcomes["baseline"] != outcomes["candidate"]))
|
| 271 |
+
run.sanity = {
|
| 272 |
+
"plan_hash": plan["plan_hash"],
|
| 273 |
+
"dataset_fingerprint": plan["dataset_fingerprint"],
|
| 274 |
+
"baseline_fingerprint": run.baseline.fingerprint(),
|
| 275 |
+
"candidate_fingerprint": run.candidate.fingerprint(),
|
| 276 |
+
"smoke_n": int(smoke.size),
|
| 277 |
+
"smoke_baseline_rate": round(b_rate, 4),
|
| 278 |
+
"smoke_candidate_rate": round(c_rate, 4),
|
| 279 |
+
"smoke_discordance": round(discordance, 4),
|
| 280 |
+
"ok": bool(0.2 < b_rate <= 1.0 and 0.2 < c_rate <= 1.0
|
| 281 |
+
and discordance < 0.5),
|
| 282 |
+
}
|
| 283 |
+
if not run.sanity["ok"]:
|
| 284 |
+
run.decision = DECISION_INSUFFICIENT
|
| 285 |
+
run.stopping_reason = "sanity_failed"
|
| 286 |
+
run.message = ("Sanity stage failed (degenerate outcome rates or "
|
| 287 |
+
"excessive discordance); evaluation aborted. "
|
| 288 |
+
+ INSUFFICIENT_LANGUAGE)
|
| 289 |
+
self._finalize(run)
|
| 290 |
+
return
|
| 291 |
+
|
| 292 |
+
# ---- build hierarchy + per-stratum cluster iterators ---------
|
| 293 |
+
n_cond = len(pop_mod.DIMENSIONS[policy["condition_dim"]])
|
| 294 |
+
hier = hierarchy_mod.HierarchyController(
|
| 295 |
+
plan["strata"], n_cond=n_cond, delta=policy["delta_margin"],
|
| 296 |
+
alpha=policy["alpha"], alpha_pass=policy["alpha_pass"],
|
| 297 |
+
alpha_shares=policy["alpha_shares"],
|
| 298 |
+
band_names=planner_mod.BAND_NAMES)
|
| 299 |
+
run.hier = hier
|
| 300 |
+
|
| 301 |
+
clusters = {int(s): planner_mod.stratum_clusters(plan, int(s))
|
| 302 |
+
for s in plan["strata"]}
|
| 303 |
+
reserves = {int(s): planner_mod.stratum_clusters(plan, int(s), reserve=True)
|
| 304 |
+
for s in plan["strata"]}
|
| 305 |
+
pointers = {s: 0 for s in clusters}
|
| 306 |
+
taken_objects = {s: 0 for s in clusters}
|
| 307 |
+
weights = {int(s): float(v["weight"]) for s, v in plan["strata"].items()}
|
| 308 |
+
key_to_sid = {f"stratum:{v['key']}": int(s) for s, v in plan["strata"].items()}
|
| 309 |
+
bands = np.digitize(frame["difficulty"],
|
| 310 |
+
np.asarray(planner_mod.DIFFICULTY_BANDS)).astype(np.int8)
|
| 311 |
+
|
| 312 |
+
def make_batch(chunks: List) -> Optional[Dict[str, np.ndarray]]:
|
| 313 |
+
"""chunks: list of (stratum_id, [cluster object_id arrays])."""
|
| 314 |
+
sids, ids = [], []
|
| 315 |
+
for s, cl in chunks:
|
| 316 |
+
for arr in cl:
|
| 317 |
+
ids.append(arr)
|
| 318 |
+
sids.append(np.full(arr.size, s, dtype=np.int64))
|
| 319 |
+
if not ids:
|
| 320 |
+
return None
|
| 321 |
+
oid = np.concatenate(ids)
|
| 322 |
+
sid = np.concatenate(sids)
|
| 323 |
+
oc = paired_mod.paired_outcomes(meta, run.baseline, run.candidate,
|
| 324 |
+
oid, policy["condition_dim"])
|
| 325 |
+
return {
|
| 326 |
+
"object_id": oid,
|
| 327 |
+
"container_id": frame["container_id"][oid],
|
| 328 |
+
"class": frame["class"][oid].astype(np.int64),
|
| 329 |
+
"cond": frame[policy["condition_dim"]][oid].astype(np.int64),
|
| 330 |
+
"band": bands[oid].astype(np.int64),
|
| 331 |
+
"weight": np.asarray([weights[s] for s in sid], dtype=np.float64),
|
| 332 |
+
"d": oc["d"], "b": oc["baseline"], "c": oc["candidate"],
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
def process(chunks: List) -> None:
|
| 336 |
+
batch = make_batch(chunks)
|
| 337 |
+
if batch is None:
|
| 338 |
+
return
|
| 339 |
+
hier.update_with_batch(batch)
|
| 340 |
+
hier.evaluate(policy["screening_e_threshold"])
|
| 341 |
+
hier.record_trajectories()
|
| 342 |
+
run.samples_used += int(batch["d"].size)
|
| 343 |
+
|
| 344 |
+
def take_until(s: int, target: int) -> List[np.ndarray]:
|
| 345 |
+
out = []
|
| 346 |
+
while pointers[s] < len(clusters[s]) and taken_objects[s] < target:
|
| 347 |
+
arr = clusters[s][pointers[s]]
|
| 348 |
+
out.append(arr)
|
| 349 |
+
taken_objects[s] += arr.size
|
| 350 |
+
pointers[s] += 1
|
| 351 |
+
return out
|
| 352 |
+
|
| 353 |
+
stopped = False
|
| 354 |
+
|
| 355 |
+
# ---- stages: screening then sequential confirmation ----------
|
| 356 |
+
for stage_idx, frac in enumerate(policy["stage_fractions"]):
|
| 357 |
+
run.stage = "screening" if stage_idx == 0 else "sequential"
|
| 358 |
+
for sub in range(policy["batches_per_stage"]):
|
| 359 |
+
prev_frac = (policy["stage_fractions"][stage_idx - 1]
|
| 360 |
+
if stage_idx > 0 else 0.0)
|
| 361 |
+
eff_frac = prev_frac + (frac - prev_frac) * (sub + 1) / policy["batches_per_stage"]
|
| 362 |
+
chunks = []
|
| 363 |
+
for s in clusters:
|
| 364 |
+
target = int(np.ceil(eff_frac * plan["strata"][str(s)]["allocated"]))
|
| 365 |
+
cl = take_until(s, target)
|
| 366 |
+
if cl:
|
| 367 |
+
chunks.append((s, cl))
|
| 368 |
+
process(chunks)
|
| 369 |
+
if policy["stop_on_regression"] and hier.any_regression():
|
| 370 |
+
run.stopping_reason = "regression_confirmed"
|
| 371 |
+
stopped = True
|
| 372 |
+
break
|
| 373 |
+
if hier.required_pass() and not hier.undecided_keys():
|
| 374 |
+
run.stopping_reason = "all_passed"
|
| 375 |
+
stopped = True
|
| 376 |
+
break
|
| 377 |
+
if stopped:
|
| 378 |
+
break
|
| 379 |
+
|
| 380 |
+
# ---- targeted escalation -------------------------------------
|
| 381 |
+
esc = policy["escalation"]
|
| 382 |
+
if not stopped and esc.get("enabled", True):
|
| 383 |
+
run.stage = "escalation"
|
| 384 |
+
for key in list(hier.undecided_keys()):
|
| 385 |
+
node = hier.nodes.get(key)
|
| 386 |
+
if node is None or node.level != 3:
|
| 387 |
+
continue
|
| 388 |
+
if not (node.suspect or node.safety_primary):
|
| 389 |
+
continue
|
| 390 |
+
s = key_to_sid.get(key)
|
| 391 |
+
if s is None:
|
| 392 |
+
continue
|
| 393 |
+
pool = reserves.get(s, [])
|
| 394 |
+
ptr, extra = 0, 0
|
| 395 |
+
while (ptr < len(pool) and extra < esc["max_extra_per_stratum"]
|
| 396 |
+
and node.test.decision == DECISION_INSUFFICIENT):
|
| 397 |
+
chunk, size = [], 0
|
| 398 |
+
while ptr < len(pool) and size < esc["batch_objects"]:
|
| 399 |
+
chunk.append(pool[ptr])
|
| 400 |
+
size += pool[ptr].size
|
| 401 |
+
ptr += 1
|
| 402 |
+
if not chunk:
|
| 403 |
+
break
|
| 404 |
+
process([(s, chunk)])
|
| 405 |
+
extra += size
|
| 406 |
+
run.escalation_used += size
|
| 407 |
+
if policy["stop_on_regression"] and hier.any_regression():
|
| 408 |
+
run.stopping_reason = "regression_confirmed"
|
| 409 |
+
stopped = True
|
| 410 |
+
break
|
| 411 |
+
if stopped:
|
| 412 |
+
break
|
| 413 |
+
|
| 414 |
+
# ---- finalize -------------------------------------------------
|
| 415 |
+
if run.stopping_reason is None:
|
| 416 |
+
run.stopping_reason = ("escalation_exhausted"
|
| 417 |
+
if esc.get("enabled", True) else "budget_exhausted")
|
| 418 |
+
if hier.any_regression():
|
| 419 |
+
run.decision = DECISION_REGRESSION
|
| 420 |
+
run.message = "Anytime-valid regression confirmed; see attribution."
|
| 421 |
+
elif hier.required_pass():
|
| 422 |
+
run.decision = DECISION_PASS
|
| 423 |
+
run.message = ("Overall and all pre-registered safety primaries proved "
|
| 424 |
+
"equivalent within the practical margin.")
|
| 425 |
+
else:
|
| 426 |
+
run.decision = DECISION_INSUFFICIENT
|
| 427 |
+
run.message = INSUFFICIENT_LANGUAGE
|
| 428 |
+
run.attribution = attribution_mod.build_attribution(hier)
|
| 429 |
+
self._write_ledger(run)
|
| 430 |
+
self._finalize(run)
|
| 431 |
+
except Exception as e: # pragma: no cover - defensive
|
| 432 |
+
run.status = "failed"
|
| 433 |
+
run.error = str(e)
|
| 434 |
+
run.finished_at = ledger_mod.now_iso()
|
| 435 |
+
try:
|
| 436 |
+
self.save_run(run)
|
| 437 |
+
except Exception:
|
| 438 |
+
pass
|
| 439 |
+
|
| 440 |
+
# ---------------------------------------------------------------- output
|
| 441 |
+
|
| 442 |
+
def _write_ledger(self, run: SequentialRegressionRun) -> None:
|
| 443 |
+
hier = run.hier
|
| 444 |
+
for node in hier.nodes.values():
|
| 445 |
+
t = node.test
|
| 446 |
+
if t.n_objects == 0:
|
| 447 |
+
continue
|
| 448 |
+
clustering = node.clustering()
|
| 449 |
+
nu = (t.n01 + t.n10) / t.n_objects if t.n_objects else 0.0
|
| 450 |
+
est = t.delta_estimate() or 0.0
|
| 451 |
+
var_d = max(nu - est ** 2, 1e-6)
|
| 452 |
+
base_rate = t.sum_baseline / t.n_objects
|
| 453 |
+
cand_rate = t.sum_candidate / t.n_objects
|
| 454 |
+
lo, hi = t.delta_interval()
|
| 455 |
+
alpha_alloc = hier.alpha_for(node)
|
| 456 |
+
run.ledger.append({
|
| 457 |
+
"run_id": run.run_id,
|
| 458 |
+
"baseline_model": run.baseline.to_dict(),
|
| 459 |
+
"candidate_model": run.candidate.to_dict(),
|
| 460 |
+
"dataset_version": run.population_id,
|
| 461 |
+
"seed": run.seed,
|
| 462 |
+
"stratum": node.key,
|
| 463 |
+
"level": node.level,
|
| 464 |
+
"metric": run.policy["metric"],
|
| 465 |
+
"baseline_value": round(base_rate, 6),
|
| 466 |
+
"candidate_value": round(cand_rate, 6),
|
| 467 |
+
"abs_delta": round(cand_rate - base_rate, 6),
|
| 468 |
+
"rel_delta": (round((cand_rate - base_rate) / base_rate, 6)
|
| 469 |
+
if base_rate else None),
|
| 470 |
+
"delta_ci_low": round(lo, 6),
|
| 471 |
+
"delta_ci_high": round(hi, 6),
|
| 472 |
+
"mde_abs": approx_mde(var_d, clustering["n_effective"],
|
| 473 |
+
run.policy["delta_margin"], alpha_alloc),
|
| 474 |
+
"n": t.n_objects,
|
| 475 |
+
"n_effective": clustering["n_effective"],
|
| 476 |
+
"n_clusters": clustering["n_clusters"],
|
| 477 |
+
"design_effect": clustering["design_effect"],
|
| 478 |
+
"confidence_level": 1.0 - run.policy["alpha"],
|
| 479 |
+
"e_value": round(t.e_reg.e_value, 6),
|
| 480 |
+
"p_analogue": round(t.e_reg.p_analogue(), 8),
|
| 481 |
+
"bayes_p_regression": t.bayes_p_regression(),
|
| 482 |
+
"decision": t.decision,
|
| 483 |
+
"test_method": ledger_mod.TEST_METHOD,
|
| 484 |
+
"multiple_testing_method": hierarchy_mod.MULTIPLE_TESTING_METHOD,
|
| 485 |
+
"practical_margin": run.policy["delta_margin"],
|
| 486 |
+
"alpha_allocated": round(alpha_alloc, 6),
|
| 487 |
+
"stopping_reason": run.stopping_reason,
|
| 488 |
+
"timestamp": ledger_mod.now_iso(),
|
| 489 |
+
})
|
| 490 |
+
run.ledger.write_lineage({
|
| 491 |
+
"run_id": run.run_id,
|
| 492 |
+
"dataset_version": run.population_id,
|
| 493 |
+
"dataset_fingerprint": run.sanity.get("dataset_fingerprint"),
|
| 494 |
+
"plan_id": run.plan["plan_id"],
|
| 495 |
+
"plan_hash": run.plan["plan_hash"],
|
| 496 |
+
"seed": run.seed,
|
| 497 |
+
"statistical_config": run.policy,
|
| 498 |
+
"baseline_model": run.baseline.to_dict(),
|
| 499 |
+
"candidate_model": run.candidate.to_dict(),
|
| 500 |
+
})
|
| 501 |
+
|
| 502 |
+
def _finalize(self, run: SequentialRegressionRun) -> None:
|
| 503 |
+
run.status = "done"
|
| 504 |
+
run.stage = "done"
|
| 505 |
+
run.finished_at = ledger_mod.now_iso()
|
| 506 |
+
self.save_run(run)
|
| 507 |
+
# best-effort integration with platform accounting + audit trail
|
| 508 |
+
try:
|
| 509 |
+
from sensorflow.evaluation.process_units import ProcessMeter
|
| 510 |
+
from sensorflow.evaluation.records import get_store as get_legacy_store
|
| 511 |
+
legacy = get_legacy_store()
|
| 512 |
+
ProcessMeter(legacy, run.run_id).record("regression_tracking",
|
| 513 |
+
run.samples_used)
|
| 514 |
+
legacy.audit("seqeval_decision", "seqeval_run", run.run_id,
|
| 515 |
+
detail=f"{run.decision} ({run.stopping_reason}); "
|
| 516 |
+
f"samples={run.samples_used}")
|
| 517 |
+
legacy.save()
|
| 518 |
+
except Exception:
|
| 519 |
+
pass
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
_STORE: Optional[SeqevalStore] = None
|
| 523 |
+
_STORE_LOCK = threading.Lock()
|
| 524 |
+
|
| 525 |
+
|
| 526 |
+
def get_seqeval_store() -> SeqevalStore:
|
| 527 |
+
global _STORE
|
| 528 |
+
with _STORE_LOCK:
|
| 529 |
+
if _STORE is None:
|
| 530 |
+
_STORE = SeqevalStore()
|
| 531 |
+
return _STORE
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def reset_seqeval_store() -> None:
|
| 535 |
+
global _STORE
|
| 536 |
+
with _STORE_LOCK:
|
| 537 |
+
_STORE = None
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def evaluate_regression(population_id: str, baseline: Dict, candidate: Dict,
|
| 541 |
+
policy: Optional[Dict] = None, seed: Optional[int] = None
|
| 542 |
+
) -> Dict:
|
| 543 |
+
"""Clean entry point for the safety Regression Gate.
|
| 544 |
+
|
| 545 |
+
baseline/candidate: {"model_version": str, "effects": {stratum: pp_delta}}.
|
| 546 |
+
Returns a gate verdict with the evidence needed to block or allow:
|
| 547 |
+
{gate, decision, stopping_reason, samples_used, affected_strata,
|
| 548 |
+
regression_map, run_id, message}.
|
| 549 |
+
"""
|
| 550 |
+
store = get_seqeval_store()
|
| 551 |
+
run = store.create_run(population_id, baseline, candidate, policy=policy, seed=seed)
|
| 552 |
+
store.execute_sync(run)
|
| 553 |
+
att = run.attribution or {"regression_map": [], "affected_strata": []}
|
| 554 |
+
return {
|
| 555 |
+
"run_id": run.run_id,
|
| 556 |
+
"gate": run.gate(),
|
| 557 |
+
"decision": run.decision,
|
| 558 |
+
"message": run.message,
|
| 559 |
+
"stopping_reason": run.stopping_reason,
|
| 560 |
+
"samples_used": run.samples_used,
|
| 561 |
+
"planned_total": run.plan["total_allocated"] if run.plan else None,
|
| 562 |
+
"full_population": run.plan["population_objects"] if run.plan else None,
|
| 563 |
+
"affected_strata": att["affected_strata"],
|
| 564 |
+
"regression_map": att["regression_map"],
|
| 565 |
+
}
|
sensorflow/seqeval/hierarchy.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hierarchical gatekeeping multiple-testing controller.
|
| 2 |
+
|
| 3 |
+
Four levels of hypotheses, each with its own alpha budget (budgets sum to the
|
| 4 |
+
family alpha, so the union bound gives family-wise control across levels):
|
| 5 |
+
|
| 6 |
+
L1 overall population delta (single test)
|
| 7 |
+
L2 per-class deltas (e-BH within level)
|
| 8 |
+
L3 per class x condition strata (e-BH within level)
|
| 9 |
+
L4 difficulty bands (easy/medium/hard) (e-BH within level)
|
| 10 |
+
|
| 11 |
+
Within a level, discoveries are controlled with e-BH (Benjamini-Hochberg on
|
| 12 |
+
e-values, Wang & Ramdas 2022): sort e-values descending and reject the k
|
| 13 |
+
hypotheses with e_(k) >= M / (k * alpha_level). e-BH is valid under ARBITRARY
|
| 14 |
+
dependence — essential here because the levels aggregate the same units and
|
| 15 |
+
the strata share scenes — and composes cleanly with optional stopping because
|
| 16 |
+
an e-process value at any stopping time is a valid e-value.
|
| 17 |
+
|
| 18 |
+
Safety-critical strata are PRE-REGISTERED PRIMARIES: each gets its own reserved
|
| 19 |
+
alpha and is tested individually, outside e-BH. They can therefore never be
|
| 20 |
+
masked by an improving overall metric or by many well-behaved sibling strata
|
| 21 |
+
(this is what makes the "overall improved, pedestrian-night regressed" case
|
| 22 |
+
detectable by construction).
|
| 23 |
+
|
| 24 |
+
Deltas are candidate-minus-baseline, so all rejection decisions are REGRESSION
|
| 25 |
+
claims; PASS is the separate equivalence-style claim per node (e_pass at the
|
| 26 |
+
node's pass alpha). Decisions are sticky: an anytime-valid rejection stays
|
| 27 |
+
rejected as more data arrive.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
from typing import Dict, List, Optional
|
| 33 |
+
|
| 34 |
+
import numpy as np
|
| 35 |
+
|
| 36 |
+
from sensorflow.seqeval import units as units_mod
|
| 37 |
+
from sensorflow.seqeval.sequential import (DECISION_INSUFFICIENT, DECISION_PASS,
|
| 38 |
+
DECISION_REGRESSION, PairedSequentialTest)
|
| 39 |
+
|
| 40 |
+
LEVELS = {1: "overall", 2: "class", 3: "stratum", 4: "difficulty"}
|
| 41 |
+
|
| 42 |
+
# Default alpha budget shares (of the family alpha).
|
| 43 |
+
DEFAULT_ALPHA_SHARES = {
|
| 44 |
+
"overall": 0.30,
|
| 45 |
+
"safety": 0.30, # split equally among pre-registered safety primaries
|
| 46 |
+
"class": 0.15,
|
| 47 |
+
"stratum": 0.15,
|
| 48 |
+
"difficulty": 0.10,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
MULTIPLE_TESTING_METHOD = ("hierarchical alpha budgeting (L1 overall / L2 class / "
|
| 52 |
+
"L3 stratum / L4 difficulty) with e-BH within levels; "
|
| 53 |
+
"safety primaries pre-registered at reserved alpha")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class Node:
|
| 57 |
+
def __init__(self, key: str, level: int, delta: float, alpha_family: float,
|
| 58 |
+
cls_code: Optional[int] = None, cond_code: Optional[int] = None,
|
| 59 |
+
band: Optional[int] = None, weighted: bool = False,
|
| 60 |
+
safety_primary: bool = False):
|
| 61 |
+
self.key = key
|
| 62 |
+
self.level = level
|
| 63 |
+
self.cls_code = cls_code
|
| 64 |
+
self.cond_code = cond_code
|
| 65 |
+
self.band = band
|
| 66 |
+
self.weighted = weighted
|
| 67 |
+
self.safety_primary = safety_primary
|
| 68 |
+
self.test = PairedSequentialTest(delta=delta, alpha=alpha_family)
|
| 69 |
+
self.suspect = False
|
| 70 |
+
# clustering diagnostics (accumulated per batch)
|
| 71 |
+
self._d_values: List[np.ndarray] = []
|
| 72 |
+
self._d_clusters: List[np.ndarray] = []
|
| 73 |
+
|
| 74 |
+
def select(self, batch: Dict[str, np.ndarray]) -> np.ndarray:
|
| 75 |
+
mask = np.ones(batch["d"].size, dtype=bool)
|
| 76 |
+
if self.cls_code is not None:
|
| 77 |
+
mask &= batch["class"] == self.cls_code
|
| 78 |
+
if self.cond_code is not None:
|
| 79 |
+
mask &= batch["cond"] == self.cond_code
|
| 80 |
+
if self.band is not None:
|
| 81 |
+
mask &= batch["band"] == self.band
|
| 82 |
+
return mask
|
| 83 |
+
|
| 84 |
+
def update(self, batch: Dict[str, np.ndarray]) -> None:
|
| 85 |
+
mask = self.select(batch)
|
| 86 |
+
if not mask.any():
|
| 87 |
+
return
|
| 88 |
+
d = batch["d"][mask]
|
| 89 |
+
containers = batch["container_id"][mask]
|
| 90 |
+
w = batch["weight"][mask] if self.weighted else None
|
| 91 |
+
means, _ = units_mod.cluster_units(d, containers, weights=w)
|
| 92 |
+
self.test.update_clusters(means)
|
| 93 |
+
self.test.record_objects(batch["b"][mask], batch["c"][mask])
|
| 94 |
+
self._d_values.append(d)
|
| 95 |
+
self._d_clusters.append(containers)
|
| 96 |
+
|
| 97 |
+
def clustering(self) -> Dict:
|
| 98 |
+
if not self._d_values:
|
| 99 |
+
return {"n": 0, "n_clusters": 0, "icc": 0.0, "design_effect": 1.0,
|
| 100 |
+
"n_effective": 0.0, "mean_cluster_size": 0.0}
|
| 101 |
+
return units_mod.cluster_summary(np.concatenate(self._d_values),
|
| 102 |
+
np.concatenate(self._d_clusters))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def e_bh_reject(e_values: Dict[str, float], alpha: float) -> List[str]:
|
| 106 |
+
"""e-BH: reject the k hypotheses with largest e-values where
|
| 107 |
+
e_(k) >= M / (k * alpha). Valid under arbitrary dependence."""
|
| 108 |
+
if not e_values or alpha <= 0:
|
| 109 |
+
return []
|
| 110 |
+
items = sorted(e_values.items(), key=lambda kv: -kv[1])
|
| 111 |
+
m = len(items)
|
| 112 |
+
k_star = 0
|
| 113 |
+
for k, (_, e) in enumerate(items, start=1):
|
| 114 |
+
if e >= m / (k * alpha):
|
| 115 |
+
k_star = k
|
| 116 |
+
return [key for key, _ in items[:k_star]]
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class HierarchyController:
|
| 120 |
+
def __init__(self, strata: Dict[str, Dict], n_cond: int, delta: float,
|
| 121 |
+
alpha: float, alpha_pass: float,
|
| 122 |
+
alpha_shares: Optional[Dict[str, float]] = None,
|
| 123 |
+
band_names: Optional[List[str]] = None):
|
| 124 |
+
self.alpha = float(alpha)
|
| 125 |
+
self.alpha_pass = float(alpha_pass)
|
| 126 |
+
self.shares = {**DEFAULT_ALPHA_SHARES, **(alpha_shares or {})}
|
| 127 |
+
self.nodes: Dict[str, Node] = {}
|
| 128 |
+
band_names = band_names or ["easy", "medium", "hard"]
|
| 129 |
+
|
| 130 |
+
self.nodes["overall"] = Node("overall", 1, delta, alpha, weighted=True)
|
| 131 |
+
classes = sorted({(int(s) // n_cond, v["class"]) for s, v in strata.items()})
|
| 132 |
+
for code, name in classes:
|
| 133 |
+
self.nodes[f"class:{name}"] = Node(f"class:{name}", 2, delta, alpha,
|
| 134 |
+
cls_code=code, weighted=True)
|
| 135 |
+
self.safety_keys: List[str] = []
|
| 136 |
+
for s, v in strata.items():
|
| 137 |
+
s = int(s)
|
| 138 |
+
key = f"stratum:{v['key']}"
|
| 139 |
+
node = Node(key, 3, delta, alpha, cls_code=s // n_cond,
|
| 140 |
+
cond_code=s % n_cond, safety_primary=v.get("safety_primary", False))
|
| 141 |
+
self.nodes[key] = node
|
| 142 |
+
if node.safety_primary:
|
| 143 |
+
self.safety_keys.append(key)
|
| 144 |
+
for b, name in enumerate(band_names):
|
| 145 |
+
self.nodes[f"difficulty:{name}"] = Node(f"difficulty:{name}", 4, delta,
|
| 146 |
+
alpha, band=b, weighted=True)
|
| 147 |
+
|
| 148 |
+
# ---- alpha allocation ------------------------------------------------
|
| 149 |
+
|
| 150 |
+
def alpha_for(self, node: Node) -> float:
|
| 151 |
+
a = self.alpha
|
| 152 |
+
if node.safety_primary:
|
| 153 |
+
return a * self.shares["safety"] / max(len(self.safety_keys), 1)
|
| 154 |
+
if node.level == 1:
|
| 155 |
+
return a * self.shares["overall"]
|
| 156 |
+
if node.level == 2:
|
| 157 |
+
return a * self.shares["class"] # level budget, shared via e-BH
|
| 158 |
+
if node.level == 3:
|
| 159 |
+
return a * self.shares["stratum"]
|
| 160 |
+
return a * self.shares["difficulty"]
|
| 161 |
+
|
| 162 |
+
# ---- updates -----------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
def update_with_batch(self, batch: Dict[str, np.ndarray]) -> None:
|
| 165 |
+
for node in self.nodes.values():
|
| 166 |
+
node.update(batch)
|
| 167 |
+
|
| 168 |
+
def evaluate(self, screening_e_threshold: float = 1.5) -> Dict[str, str]:
|
| 169 |
+
"""Apply per-level testing; return {node_key: decision}. Sticky."""
|
| 170 |
+
# pre-registered primaries + single-hypothesis levels: individual tests
|
| 171 |
+
for node in self.nodes.values():
|
| 172 |
+
if node.safety_primary or node.level == 1:
|
| 173 |
+
node.test.evaluate(alpha_reg=self.alpha_for(node),
|
| 174 |
+
alpha_pass=self.alpha_pass)
|
| 175 |
+
# e-BH within L2 / L3(non-safety) / L4
|
| 176 |
+
for level, share_key in ((2, "class"), (3, "stratum"), (4, "difficulty")):
|
| 177 |
+
group = {k: n for k, n in self.nodes.items()
|
| 178 |
+
if n.level == level and not n.safety_primary}
|
| 179 |
+
undecided = {k: n.test.e_reg.e_value for k, n in group.items()
|
| 180 |
+
if n.test.decision == DECISION_INSUFFICIENT}
|
| 181 |
+
already = {k: n.test.e_reg.e_value for k, n in group.items()
|
| 182 |
+
if n.test.decision == DECISION_REGRESSION}
|
| 183 |
+
# include already-rejected e-values so e-BH stays monotone/sticky
|
| 184 |
+
rejected = e_bh_reject({**undecided, **already},
|
| 185 |
+
self.alpha * self.shares[share_key])
|
| 186 |
+
for k in rejected:
|
| 187 |
+
node = group[k]
|
| 188 |
+
if node.test.decision == DECISION_INSUFFICIENT:
|
| 189 |
+
node.test.decision = DECISION_REGRESSION
|
| 190 |
+
node.test.decided_at_n = node.test.n_objects
|
| 191 |
+
# PASS claims are per-node equivalence statements
|
| 192 |
+
for k, n in group.items():
|
| 193 |
+
if n.test.decision == DECISION_INSUFFICIENT \
|
| 194 |
+
and n.test.e_pass.e_value >= 1.0 / self.alpha_pass:
|
| 195 |
+
n.test.decision = DECISION_PASS
|
| 196 |
+
n.test.decided_at_n = n.test.n_objects
|
| 197 |
+
# screening flags (soft evidence, drives escalation priority)
|
| 198 |
+
for node in self.nodes.values():
|
| 199 |
+
if node.test.e_reg.e_value >= screening_e_threshold:
|
| 200 |
+
node.suspect = True
|
| 201 |
+
return {k: n.test.decision for k, n in self.nodes.items()}
|
| 202 |
+
|
| 203 |
+
def record_trajectories(self) -> None:
|
| 204 |
+
for node in self.nodes.values():
|
| 205 |
+
if node.test.n_objects > 0:
|
| 206 |
+
node.test.record_trajectory_point()
|
| 207 |
+
|
| 208 |
+
# ---- summaries -----------------------------------------------------
|
| 209 |
+
|
| 210 |
+
def decisions(self) -> Dict[str, str]:
|
| 211 |
+
return {k: n.test.decision for k, n in self.nodes.items()}
|
| 212 |
+
|
| 213 |
+
def any_regression(self) -> bool:
|
| 214 |
+
return any(n.test.decision == DECISION_REGRESSION for n in self.nodes.values())
|
| 215 |
+
|
| 216 |
+
def required_pass(self) -> bool:
|
| 217 |
+
"""Overall + every safety primary must individually reach PASS."""
|
| 218 |
+
required = [self.nodes["overall"]] + [self.nodes[k] for k in self.safety_keys]
|
| 219 |
+
return all(n.test.decision == DECISION_PASS for n in required)
|
| 220 |
+
|
| 221 |
+
def undecided_keys(self) -> List[str]:
|
| 222 |
+
return [k for k, n in self.nodes.items()
|
| 223 |
+
if n.test.n_objects > 0 and n.test.decision == DECISION_INSUFFICIENT]
|
sensorflow/seqeval/ledger.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Machine-readable evidence ledger + seqeval storage root.
|
| 2 |
+
|
| 3 |
+
Every stratum/node decision is persisted as one append-only JSONL record with
|
| 4 |
+
every field an auditor needs to reproduce the decision: models, dataset
|
| 5 |
+
version, seed, stratum, metric, baseline/candidate values, absolute+relative
|
| 6 |
+
deltas, MDE at the achieved n, raw and effective sample sizes, confidence
|
| 7 |
+
level, e-value and its p-analogue, the decision, the test and multiple-testing
|
| 8 |
+
methods, the stopping reason and a timestamp. A lineage record (evaluator
|
| 9 |
+
version + full statistical config) accompanies each run.
|
| 10 |
+
|
| 11 |
+
Layout:
|
| 12 |
+
runs/seqeval/
|
| 13 |
+
plans/{plan_id}.json|.npz frozen sampling plans (planner.py)
|
| 14 |
+
cache/{dataset_fp}-{model_fp}.npz prediction cache (paired.py)
|
| 15 |
+
runs/{run_id}/run.json controller state
|
| 16 |
+
runs/{run_id}/ledger.jsonl evidence records
|
| 17 |
+
runs/{run_id}/lineage.json evaluator + statistical config lineage
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import threading
|
| 25 |
+
from datetime import datetime, timezone
|
| 26 |
+
from typing import Dict, List, Optional
|
| 27 |
+
|
| 28 |
+
SEQEVAL_VERSION = "seqeval-1.0.0"
|
| 29 |
+
|
| 30 |
+
_SEQ_ROOT = os.path.join("runs", "seqeval")
|
| 31 |
+
_ROOT_LOCK = threading.Lock()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def seq_root() -> str:
|
| 35 |
+
return _SEQ_ROOT
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def set_seqeval_root(path: str) -> None:
|
| 39 |
+
"""Test hook: relocate all seqeval storage."""
|
| 40 |
+
global _SEQ_ROOT
|
| 41 |
+
with _ROOT_LOCK:
|
| 42 |
+
_SEQ_ROOT = str(path)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def run_dir(run_id: str) -> str:
|
| 46 |
+
return os.path.join(seq_root(), "runs", run_id)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def now_iso() -> str:
|
| 50 |
+
return datetime.now(timezone.utc).isoformat()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
REQUIRED_FIELDS = [
|
| 54 |
+
"baseline_model", "candidate_model", "dataset_version", "seed",
|
| 55 |
+
"stratum", "level", "metric",
|
| 56 |
+
"baseline_value", "candidate_value", "abs_delta", "rel_delta",
|
| 57 |
+
"delta_ci_low", "delta_ci_high", "mde_abs",
|
| 58 |
+
"n", "n_effective", "n_clusters", "design_effect",
|
| 59 |
+
"confidence_level", "e_value", "p_analogue", "bayes_p_regression",
|
| 60 |
+
"decision", "test_method", "multiple_testing_method",
|
| 61 |
+
"practical_margin", "alpha_allocated",
|
| 62 |
+
"stopping_reason", "timestamp",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
TEST_METHOD = ("paired cluster-mean empirical-Bernstein confidence sequence + "
|
| 66 |
+
"one-sided betting e-process (anytime-valid)")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class EvidenceLedger:
|
| 70 |
+
"""Append-only JSONL evidence store for one controller run."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, run_id: str):
|
| 73 |
+
self.run_id = run_id
|
| 74 |
+
self.dir = run_dir(run_id)
|
| 75 |
+
os.makedirs(self.dir, exist_ok=True)
|
| 76 |
+
self.path = os.path.join(self.dir, "ledger.jsonl")
|
| 77 |
+
self._lock = threading.Lock()
|
| 78 |
+
|
| 79 |
+
def append(self, record: Dict) -> Dict:
|
| 80 |
+
missing = [f for f in REQUIRED_FIELDS if f not in record]
|
| 81 |
+
if missing:
|
| 82 |
+
raise ValueError(f"Evidence record missing fields: {missing}")
|
| 83 |
+
record = {**record, "record_type": record.get("record_type", "node_decision")}
|
| 84 |
+
with self._lock:
|
| 85 |
+
with open(self.path, "a") as f:
|
| 86 |
+
f.write(json.dumps(record) + "\n")
|
| 87 |
+
return record
|
| 88 |
+
|
| 89 |
+
def records(self) -> List[Dict]:
|
| 90 |
+
if not os.path.exists(self.path):
|
| 91 |
+
return []
|
| 92 |
+
out = []
|
| 93 |
+
with open(self.path) as f:
|
| 94 |
+
for line in f:
|
| 95 |
+
line = line.strip()
|
| 96 |
+
if line:
|
| 97 |
+
out.append(json.loads(line))
|
| 98 |
+
return out
|
| 99 |
+
|
| 100 |
+
def write_lineage(self, lineage: Dict) -> Dict:
|
| 101 |
+
lineage = {"evaluator_version": SEQEVAL_VERSION,
|
| 102 |
+
"timestamp": now_iso(), **lineage}
|
| 103 |
+
with open(os.path.join(self.dir, "lineage.json"), "w") as f:
|
| 104 |
+
json.dump(lineage, f, indent=1)
|
| 105 |
+
return lineage
|
| 106 |
+
|
| 107 |
+
def lineage(self) -> Optional[Dict]:
|
| 108 |
+
path = os.path.join(self.dir, "lineage.json")
|
| 109 |
+
if not os.path.exists(path):
|
| 110 |
+
return None
|
| 111 |
+
with open(path) as f:
|
| 112 |
+
return json.load(f)
|
sensorflow/seqeval/paired.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paired evaluation harness with a fingerprint-keyed prediction cache.
|
| 2 |
+
|
| 3 |
+
Core design: the SAME sampled units are run through both models. The paired
|
| 4 |
+
difference d_i = c_i - b_i removes the between-unit difficulty variance that
|
| 5 |
+
dominates two-independent-sample comparisons (Var(d) ~ discordance rate, an
|
| 6 |
+
order of magnitude below p(1-p) for adjacent model versions), which is where
|
| 7 |
+
most of the sample-size savings in this system come from.
|
| 8 |
+
|
| 9 |
+
Models are simulated through the megaeval synthetic machinery's population
|
| 10 |
+
(dimension codes + difficulty + container structure) with configurable
|
| 11 |
+
per-stratum performance effects, so a "2pp pedestrian-night regression"
|
| 12 |
+
candidate is constructible deterministically:
|
| 13 |
+
|
| 14 |
+
SimulatedModel("cand-v2", effects={"pedestrian|night": -0.02, "__global__": 0.004})
|
| 15 |
+
|
| 16 |
+
Determinism/pairing:
|
| 17 |
+
* a per-OBJECT pairing latent u_i drawn once per dataset (seeded by the
|
| 18 |
+
population seed) is shared by every model -> outcomes are paired;
|
| 19 |
+
* each model adds a small independent "flip" noise stream seeded by its own
|
| 20 |
+
fingerprint -> realistic discordance even at Delta = 0;
|
| 21 |
+
* a container-level scene effect (shared by both models) induces honest
|
| 22 |
+
intra-cluster correlation for the clustering machinery to handle.
|
| 23 |
+
|
| 24 |
+
Prediction cache: full-population success vectors keyed by
|
| 25 |
+
(dataset fingerprint, model fingerprint) and persisted under
|
| 26 |
+
runs/seqeval/cache/. Baseline predictions are computed once and reused across
|
| 27 |
+
candidate updates; COMPUTE_COUNTS lets tests assert no recompute happened.
|
| 28 |
+
Invalidation is by construction: a new dataset or new checkpoint changes the
|
| 29 |
+
fingerprint and therefore the key; stale entries never collide.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import hashlib
|
| 35 |
+
import json
|
| 36 |
+
import os
|
| 37 |
+
import threading
|
| 38 |
+
from typing import Dict, Optional, Tuple
|
| 39 |
+
|
| 40 |
+
import numpy as np
|
| 41 |
+
|
| 42 |
+
from sensorflow.megaeval import population as pop_mod
|
| 43 |
+
from sensorflow.seqeval import ledger as ledger_mod
|
| 44 |
+
|
| 45 |
+
# Simulation profile (baseline physics of the synthetic detector).
|
| 46 |
+
BASE_DETECT = 0.945
|
| 47 |
+
DIFFICULTY_WEIGHT = 0.16
|
| 48 |
+
NIGHT_PENALTY = 0.030
|
| 49 |
+
VRU_PENALTY = 0.020
|
| 50 |
+
SCENE_EFFECT_SD = 0.030
|
| 51 |
+
FLIP_NOISE = 0.04 # per-model chance of drawing a fresh latent
|
| 52 |
+
VRU_CLASSES = (1, 2, 3) # pedestrian, cyclist, motorcycle
|
| 53 |
+
|
| 54 |
+
GLOBAL_EFFECT_KEY = "__global__"
|
| 55 |
+
|
| 56 |
+
# fingerprint -> number of actual (non-cache) prediction computations
|
| 57 |
+
COMPUTE_COUNTS: Dict[str, int] = {}
|
| 58 |
+
_COUNT_LOCK = threading.Lock()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SimulatedModel:
|
| 62 |
+
"""A model version plus per-stratum performance effects.
|
| 63 |
+
|
| 64 |
+
effects keys: "class|condition" (e.g. "pedestrian|night") in absolute
|
| 65 |
+
detection-probability points, or "__global__" applied everywhere.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, model_version: str, effects: Optional[Dict[str, float]] = None):
|
| 69 |
+
self.model_version = model_version
|
| 70 |
+
self.effects = dict(effects or {})
|
| 71 |
+
|
| 72 |
+
def fingerprint(self) -> str:
|
| 73 |
+
blob = json.dumps({"v": self.model_version,
|
| 74 |
+
"e": {k: round(float(v), 6)
|
| 75 |
+
for k, v in sorted(self.effects.items())}},
|
| 76 |
+
sort_keys=True)
|
| 77 |
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
| 78 |
+
|
| 79 |
+
def to_dict(self) -> Dict:
|
| 80 |
+
return {"model_version": self.model_version, "effects": self.effects,
|
| 81 |
+
"fingerprint": self.fingerprint()}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def dataset_fingerprint(meta: Dict) -> str:
|
| 85 |
+
blob = json.dumps({"pop": meta["population_id"], "seed": meta["seed"],
|
| 86 |
+
"n": meta["num_objects"]}, sort_keys=True)
|
| 87 |
+
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------ frame
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
_FRAME_CACHE: Dict[str, Dict[str, np.ndarray]] = {}
|
| 94 |
+
_FRAME_LOCK = threading.Lock()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_frame(meta: Dict) -> Dict[str, np.ndarray]:
|
| 98 |
+
"""Full-population sampling frame (dimension codes only), sorted by
|
| 99 |
+
object_id so that object_id == row index. Cached in memory per population."""
|
| 100 |
+
pid = meta["population_id"]
|
| 101 |
+
with _FRAME_LOCK:
|
| 102 |
+
if pid in _FRAME_CACHE:
|
| 103 |
+
return _FRAME_CACHE[pid]
|
| 104 |
+
cols_needed = ["object_id", "container_id", "class", "lighting", "scenario",
|
| 105 |
+
"weather", "difficulty", "safety_critical"]
|
| 106 |
+
parts = {k: [] for k in cols_needed}
|
| 107 |
+
for p in range(meta["num_partitions"]):
|
| 108 |
+
part = pop_mod.load_partition(pid, p)
|
| 109 |
+
for k in cols_needed:
|
| 110 |
+
parts[k].append(part[k])
|
| 111 |
+
frame = {k: np.concatenate(v) for k, v in parts.items()}
|
| 112 |
+
order = np.argsort(frame["object_id"], kind="stable")
|
| 113 |
+
frame = {k: v[order] for k, v in frame.items()}
|
| 114 |
+
with _FRAME_LOCK:
|
| 115 |
+
if len(_FRAME_CACHE) > 4:
|
| 116 |
+
_FRAME_CACHE.clear()
|
| 117 |
+
_FRAME_CACHE[pid] = frame
|
| 118 |
+
return frame
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ------------------------------------------------------------------ latents
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _pair_latents(meta: Dict) -> np.ndarray:
|
| 125 |
+
"""Per-object pairing latent, shared by all models on this dataset."""
|
| 126 |
+
rng = np.random.default_rng([int(meta["seed"]), 0x5E9, 424242])
|
| 127 |
+
return rng.random(int(meta["num_objects"]))
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _scene_effects(meta: Dict) -> np.ndarray:
|
| 131 |
+
"""Container-level 'scene hardness' shared by all models (drives ICC)."""
|
| 132 |
+
rng = np.random.default_rng([int(meta["seed"]), 777])
|
| 133 |
+
return rng.normal(0.0, SCENE_EFFECT_SD, size=int(meta["num_containers"]))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
_LATENT_CACHE: Dict[str, Tuple[np.ndarray, np.ndarray]] = {}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _latents(meta: Dict) -> Tuple[np.ndarray, np.ndarray]:
|
| 140 |
+
pid = meta["population_id"]
|
| 141 |
+
if pid not in _LATENT_CACHE:
|
| 142 |
+
if len(_LATENT_CACHE) > 4:
|
| 143 |
+
_LATENT_CACHE.clear()
|
| 144 |
+
_LATENT_CACHE[pid] = (_pair_latents(meta), _scene_effects(meta))
|
| 145 |
+
return _LATENT_CACHE[pid]
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------ prediction
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _detect_probability(frame: Dict[str, np.ndarray], meta: Dict,
|
| 152 |
+
model: SimulatedModel, condition_dim: str) -> np.ndarray:
|
| 153 |
+
difficulty = frame["difficulty"].astype(np.float64)
|
| 154 |
+
p = np.full(difficulty.shape, BASE_DETECT)
|
| 155 |
+
p -= DIFFICULTY_WEIGHT * difficulty
|
| 156 |
+
p -= NIGHT_PENALTY * (frame["lighting"] == 1)
|
| 157 |
+
p -= VRU_PENALTY * np.isin(frame["class"], VRU_CLASSES)
|
| 158 |
+
_, scene = _latents(meta)
|
| 159 |
+
p += scene[frame["container_id"]]
|
| 160 |
+
p += float(model.effects.get(GLOBAL_EFFECT_KEY, 0.0))
|
| 161 |
+
cond_vocab = pop_mod.DIMENSIONS[condition_dim]
|
| 162 |
+
cls_vocab = pop_mod.DIMENSIONS["class"]
|
| 163 |
+
for key, eff in model.effects.items():
|
| 164 |
+
if key == GLOBAL_EFFECT_KEY or "|" not in key:
|
| 165 |
+
continue
|
| 166 |
+
cls_name, cond_name = key.split("|", 1)
|
| 167 |
+
if cls_name not in cls_vocab or cond_name not in cond_vocab:
|
| 168 |
+
continue
|
| 169 |
+
mask = ((frame["class"] == cls_vocab.index(cls_name))
|
| 170 |
+
& (frame[condition_dim] == cond_vocab.index(cond_name)))
|
| 171 |
+
p[mask] += float(eff)
|
| 172 |
+
return np.clip(p, 0.01, 0.995)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _compute_predictions(meta: Dict, model: SimulatedModel,
|
| 176 |
+
condition_dim: str) -> np.ndarray:
|
| 177 |
+
frame = load_frame(meta)
|
| 178 |
+
p = _detect_probability(frame, meta, model, condition_dim)
|
| 179 |
+
u_pair, _ = _latents(meta)
|
| 180 |
+
fp_int = int(model.fingerprint(), 16)
|
| 181 |
+
rng = np.random.default_rng([fp_int % (2**32), int(meta["seed"]), 7])
|
| 182 |
+
n = u_pair.size
|
| 183 |
+
swap = rng.random(n) < FLIP_NOISE
|
| 184 |
+
fresh = rng.random(n)
|
| 185 |
+
u = np.where(swap, fresh, u_pair)
|
| 186 |
+
return (u < p).astype(np.uint8)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class PredictionCache:
|
| 190 |
+
"""Disk + memory cache of full-population success vectors.
|
| 191 |
+
|
| 192 |
+
Key = (dataset fingerprint, model fingerprint). Both fingerprints cover
|
| 193 |
+
everything the predictions depend on, so invalidation is automatic: any
|
| 194 |
+
change to the dataset (new population/seed/size) or the model (version or
|
| 195 |
+
effects) produces a new key.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
def __init__(self):
|
| 199 |
+
self._mem: Dict[str, np.ndarray] = {}
|
| 200 |
+
self._lock = threading.Lock()
|
| 201 |
+
|
| 202 |
+
def _path(self, key: str) -> str:
|
| 203 |
+
return os.path.join(ledger_mod.seq_root(), "cache", f"{key}.npz")
|
| 204 |
+
|
| 205 |
+
def get_or_compute(self, meta: Dict, model: SimulatedModel,
|
| 206 |
+
condition_dim: str = "lighting") -> np.ndarray:
|
| 207 |
+
key = f"{dataset_fingerprint(meta)}-{model.fingerprint()}"
|
| 208 |
+
with self._lock:
|
| 209 |
+
if key in self._mem:
|
| 210 |
+
return self._mem[key]
|
| 211 |
+
path = self._path(key)
|
| 212 |
+
if os.path.exists(path):
|
| 213 |
+
with np.load(path) as z:
|
| 214 |
+
success = z["success"]
|
| 215 |
+
else:
|
| 216 |
+
success = _compute_predictions(meta, model, condition_dim)
|
| 217 |
+
with _COUNT_LOCK:
|
| 218 |
+
COMPUTE_COUNTS[key] = COMPUTE_COUNTS.get(key, 0) + 1
|
| 219 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 220 |
+
np.savez_compressed(path, success=success,
|
| 221 |
+
model_version=np.array([model.model_version]))
|
| 222 |
+
with self._lock:
|
| 223 |
+
if len(self._mem) > 16:
|
| 224 |
+
self._mem.clear()
|
| 225 |
+
self._mem[key] = success
|
| 226 |
+
return success
|
| 227 |
+
|
| 228 |
+
def clear_memory(self) -> None:
|
| 229 |
+
with self._lock:
|
| 230 |
+
self._mem.clear()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
_CACHE: Optional[PredictionCache] = None
|
| 234 |
+
_CACHE_LOCK = threading.Lock()
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def get_prediction_cache() -> PredictionCache:
|
| 238 |
+
global _CACHE
|
| 239 |
+
with _CACHE_LOCK:
|
| 240 |
+
if _CACHE is None:
|
| 241 |
+
_CACHE = PredictionCache()
|
| 242 |
+
return _CACHE
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def reset_prediction_cache() -> None:
|
| 246 |
+
global _CACHE
|
| 247 |
+
with _CACHE_LOCK:
|
| 248 |
+
_CACHE = None
|
| 249 |
+
with _FRAME_LOCK:
|
| 250 |
+
_FRAME_CACHE.clear()
|
| 251 |
+
_LATENT_CACHE.clear()
|
| 252 |
+
with _COUNT_LOCK:
|
| 253 |
+
COMPUTE_COUNTS.clear()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def paired_outcomes(meta: Dict, baseline: SimulatedModel, candidate: SimulatedModel,
|
| 257 |
+
object_ids: np.ndarray, condition_dim: str = "lighting"
|
| 258 |
+
) -> Dict[str, np.ndarray]:
|
| 259 |
+
"""Per-unit paired outcomes for the given objects (baseline served from
|
| 260 |
+
cache; the same sampled units go through both models)."""
|
| 261 |
+
cache = get_prediction_cache()
|
| 262 |
+
b_full = cache.get_or_compute(meta, baseline, condition_dim)
|
| 263 |
+
c_full = cache.get_or_compute(meta, candidate, condition_dim)
|
| 264 |
+
idx = np.asarray(object_ids, dtype=np.int64)
|
| 265 |
+
b = b_full[idx].astype(np.int8)
|
| 266 |
+
c = c_full[idx].astype(np.int8)
|
| 267 |
+
return {"baseline": b, "candidate": c, "d": (c - b).astype(np.float64)}
|
sensorflow/seqeval/planner.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen stratified sampling planner (hybrid Neyman + risk floors + safety minimums).
|
| 2 |
+
|
| 3 |
+
The plan is built from the POPULATION ONLY (dimension codes, counts, and a
|
| 4 |
+
candidate-independent prior on baseline variance) and is persisted with its
|
| 5 |
+
seed, config and a content hash BEFORE any candidate outcome is observed.
|
| 6 |
+
This is the sampling-bias firewall: no outcome-dependent selection is possible
|
| 7 |
+
because the selection provably cannot depend on outcomes (the planner never
|
| 8 |
+
sees them), and the hash gives auditors a cheap way to verify the plan did not
|
| 9 |
+
change after results started arriving.
|
| 10 |
+
|
| 11 |
+
Allocation (hybrid, in order):
|
| 12 |
+
1. Neyman baseline: n_h proportional to N_h * sigma_h, where sigma_h =
|
| 13 |
+
sqrt(p_h (1 - p_h)) uses a PRIOR per-stratum baseline success rate
|
| 14 |
+
(deterministic profile; never candidate data).
|
| 15 |
+
2. Risk weighting: VRU-class and night/adverse-condition strata get a
|
| 16 |
+
multiplicative boost (they are where regressions hurt).
|
| 17 |
+
3. Floors: every stratum gets a mandatory minimum; pre-registered
|
| 18 |
+
safety-critical strata get a much larger minimum sized for the target MDE.
|
| 19 |
+
4. Truncation at stratum population size; whole CLUSTERS (container groups)
|
| 20 |
+
are selected so the inference unit stays intact.
|
| 21 |
+
|
| 22 |
+
Because allocation is non-proportional, every sampled object carries the
|
| 23 |
+
Horvitz-Thompson style weight w_h = N_h / n_h; population-level estimates are
|
| 24 |
+
weight-corrected and therefore remain unbiased despite oversampling.
|
| 25 |
+
|
| 26 |
+
The plan also stores a per-stratum escalation RESERVE (the remaining cluster
|
| 27 |
+
units in shuffled order) so the controller can expand the sample without
|
| 28 |
+
breaking the frozen-selection property: the reserve order was fixed at plan
|
| 29 |
+
time too.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import hashlib
|
| 35 |
+
import json
|
| 36 |
+
import os
|
| 37 |
+
from typing import Dict, List, Optional
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
|
| 41 |
+
from sensorflow.megaeval import population as pop_mod
|
| 42 |
+
from sensorflow.seqeval import ledger as ledger_mod
|
| 43 |
+
from sensorflow.seqeval import paired as paired_mod
|
| 44 |
+
|
| 45 |
+
DIFFICULTY_BANDS = [0.30, 0.55] # easy < 0.30 <= medium < 0.55 <= hard
|
| 46 |
+
BAND_NAMES = ["easy", "medium", "hard"]
|
| 47 |
+
|
| 48 |
+
# Candidate-independent prior baseline success rates used for Neyman sigma_h.
|
| 49 |
+
_PRIOR_BASE = 0.90
|
| 50 |
+
_PRIOR_NIGHT_DROP = 0.04
|
| 51 |
+
_PRIOR_VRU_DROP = 0.03
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def stratum_key(cls_name: str, cond_name: str) -> str:
|
| 55 |
+
return f"{cls_name}|{cond_name}"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _prior_rate(cls_code: int, cond_code: int, condition_dim: str) -> float:
|
| 59 |
+
p = _PRIOR_BASE
|
| 60 |
+
if condition_dim == "lighting" and cond_code == 1:
|
| 61 |
+
p -= _PRIOR_NIGHT_DROP
|
| 62 |
+
if cls_code in paired_mod.VRU_CLASSES:
|
| 63 |
+
p -= _PRIOR_VRU_DROP
|
| 64 |
+
return p
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _risk_weight(cls_code: int, cond_code: int, condition_dim: str) -> float:
|
| 68 |
+
w = 1.0
|
| 69 |
+
if cls_code in paired_mod.VRU_CLASSES:
|
| 70 |
+
w *= 1.5
|
| 71 |
+
if condition_dim == "lighting" and cond_code == 1:
|
| 72 |
+
w *= 1.3
|
| 73 |
+
return w
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def plan_path(plan_id: str) -> str:
|
| 77 |
+
return os.path.join(ledger_mod.seq_root(), "plans", plan_id)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_plan(meta: Dict, target_n: int, seed: int,
|
| 81 |
+
condition_dim: str = "lighting",
|
| 82 |
+
safety_primaries: Optional[List[str]] = None,
|
| 83 |
+
min_per_stratum: int = 200,
|
| 84 |
+
safety_floor: int = 2500,
|
| 85 |
+
persist: bool = True) -> Dict:
|
| 86 |
+
"""Build (and persist) a frozen stratified cluster-sampling plan.
|
| 87 |
+
|
| 88 |
+
Takes NO candidate/model arguments by design — see module docstring.
|
| 89 |
+
"""
|
| 90 |
+
safety_primaries = list(safety_primaries or [])
|
| 91 |
+
frame = paired_mod.load_frame(meta)
|
| 92 |
+
cls_vocab = pop_mod.DIMENSIONS["class"]
|
| 93 |
+
cond_vocab = pop_mod.DIMENSIONS[condition_dim]
|
| 94 |
+
n_cond = len(cond_vocab)
|
| 95 |
+
strata_ids = frame["class"].astype(np.int64) * n_cond + frame[condition_dim].astype(np.int64)
|
| 96 |
+
n_strata = len(cls_vocab) * n_cond
|
| 97 |
+
|
| 98 |
+
counts = np.bincount(strata_ids, minlength=n_strata)
|
| 99 |
+
keys = [stratum_key(cls_vocab[s // n_cond], cond_vocab[s % n_cond])
|
| 100 |
+
for s in range(n_strata)]
|
| 101 |
+
|
| 102 |
+
# --- 1-2. Neyman shares with risk multipliers (prior variance, no outcomes)
|
| 103 |
+
shares = np.zeros(n_strata)
|
| 104 |
+
for s in range(n_strata):
|
| 105 |
+
if counts[s] == 0:
|
| 106 |
+
continue
|
| 107 |
+
p = _prior_rate(s // n_cond, s % n_cond, condition_dim)
|
| 108 |
+
sigma = np.sqrt(p * (1 - p))
|
| 109 |
+
shares[s] = counts[s] * sigma * _risk_weight(s // n_cond, s % n_cond, condition_dim)
|
| 110 |
+
shares = shares / shares.sum() if shares.sum() > 0 else shares
|
| 111 |
+
|
| 112 |
+
# --- 3. floors and safety minimums, truncated at N_h
|
| 113 |
+
alloc = np.zeros(n_strata, dtype=np.int64)
|
| 114 |
+
for s in range(n_strata):
|
| 115 |
+
if counts[s] == 0:
|
| 116 |
+
continue
|
| 117 |
+
n_h = int(round(target_n * shares[s]))
|
| 118 |
+
n_h = max(n_h, min(min_per_stratum, int(counts[s])))
|
| 119 |
+
if keys[s] in safety_primaries:
|
| 120 |
+
n_h = max(n_h, min(safety_floor, int(0.85 * counts[s])))
|
| 121 |
+
alloc[s] = min(n_h, int(counts[s]))
|
| 122 |
+
|
| 123 |
+
# --- 4. whole-cluster selection in seeded shuffled order + reserve
|
| 124 |
+
rng = np.random.default_rng([int(seed), int(meta["seed"]), 20260812])
|
| 125 |
+
strata = {}
|
| 126 |
+
arrays = {}
|
| 127 |
+
for s in range(n_strata):
|
| 128 |
+
if counts[s] == 0:
|
| 129 |
+
continue
|
| 130 |
+
rows = np.where(strata_ids == s)[0]
|
| 131 |
+
containers = frame["container_id"][rows]
|
| 132 |
+
order = np.argsort(containers, kind="stable")
|
| 133 |
+
rows, containers = rows[order], containers[order]
|
| 134 |
+
uniq, start = np.unique(containers, return_index=True)
|
| 135 |
+
bounds = np.append(start, rows.size)
|
| 136 |
+
cluster_order = rng.permutation(uniq.size)
|
| 137 |
+
|
| 138 |
+
sel_ids, sel_bounds = [], [0]
|
| 139 |
+
res_ids, res_bounds = [], [0]
|
| 140 |
+
taken = 0
|
| 141 |
+
for ci in cluster_order:
|
| 142 |
+
members = frame["object_id"][rows[bounds[ci]:bounds[ci + 1]]]
|
| 143 |
+
if taken < alloc[s]:
|
| 144 |
+
sel_ids.append(members)
|
| 145 |
+
sel_bounds.append(sel_bounds[-1] + members.size)
|
| 146 |
+
taken += members.size
|
| 147 |
+
else:
|
| 148 |
+
res_ids.append(members)
|
| 149 |
+
res_bounds.append(res_bounds[-1] + members.size)
|
| 150 |
+
sel = np.concatenate(sel_ids) if sel_ids else np.empty(0, dtype=np.int64)
|
| 151 |
+
res = np.concatenate(res_ids) if res_ids else np.empty(0, dtype=np.int64)
|
| 152 |
+
arrays[f"s{s}_ids"] = sel
|
| 153 |
+
arrays[f"s{s}_bounds"] = np.asarray(sel_bounds, dtype=np.int64)
|
| 154 |
+
arrays[f"s{s}_res_ids"] = res
|
| 155 |
+
arrays[f"s{s}_res_bounds"] = np.asarray(res_bounds, dtype=np.int64)
|
| 156 |
+
strata[str(s)] = {
|
| 157 |
+
"key": keys[s],
|
| 158 |
+
"class": cls_vocab[s // n_cond],
|
| 159 |
+
"condition": cond_vocab[s % n_cond],
|
| 160 |
+
"N": int(counts[s]),
|
| 161 |
+
"allocated": int(sel.size),
|
| 162 |
+
"clusters": len(sel_bounds) - 1,
|
| 163 |
+
"reserve": int(res.size),
|
| 164 |
+
"weight": round(float(counts[s]) / max(sel.size, 1), 4),
|
| 165 |
+
"safety_primary": keys[s] in safety_primaries,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
config = {
|
| 169 |
+
"target_n": int(target_n),
|
| 170 |
+
"condition_dim": condition_dim,
|
| 171 |
+
"safety_primaries": safety_primaries,
|
| 172 |
+
"min_per_stratum": int(min_per_stratum),
|
| 173 |
+
"safety_floor": int(safety_floor),
|
| 174 |
+
"difficulty_bands": DIFFICULTY_BANDS,
|
| 175 |
+
"allocation": "neyman(prior sigma) * risk_weights, floors, whole clusters",
|
| 176 |
+
}
|
| 177 |
+
h = hashlib.sha256()
|
| 178 |
+
h.update(json.dumps({"dataset": paired_mod.dataset_fingerprint(meta),
|
| 179 |
+
"seed": int(seed), "config": config}, sort_keys=True).encode())
|
| 180 |
+
for s in sorted(int(k) for k in strata):
|
| 181 |
+
h.update(arrays[f"s{s}_ids"].tobytes())
|
| 182 |
+
h.update(arrays[f"s{s}_res_ids"].tobytes())
|
| 183 |
+
plan_hash = h.hexdigest()[:24]
|
| 184 |
+
|
| 185 |
+
plan = {
|
| 186 |
+
"plan_id": f"plan-{plan_hash[:10]}",
|
| 187 |
+
"plan_hash": plan_hash,
|
| 188 |
+
"population_id": meta["population_id"],
|
| 189 |
+
"dataset_fingerprint": paired_mod.dataset_fingerprint(meta),
|
| 190 |
+
"seed": int(seed),
|
| 191 |
+
"created_at": ledger_mod.now_iso(),
|
| 192 |
+
"config": config,
|
| 193 |
+
"population_objects": int(meta["num_objects"]),
|
| 194 |
+
"total_allocated": int(sum(v["allocated"] for v in strata.values())),
|
| 195 |
+
"strata": strata,
|
| 196 |
+
"frozen_before_candidate_outcomes": True,
|
| 197 |
+
}
|
| 198 |
+
if persist:
|
| 199 |
+
d = plan_path(plan["plan_id"])
|
| 200 |
+
os.makedirs(d, exist_ok=True)
|
| 201 |
+
with open(os.path.join(d, "plan.json"), "w") as f:
|
| 202 |
+
json.dump(plan, f)
|
| 203 |
+
np.savez_compressed(os.path.join(d, "units.npz"), **arrays)
|
| 204 |
+
plan["_arrays"] = arrays # in-memory only; not serialized
|
| 205 |
+
return plan
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def load_plan(plan_id: str) -> Optional[Dict]:
|
| 209 |
+
d = plan_path(plan_id)
|
| 210 |
+
meta_path = os.path.join(d, "plan.json")
|
| 211 |
+
if not os.path.exists(meta_path):
|
| 212 |
+
return None
|
| 213 |
+
with open(meta_path) as f:
|
| 214 |
+
plan = json.load(f)
|
| 215 |
+
with np.load(os.path.join(d, "units.npz")) as z:
|
| 216 |
+
plan["_arrays"] = {k: z[k] for k in z.files}
|
| 217 |
+
return plan
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def stratum_clusters(plan: Dict, s: int, reserve: bool = False) -> List[np.ndarray]:
|
| 221 |
+
"""Ordered list of cluster-unit object_id arrays for stratum s."""
|
| 222 |
+
suffix = "_res" if reserve else ""
|
| 223 |
+
ids = plan["_arrays"].get(f"s{s}{suffix}_ids")
|
| 224 |
+
bounds = plan["_arrays"].get(f"s{s}{suffix}_bounds")
|
| 225 |
+
if ids is None or bounds is None or ids.size == 0:
|
| 226 |
+
return []
|
| 227 |
+
return [ids[bounds[i]:bounds[i + 1]] for i in range(bounds.size - 1)]
|
sensorflow/seqeval/sequential.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Anytime-valid sequential inference for paired mean differences.
|
| 2 |
+
|
| 3 |
+
Why not repeated fixed-sample tests: a CLT/Wilson interval is valid at ONE
|
| 4 |
+
pre-committed n. Recomputing it after every batch and stopping on the first
|
| 5 |
+
significant look inflates type-I error without bound (the law of the iterated
|
| 6 |
+
logarithm guarantees an eventual false crossing). Everything here is therefore
|
| 7 |
+
ANYTIME-VALID: the guarantee holds simultaneously over all sample sizes and
|
| 8 |
+
data-dependent stopping rules.
|
| 9 |
+
|
| 10 |
+
Machinery (Waudby-Smith & Ramdas, "Estimating means of bounded random variables
|
| 11 |
+
by betting", JRSS-B 2023):
|
| 12 |
+
|
| 13 |
+
* `EmpiricalBernsteinCS` — predictable plug-in empirical-Bernstein confidence
|
| 14 |
+
sequence for a mean of [0,1]-bounded observations. Variance-adaptive: for
|
| 15 |
+
paired differences (variance << 1/4) it is far tighter than Hoeffding.
|
| 16 |
+
* `OneSidedEProcess` — a betting supermartingale (e-process) against a
|
| 17 |
+
one-sided null "mean >= m" or "mean <= m". Ville's inequality gives
|
| 18 |
+
P(e-value ever >= 1/alpha) <= alpha under the null, at any stopping time.
|
| 19 |
+
* `PairedSequentialTest` — combines both on paired differences d in [-1,1]
|
| 20 |
+
(mapped to x=(d+1)/2), producing the mandatory THREE-OUTCOME decision
|
| 21 |
+
against a practical-significance margin delta:
|
| 22 |
+
REGRESSION e-process against H0: Delta >= -delta crossed 1/alpha_reg
|
| 23 |
+
PASS e-process against H0: Delta <= -delta crossed 1/alpha_pass
|
| 24 |
+
(an equivalence-style claim: the drop, if any, is
|
| 25 |
+
confidently smaller than the margin)
|
| 26 |
+
INSUFFICIENT_EVIDENCE neither — never interpreted as proven equivalence.
|
| 27 |
+
* A Beta-Binomial posterior P(Delta < -delta | data) is computed alongside as
|
| 28 |
+
an operational, human-readable complement (it is NOT the gate criterion).
|
| 29 |
+
|
| 30 |
+
Observations fed in are CLUSTER means (see units.py), so temporal/spatial
|
| 31 |
+
correlation is handled by construction rather than by post-hoc correction.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import math
|
| 37 |
+
from typing import Dict, List, Optional
|
| 38 |
+
|
| 39 |
+
from scipy import stats as _sstats
|
| 40 |
+
|
| 41 |
+
DECISION_REGRESSION = "REGRESSION"
|
| 42 |
+
DECISION_PASS = "PASS"
|
| 43 |
+
DECISION_INSUFFICIENT = "INSUFFICIENT_EVIDENCE"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class EmpiricalBernsteinCS:
|
| 47 |
+
"""Predictable plug-in empirical-Bernstein confidence sequence on [0,1].
|
| 48 |
+
|
| 49 |
+
Running-intersection bounds are kept (intersecting a CS over time preserves
|
| 50 |
+
coverage), so the interval never widens as data accrue.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(self, alpha: float = 0.05, truncation: float = 0.5):
|
| 54 |
+
self.alpha = alpha
|
| 55 |
+
self.truncation = truncation
|
| 56 |
+
self.t = 0
|
| 57 |
+
self._sum_x = 0.0
|
| 58 |
+
self._mu_hat = 0.5 # mu_hat_0 = 1/2 (prior-regularized running mean)
|
| 59 |
+
self._var_acc = 0.25 # 1/4 + sum_i (x_i - mu_hat_i)^2
|
| 60 |
+
self._sigma2_hat = 0.25
|
| 61 |
+
self._sum_lam = 0.0
|
| 62 |
+
self._sum_lam_x = 0.0
|
| 63 |
+
self._sum_v_psi = 0.0
|
| 64 |
+
self.lower = 0.0
|
| 65 |
+
self.upper = 1.0
|
| 66 |
+
|
| 67 |
+
def update(self, x: float) -> None:
|
| 68 |
+
x = min(1.0, max(0.0, float(x)))
|
| 69 |
+
t = self.t + 1
|
| 70 |
+
log2a = math.log(2.0 / self.alpha)
|
| 71 |
+
lam = math.sqrt(2.0 * log2a / (max(self._sigma2_hat, 1e-12) * t * math.log1p(t)))
|
| 72 |
+
lam = min(lam, self.truncation)
|
| 73 |
+
v = 4.0 * (x - self._mu_hat) ** 2
|
| 74 |
+
psi = (-math.log1p(-lam) - lam) / 4.0
|
| 75 |
+
self._sum_lam += lam
|
| 76 |
+
self._sum_lam_x += lam * x
|
| 77 |
+
self._sum_v_psi += v * psi
|
| 78 |
+
# advance predictable estimates AFTER betting with the old ones
|
| 79 |
+
self.t = t
|
| 80 |
+
self._sum_x += x
|
| 81 |
+
self._mu_hat = (0.5 + self._sum_x) / (t + 1)
|
| 82 |
+
self._var_acc += (x - self._mu_hat) ** 2
|
| 83 |
+
self._sigma2_hat = self._var_acc / (t + 1)
|
| 84 |
+
if self._sum_lam > 0:
|
| 85 |
+
center = self._sum_lam_x / self._sum_lam
|
| 86 |
+
radius = (log2a + self._sum_v_psi) / self._sum_lam
|
| 87 |
+
self.lower = max(self.lower, center - radius)
|
| 88 |
+
self.upper = min(self.upper, center + radius)
|
| 89 |
+
|
| 90 |
+
def interval(self) -> tuple:
|
| 91 |
+
return (max(0.0, self.lower), min(1.0, self.upper))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class OneSidedEProcess:
|
| 95 |
+
"""Betting e-process for a one-sided mean null on [0,1] observations.
|
| 96 |
+
|
| 97 |
+
side="below": H0: mu >= m, wealth grows when observations fall below m.
|
| 98 |
+
side="above": H0: mu <= m, wealth grows when observations exceed m.
|
| 99 |
+
|
| 100 |
+
Bet sizing: max of the empirical-Bernstein recipe (drives early growth
|
| 101 |
+
while the sample variance estimate is still coarse) and an aGRAPA /
|
| 102 |
+
approximate-Kelly bet lambda ~ drift / (sigma^2 + drift^2) (near-optimal
|
| 103 |
+
asymptotic growth rate ~ KL divergence per observation once a real drift
|
| 104 |
+
emerges). Both are predictable and clipped to the admissible range, so the
|
| 105 |
+
wealth process remains a nonnegative supermartingale under the null — the
|
| 106 |
+
sizing choice affects power only, never validity.
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
def __init__(self, m: float, side: str, alpha_ref: float = 0.05,
|
| 110 |
+
truncation: float = 0.75):
|
| 111 |
+
assert side in ("below", "above")
|
| 112 |
+
self.m = float(m)
|
| 113 |
+
self.side = side
|
| 114 |
+
self.alpha_ref = alpha_ref
|
| 115 |
+
self.truncation = truncation
|
| 116 |
+
self.t = 0
|
| 117 |
+
self.log_e = 0.0
|
| 118 |
+
self._sum_x = 0.0
|
| 119 |
+
self._mu_hat = 0.5
|
| 120 |
+
self._var_acc = 0.25
|
| 121 |
+
self._sigma2_hat = 0.25
|
| 122 |
+
|
| 123 |
+
def update(self, x: float) -> None:
|
| 124 |
+
x = min(1.0, max(0.0, float(x)))
|
| 125 |
+
t = self.t + 1
|
| 126 |
+
loga = math.log(1.0 / self.alpha_ref)
|
| 127 |
+
sigma2 = max(self._sigma2_hat, 1e-12)
|
| 128 |
+
lam_eb = math.sqrt(2.0 * loga / (sigma2 * t * math.log1p(t)))
|
| 129 |
+
drift = (self.m - self._mu_hat) if self.side == "below" else (self._mu_hat - self.m)
|
| 130 |
+
lam_kelly = max(drift, 0.0) / (sigma2 + drift * drift)
|
| 131 |
+
lam = max(lam_eb, lam_kelly)
|
| 132 |
+
if self.side == "below":
|
| 133 |
+
lam = min(lam, self.truncation / max(1.0 - self.m, 1e-6))
|
| 134 |
+
factor = 1.0 - lam * (x - self.m)
|
| 135 |
+
else:
|
| 136 |
+
lam = min(lam, self.truncation / max(self.m, 1e-6))
|
| 137 |
+
factor = 1.0 + lam * (x - self.m)
|
| 138 |
+
self.log_e += math.log(max(factor, 1e-12))
|
| 139 |
+
self.t = t
|
| 140 |
+
self._sum_x += x
|
| 141 |
+
self._mu_hat = (0.5 + self._sum_x) / (t + 1)
|
| 142 |
+
self._var_acc += (x - self._mu_hat) ** 2
|
| 143 |
+
self._sigma2_hat = self._var_acc / (t + 1)
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def e_value(self) -> float:
|
| 147 |
+
return math.exp(min(self.log_e, 700.0))
|
| 148 |
+
|
| 149 |
+
def p_analogue(self) -> float:
|
| 150 |
+
"""Anytime-valid p-value analogue: min(1, 1/e)."""
|
| 151 |
+
return min(1.0, 1.0 / max(self.e_value, 1e-300))
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class PairedSequentialTest:
|
| 155 |
+
"""Three-outcome anytime-valid test on paired differences d in [-1, 1].
|
| 156 |
+
|
| 157 |
+
Delta = E[d] (candidate minus baseline). Regression margin `delta` in
|
| 158 |
+
absolute metric points: the H0 boundary sits at Delta = -delta.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
def __init__(self, delta: float = 0.005, alpha: float = 0.05,
|
| 162 |
+
alpha_pass: Optional[float] = None):
|
| 163 |
+
self.delta = float(delta)
|
| 164 |
+
self.alpha = float(alpha)
|
| 165 |
+
self.alpha_pass = float(alpha_pass if alpha_pass is not None else alpha)
|
| 166 |
+
m = (1.0 - self.delta) / 2.0 # Delta = -delta <=> mu = (1-delta)/2
|
| 167 |
+
self.cs = EmpiricalBernsteinCS(alpha=self.alpha)
|
| 168 |
+
self.e_reg = OneSidedEProcess(m, side="below", alpha_ref=self.alpha)
|
| 169 |
+
self.e_pass = OneSidedEProcess(m, side="above", alpha_ref=self.alpha_pass)
|
| 170 |
+
# object-level accounting (for reporting + Bayesian complement)
|
| 171 |
+
self.n_objects = 0
|
| 172 |
+
self.n_clusters = 0
|
| 173 |
+
self.sum_baseline = 0.0
|
| 174 |
+
self.sum_candidate = 0.0
|
| 175 |
+
self.n01 = 0 # baseline correct, candidate wrong (regression pairs)
|
| 176 |
+
self.n10 = 0 # candidate correct, baseline wrong (improvement pairs)
|
| 177 |
+
self.decision = DECISION_INSUFFICIENT
|
| 178 |
+
self.decided_at_n: Optional[int] = None
|
| 179 |
+
self.trajectory: List[Dict] = []
|
| 180 |
+
|
| 181 |
+
# ---- updates -------------------------------------------------------
|
| 182 |
+
|
| 183 |
+
def update_clusters(self, cluster_means) -> None:
|
| 184 |
+
"""Feed per-cluster mean paired differences (each in [-1, 1])."""
|
| 185 |
+
for d in cluster_means:
|
| 186 |
+
x = (float(d) + 1.0) / 2.0
|
| 187 |
+
self.cs.update(x)
|
| 188 |
+
self.e_reg.update(x)
|
| 189 |
+
self.e_pass.update(x)
|
| 190 |
+
self.n_clusters += 1
|
| 191 |
+
|
| 192 |
+
def record_objects(self, b, c) -> None:
|
| 193 |
+
"""Track object-level paired outcomes for counts and the posterior."""
|
| 194 |
+
import numpy as np
|
| 195 |
+
b = np.asarray(b, dtype=bool)
|
| 196 |
+
c = np.asarray(c, dtype=bool)
|
| 197 |
+
self.n_objects += int(b.size)
|
| 198 |
+
self.sum_baseline += float(b.sum())
|
| 199 |
+
self.sum_candidate += float(c.sum())
|
| 200 |
+
self.n01 += int(np.sum(b & ~c))
|
| 201 |
+
self.n10 += int(np.sum(~b & c))
|
| 202 |
+
|
| 203 |
+
# ---- state ---------------------------------------------------------
|
| 204 |
+
|
| 205 |
+
def delta_interval(self) -> tuple:
|
| 206 |
+
lo, hi = self.cs.interval()
|
| 207 |
+
return (2.0 * lo - 1.0, 2.0 * hi - 1.0)
|
| 208 |
+
|
| 209 |
+
def delta_estimate(self) -> Optional[float]:
|
| 210 |
+
if self.n_objects == 0:
|
| 211 |
+
return None
|
| 212 |
+
return (self.sum_candidate - self.sum_baseline) / self.n_objects
|
| 213 |
+
|
| 214 |
+
def bayes_p_regression(self) -> Optional[float]:
|
| 215 |
+
"""Beta-Binomial posterior P(Delta < -delta | data).
|
| 216 |
+
|
| 217 |
+
McNemar decomposition: among D = n01 + n10 discordant pairs let
|
| 218 |
+
theta = P(pair is a 01 regression pair). With plug-in discordance rate
|
| 219 |
+
nu = D/n, Delta = nu * (1 - 2*theta), so
|
| 220 |
+
Delta < -delta <=> theta > (1 + delta*n/D) / 2.
|
| 221 |
+
Prior theta ~ Beta(1,1).
|
| 222 |
+
"""
|
| 223 |
+
d_total = self.n01 + self.n10
|
| 224 |
+
if self.n_objects == 0 or d_total == 0:
|
| 225 |
+
return None
|
| 226 |
+
threshold = (1.0 + self.delta * self.n_objects / d_total) / 2.0
|
| 227 |
+
if threshold >= 1.0:
|
| 228 |
+
return 0.0
|
| 229 |
+
return float(_sstats.beta.sf(threshold, 1 + self.n01, 1 + self.n10))
|
| 230 |
+
|
| 231 |
+
def evaluate(self, alpha_reg: Optional[float] = None,
|
| 232 |
+
alpha_pass: Optional[float] = None) -> str:
|
| 233 |
+
"""Update the sticky three-outcome decision at the given levels."""
|
| 234 |
+
if self.decision != DECISION_INSUFFICIENT:
|
| 235 |
+
return self.decision
|
| 236 |
+
a_r = alpha_reg if alpha_reg is not None else self.alpha
|
| 237 |
+
a_p = alpha_pass if alpha_pass is not None else self.alpha_pass
|
| 238 |
+
if self.e_reg.e_value >= 1.0 / a_r:
|
| 239 |
+
self.decision = DECISION_REGRESSION
|
| 240 |
+
self.decided_at_n = self.n_objects
|
| 241 |
+
elif self.e_pass.e_value >= 1.0 / a_p:
|
| 242 |
+
self.decision = DECISION_PASS
|
| 243 |
+
self.decided_at_n = self.n_objects
|
| 244 |
+
return self.decision
|
| 245 |
+
|
| 246 |
+
def snapshot(self) -> Dict:
|
| 247 |
+
lo, hi = self.delta_interval()
|
| 248 |
+
est = self.delta_estimate()
|
| 249 |
+
return {
|
| 250 |
+
"n": self.n_objects,
|
| 251 |
+
"n_clusters": self.n_clusters,
|
| 252 |
+
"delta_estimate": None if est is None else round(est, 5),
|
| 253 |
+
"delta_ci": [round(lo, 5), round(hi, 5)],
|
| 254 |
+
"e_regression": round(self.e_reg.e_value, 4),
|
| 255 |
+
"e_pass": round(self.e_pass.e_value, 4),
|
| 256 |
+
"p_analogue": round(self.e_reg.p_analogue(), 6),
|
| 257 |
+
"bayes_p_regression": (None if self.bayes_p_regression() is None
|
| 258 |
+
else round(self.bayes_p_regression(), 5)),
|
| 259 |
+
"decision": self.decision,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
def record_trajectory_point(self) -> None:
|
| 263 |
+
"""Append an evidence-vs-samples point (for the dashboard chart)."""
|
| 264 |
+
self.trajectory.append({
|
| 265 |
+
"n": self.n_objects,
|
| 266 |
+
"n_clusters": self.n_clusters,
|
| 267 |
+
"delta_estimate": self.delta_estimate(),
|
| 268 |
+
"delta_lower": round(self.delta_interval()[0], 5),
|
| 269 |
+
"delta_upper": round(self.delta_interval()[1], 5),
|
| 270 |
+
"log_e_regression": round(self.e_reg.log_e, 4),
|
| 271 |
+
"log_e_pass": round(self.e_pass.log_e, 4),
|
| 272 |
+
"decision": self.decision,
|
| 273 |
+
})
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def approx_mde(var_d: float, n_eff: float, delta: float, alpha: float,
|
| 277 |
+
power: float = 0.9, anytime_inflation: float = 1.4) -> Optional[float]:
|
| 278 |
+
"""Approximate minimum detectable absolute effect at the current n_eff.
|
| 279 |
+
|
| 280 |
+
Fixed-n normal approximation (z_{1-a} + z_{power}) * sqrt(var/n_eff),
|
| 281 |
+
inflated for the anytime-valid overhead, plus the practical margin delta
|
| 282 |
+
(effects must exceed the margin to be declarable)."""
|
| 283 |
+
if n_eff <= 1 or var_d <= 0:
|
| 284 |
+
return None
|
| 285 |
+
z = _sstats.norm.ppf(1 - alpha) + _sstats.norm.ppf(power)
|
| 286 |
+
return float(delta + anytime_inflation * z * math.sqrt(var_d / n_eff))
|
sensorflow/seqeval/units.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Statistical-unit handling: clustering, intra-cluster correlation, design effect.
|
| 2 |
+
|
| 3 |
+
Frames/objects are NOT independent observations: objects in the same container
|
| 4 |
+
(scene/track) share weather, lighting, scenario, scene-level "hardness", and the
|
| 5 |
+
same model failure modes. Treating them as iid understates variance and makes
|
| 6 |
+
any test anti-conservative.
|
| 7 |
+
|
| 8 |
+
The seqeval convention, used by every module downstream:
|
| 9 |
+
|
| 10 |
+
* The inference unit is the CLUSTER: all sampled objects of one stratum that
|
| 11 |
+
live in the same container. Per-cluster mean paired differences (bounded in
|
| 12 |
+
[-1, 1]) are what feed the confidence sequences and e-processes. This is
|
| 13 |
+
the clustered analogue of a paired analysis and is valid regardless of the
|
| 14 |
+
within-cluster correlation structure.
|
| 15 |
+
* Object-level counts are still tracked for reporting; the *effective* sample
|
| 16 |
+
size n_eff = n / DEFF with DEFF = 1 + (m_bar - 1) * ICC is attached to every
|
| 17 |
+
evidence record so readers can see how much clustering cost us.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from typing import Dict, List, Tuple
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def cluster_units(values: np.ndarray, cluster_ids: np.ndarray,
|
| 28 |
+
weights: np.ndarray | None = None) -> Tuple[np.ndarray, np.ndarray]:
|
| 29 |
+
"""Collapse object-level values into per-cluster (weight-normalized) means.
|
| 30 |
+
|
| 31 |
+
Returns (cluster_means, cluster_sizes). Means are convex combinations of the
|
| 32 |
+
inputs, so they stay inside the input range — a requirement for the bounded
|
| 33 |
+
confidence sequences downstream.
|
| 34 |
+
"""
|
| 35 |
+
values = np.asarray(values, dtype=np.float64)
|
| 36 |
+
cluster_ids = np.asarray(cluster_ids)
|
| 37 |
+
if weights is None:
|
| 38 |
+
weights = np.ones_like(values)
|
| 39 |
+
else:
|
| 40 |
+
weights = np.asarray(weights, dtype=np.float64)
|
| 41 |
+
order = np.argsort(cluster_ids, kind="stable")
|
| 42 |
+
cid, v, w = cluster_ids[order], values[order], weights[order]
|
| 43 |
+
uniq, start = np.unique(cid, return_index=True)
|
| 44 |
+
bounds = np.append(start, cid.size)
|
| 45 |
+
wsum = np.add.reduceat(w, start)
|
| 46 |
+
vsum = np.add.reduceat(v * w, start)
|
| 47 |
+
sizes = np.diff(bounds)
|
| 48 |
+
means = vsum / np.where(wsum > 0, wsum, 1.0)
|
| 49 |
+
return means, sizes
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def icc_anova(values: np.ndarray, cluster_ids: np.ndarray) -> float:
|
| 53 |
+
"""One-way ANOVA (method-of-moments) estimator of the intra-cluster
|
| 54 |
+
correlation. Clipped to [0, 1]; returns 0.0 when it cannot be estimated."""
|
| 55 |
+
values = np.asarray(values, dtype=np.float64)
|
| 56 |
+
cluster_ids = np.asarray(cluster_ids)
|
| 57 |
+
order = np.argsort(cluster_ids, kind="stable")
|
| 58 |
+
v, cid = values[order], cluster_ids[order]
|
| 59 |
+
uniq, start = np.unique(cid, return_index=True)
|
| 60 |
+
k = uniq.size
|
| 61 |
+
n = v.size
|
| 62 |
+
if k < 2 or n <= k:
|
| 63 |
+
return 0.0
|
| 64 |
+
sizes = np.diff(np.append(start, n)).astype(np.float64)
|
| 65 |
+
sums = np.add.reduceat(v, start)
|
| 66 |
+
means = sums / sizes
|
| 67 |
+
grand = v.mean()
|
| 68 |
+
ss_between = float(np.sum(sizes * (means - grand) ** 2))
|
| 69 |
+
ss_within = float(np.sum((v - np.repeat(means, sizes.astype(int))) ** 2))
|
| 70 |
+
ms_between = ss_between / (k - 1)
|
| 71 |
+
ms_within = ss_within / (n - k)
|
| 72 |
+
# average cluster size adjusted for imbalance (n0 in Donner & Klar)
|
| 73 |
+
n0 = (n - float(np.sum(sizes ** 2)) / n) / (k - 1)
|
| 74 |
+
denom = ms_between + (n0 - 1) * ms_within
|
| 75 |
+
if denom <= 0:
|
| 76 |
+
return 0.0
|
| 77 |
+
return float(np.clip((ms_between - ms_within) / denom, 0.0, 1.0))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def design_effect(cluster_sizes: np.ndarray, icc: float) -> float:
|
| 81 |
+
"""Kish design effect DEFF = 1 + (m_bar - 1) * ICC, with m_bar the
|
| 82 |
+
size-weighted mean cluster size (sum m^2 / sum m)."""
|
| 83 |
+
sizes = np.asarray(cluster_sizes, dtype=np.float64)
|
| 84 |
+
if sizes.size == 0 or sizes.sum() <= 0:
|
| 85 |
+
return 1.0
|
| 86 |
+
m_bar = float(np.sum(sizes ** 2) / np.sum(sizes))
|
| 87 |
+
return max(1.0, 1.0 + (m_bar - 1.0) * icc)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def effective_sample_size(n: int, deff: float) -> float:
|
| 91 |
+
return float(n) / max(deff, 1.0)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def cluster_summary(values: np.ndarray, cluster_ids: np.ndarray) -> Dict:
|
| 95 |
+
"""Full clustering diagnostics for one node/stratum (for the ledger)."""
|
| 96 |
+
values = np.asarray(values, dtype=np.float64)
|
| 97 |
+
_, sizes = cluster_units(values, cluster_ids)
|
| 98 |
+
icc = icc_anova(values, cluster_ids)
|
| 99 |
+
deff = design_effect(sizes, icc)
|
| 100 |
+
return {
|
| 101 |
+
"n": int(values.size),
|
| 102 |
+
"n_clusters": int(sizes.size),
|
| 103 |
+
"mean_cluster_size": round(float(sizes.mean()), 3) if sizes.size else 0.0,
|
| 104 |
+
"icc": round(icc, 4),
|
| 105 |
+
"design_effect": round(deff, 3),
|
| 106 |
+
"n_effective": round(effective_sample_size(values.size, deff), 1),
|
| 107 |
+
}
|
tests/test_safety/conftest.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for the safety layer test suite.
|
| 2 |
+
|
| 3 |
+
- mega_env: isolated megaeval root with a small population and two published
|
| 4 |
+
runs (a good candidate and a deliberately regressed one) — mirrors
|
| 5 |
+
tests/test_megaeval/conftest.py.
|
| 6 |
+
- safety_root: session-scoped isolated runs/safety replacement.
|
| 7 |
+
- fresh_safety_root: function-scoped pristine safety root (for tests that need
|
| 8 |
+
an empty scenario DB / no cached CSI / no supplements).
|
| 9 |
+
- eval_env: fresh labeleval store with a small synthetic dataset + labels.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
|
| 18 |
+
from sensorflow.megaeval import population as pop_mod
|
| 19 |
+
from sensorflow.megaeval.runs import get_mega_store, reset_mega_store
|
| 20 |
+
from sensorflow.safety import scenario_db
|
| 21 |
+
from sensorflow.safety import store as safety_store
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture(scope="session")
|
| 25 |
+
def mega_env(tmp_path_factory):
|
| 26 |
+
root = tmp_path_factory.mktemp("megaeval-safety")
|
| 27 |
+
pop_mod.set_mega_root(str(root))
|
| 28 |
+
reset_mega_store()
|
| 29 |
+
meta = pop_mod.generate_population("safety-pop", num_objects=8_000, seed=17)
|
| 30 |
+
store = get_mega_store()
|
| 31 |
+
|
| 32 |
+
good = store.create_run(population_id=meta["population_id"],
|
| 33 |
+
model_version="model-v41", worker_delay_s=0.0)
|
| 34 |
+
store.execute_sync(good)
|
| 35 |
+
assert good.status == "published", good.error
|
| 36 |
+
|
| 37 |
+
bad = store.create_run(population_id=meta["population_id"],
|
| 38 |
+
model_version="model-v42-regressed",
|
| 39 |
+
overrides={"night_penalty": 0.35, "vru_penalty": 0.12},
|
| 40 |
+
worker_delay_s=0.0)
|
| 41 |
+
store.execute_sync(bad)
|
| 42 |
+
assert bad.status == "published", bad.error
|
| 43 |
+
|
| 44 |
+
yield {"root": root, "meta": meta, "store": store, "good": good, "bad": bad}
|
| 45 |
+
|
| 46 |
+
pop_mod.set_mega_root("runs/megaeval")
|
| 47 |
+
reset_mega_store()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.fixture(scope="session")
|
| 51 |
+
def safety_root(tmp_path_factory):
|
| 52 |
+
root = tmp_path_factory.mktemp("safety-root")
|
| 53 |
+
safety_store.set_safety_root(str(root))
|
| 54 |
+
scenario_db.reset_db()
|
| 55 |
+
yield root
|
| 56 |
+
safety_store.set_safety_root(os.path.join("runs", "safety"))
|
| 57 |
+
scenario_db.reset_db()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.fixture()
|
| 61 |
+
def fresh_safety_root(safety_root, tmp_path):
|
| 62 |
+
"""Pristine safety root for one test; restores the session root after."""
|
| 63 |
+
fresh = tmp_path / "safety"
|
| 64 |
+
safety_store.set_safety_root(str(fresh))
|
| 65 |
+
scenario_db.reset_db()
|
| 66 |
+
yield fresh
|
| 67 |
+
safety_store.set_safety_root(str(safety_root))
|
| 68 |
+
scenario_db.reset_db()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.fixture()
|
| 72 |
+
def eval_env(tmp_path):
|
| 73 |
+
from sensorflow.evaluation import synthetic
|
| 74 |
+
from sensorflow.evaluation.records import reset_store
|
| 75 |
+
|
| 76 |
+
store = reset_store(tmp_path)
|
| 77 |
+
ds = synthetic.generate_dataset(store, num_sequences=2, frames_per_sequence=8,
|
| 78 |
+
seed=11)
|
| 79 |
+
synthetic.generate_labels(store, ds)
|
| 80 |
+
return store, ds
|
tests/test_safety/test_api_safety.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live verification of every /api/safety/* endpoint through the real app
|
| 2 |
+
(in-process fastapi TestClient against app_backend.app — no running servers
|
| 3 |
+
are touched)."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
|
| 10 |
+
LENIENT = {
|
| 11 |
+
"scenario_quality": {"min_geometric_pass_rate": 0.05},
|
| 12 |
+
"coverage": {"min_coverage_rate": 0.0, "min_production_weighted_coverage": 0.0,
|
| 13 |
+
"min_samples": 5, "max_ci_width": 0.99},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture(scope="module")
|
| 18 |
+
def client():
|
| 19 |
+
from app_backend import app
|
| 20 |
+
return TestClient(app)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture()
|
| 24 |
+
def api_env(mega_env, fresh_safety_root, tmp_path):
|
| 25 |
+
"""Isolated safety root + labeleval store with one dataset, plus mega runs."""
|
| 26 |
+
from sensorflow.evaluation import synthetic
|
| 27 |
+
from sensorflow.evaluation.records import reset_store
|
| 28 |
+
|
| 29 |
+
store = reset_store(tmp_path / "eval")
|
| 30 |
+
ds = synthetic.generate_dataset(store, num_sequences=2, frames_per_sequence=6,
|
| 31 |
+
seed=23)
|
| 32 |
+
synthetic.generate_labels(store, ds)
|
| 33 |
+
return {"mega": mega_env, "store": store, "ds": ds}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_odd_endpoints(client, api_env):
|
| 37 |
+
assert client.get("/api/safety/odd/taxonomy").status_code == 200
|
| 38 |
+
|
| 39 |
+
run_id = api_env["mega"]["good"].run_id
|
| 40 |
+
r = client.get("/api/safety/odd/coverage",
|
| 41 |
+
params={"run": run_id, "dims": "weather,lighting",
|
| 42 |
+
"min_samples": 10_000})
|
| 43 |
+
assert r.status_code == 200
|
| 44 |
+
cov = r.json()
|
| 45 |
+
assert cov["run_id"] == run_id and cov["gaps"]
|
| 46 |
+
|
| 47 |
+
gap = cov["gaps"][0]
|
| 48 |
+
r = client.post("/api/safety/odd/fill-gap",
|
| 49 |
+
json={"run": run_id, "cell": gap["cell"], "num_sequences": 1,
|
| 50 |
+
"frames_per_sequence": 10})
|
| 51 |
+
assert r.status_code == 200
|
| 52 |
+
assert r.json()["objects_added"] > 0
|
| 53 |
+
|
| 54 |
+
assert client.get("/api/safety/odd/coverage",
|
| 55 |
+
params={"run": "eval-nope"}).status_code == 404
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_gates_and_evidence_endpoints(client, api_env):
|
| 59 |
+
good = api_env["mega"]["good"].run_id
|
| 60 |
+
bad = api_env["mega"]["bad"].run_id
|
| 61 |
+
|
| 62 |
+
r = client.get("/api/safety/gates/policy")
|
| 63 |
+
assert r.status_code == 200 and "coverage" in r.json()["policy"]
|
| 64 |
+
|
| 65 |
+
r = client.post("/api/safety/gates/policy",
|
| 66 |
+
json={"overrides": {"safety": {"max_csi_increase_ratio": 0.05}}})
|
| 67 |
+
assert r.status_code == 200
|
| 68 |
+
assert r.json()["policy"]["safety"]["max_csi_increase_ratio"] == 0.05
|
| 69 |
+
|
| 70 |
+
r = client.post("/api/safety/gates/evaluate",
|
| 71 |
+
json={"candidate_run": bad, "baseline_run": good,
|
| 72 |
+
"policy_overrides": LENIENT})
|
| 73 |
+
assert r.status_code == 200
|
| 74 |
+
res = r.json()
|
| 75 |
+
assert res["decision"] == "BLOCKED"
|
| 76 |
+
assert "regression" in res["blocking_gates"]
|
| 77 |
+
|
| 78 |
+
r = client.get(f"/api/safety/gates/result/{bad}")
|
| 79 |
+
assert r.status_code == 200 and r.json()["decision"] == "BLOCKED"
|
| 80 |
+
|
| 81 |
+
r = client.get(f"/api/safety/evidence/{bad}")
|
| 82 |
+
assert r.status_code == 200
|
| 83 |
+
assert r.json()["package_id"] == f"sep-{bad}"
|
| 84 |
+
|
| 85 |
+
r = client.get(f"/api/safety/evidence/{bad}", params={"format": "markdown"})
|
| 86 |
+
assert r.status_code == 200
|
| 87 |
+
assert "# Safety Evidence Package" in r.json()["markdown"]
|
| 88 |
+
|
| 89 |
+
assert client.get("/api/safety/evidence/eval-none").status_code == 404
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_ssam_endpoints(client, api_env):
|
| 93 |
+
r = client.post("/api/safety/ssam/analyze",
|
| 94 |
+
json={"scenario": "crossing", "seed": 0, "reaction_delay_s": 0.9})
|
| 95 |
+
assert r.status_code == 200
|
| 96 |
+
body = r.json()
|
| 97 |
+
assert body["aggregate"]["num_conflicts"] >= 1
|
| 98 |
+
assert body["generated"]["simulated"] is True
|
| 99 |
+
|
| 100 |
+
# explicit trajectories path
|
| 101 |
+
states = [{"t": round(i * 0.1, 3), "x": 10.0 * i * 0.1, "y": 0.0,
|
| 102 |
+
"speed": 10.0, "heading": 0.0} for i in range(21)]
|
| 103 |
+
still = [{"t": round(i * 0.1, 3), "x": 30.0, "y": 0.0, "speed": 0.0,
|
| 104 |
+
"heading": 0.0} for i in range(21)]
|
| 105 |
+
r = client.post("/api/safety/ssam/analyze", json={"trajectories": [
|
| 106 |
+
{"vehicle_id": "A", "states": states},
|
| 107 |
+
{"vehicle_id": "B", "states": still}]})
|
| 108 |
+
assert r.status_code == 200
|
| 109 |
+
assert r.json()["aggregate"]["num_conflicts"] == 1
|
| 110 |
+
|
| 111 |
+
run_id = api_env["mega"]["good"].run_id
|
| 112 |
+
r = client.get("/api/safety/ssam/summary", params={"run": run_id})
|
| 113 |
+
assert r.status_code == 200
|
| 114 |
+
assert r.json()["aggregate_csi"] > 0
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def test_calibration_endpoints(client, api_env):
|
| 118 |
+
r = client.post("/api/safety/calibration/validate",
|
| 119 |
+
json={"mode": "miscalibrated", "rotation_offset_deg": 2.0})
|
| 120 |
+
assert r.status_code == 200
|
| 121 |
+
assert r.json()["status"] == "MISCALIBRATED"
|
| 122 |
+
|
| 123 |
+
r = client.get("/api/safety/calibration/status")
|
| 124 |
+
assert r.status_code == 200 and r.json()["status"] == "MISCALIBRATED"
|
| 125 |
+
|
| 126 |
+
r = client.post("/api/safety/calibration/validate", json={"mode": "clean"})
|
| 127 |
+
assert r.json()["status"] == "CALIBRATED"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_discrepancy_endpoints(client, api_env):
|
| 131 |
+
ds_id = api_env["ds"].dataset_id
|
| 132 |
+
r = client.post("/api/safety/discrepancy/mine",
|
| 133 |
+
json={"dataset_id": ds_id,
|
| 134 |
+
"profile": {"extra_miss_rate": 0.5}})
|
| 135 |
+
assert r.status_code == 200
|
| 136 |
+
report = r.json()
|
| 137 |
+
assert report["totals"]["discrepancies"] > 0
|
| 138 |
+
|
| 139 |
+
r = client.get("/api/safety/discrepancy/summary", params={"dataset_id": ds_id})
|
| 140 |
+
assert r.status_code == 200
|
| 141 |
+
assert r.json()["dataset_id"] == ds_id
|
| 142 |
+
|
| 143 |
+
r = client.get("/api/safety/discrepancy/summary")
|
| 144 |
+
assert r.status_code == 200 and len(r.json()["datasets"]) == 1
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_scenario_endpoints(client, api_env):
|
| 148 |
+
ds_id = api_env["ds"].dataset_id
|
| 149 |
+
# seed content through discrepancy mining
|
| 150 |
+
client.post("/api/safety/discrepancy/mine",
|
| 151 |
+
json={"dataset_id": ds_id, "profile": {"extra_miss_rate": 0.5}})
|
| 152 |
+
|
| 153 |
+
r = client.get("/api/safety/scenarios", params={"source": "discrepancy"})
|
| 154 |
+
assert r.status_code == 200
|
| 155 |
+
body = r.json()
|
| 156 |
+
assert body["counts"]["total"] > 0
|
| 157 |
+
assert all(s["source"] == "discrepancy" for s in body["scenarios"])
|
| 158 |
+
|
| 159 |
+
r = client.post("/api/safety/scenarios/populate")
|
| 160 |
+
assert r.status_code == 200
|
| 161 |
+
|
| 162 |
+
r = client.post("/api/safety/scenarios/export", json={"source": "discrepancy"})
|
| 163 |
+
assert r.status_code == 200
|
| 164 |
+
bundle = r.json()
|
| 165 |
+
assert bundle["bundle_format"] == "sensorflow-scenario-bundle/v1"
|
| 166 |
+
assert bundle["count"] == len(bundle["scenarios"]) > 0
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_semantic_search_endpoint(client, api_env):
|
| 170 |
+
r = client.post("/api/safety/semantic-search",
|
| 171 |
+
json={"concept": "hesitant vulnerable road user near lane edge",
|
| 172 |
+
"target": "containers", "k": 8, "use_llm": False})
|
| 173 |
+
assert r.status_code == 200
|
| 174 |
+
body = r.json()
|
| 175 |
+
assert body["results"]
|
| 176 |
+
assert body["stage2"]["provider"] == "offline_deterministic"
|
| 177 |
+
assert body["results"][0]["explanations"]["stage2_reasoning"]
|
| 178 |
+
|
| 179 |
+
r = client.post("/api/safety/semantic-search",
|
| 180 |
+
json={"concept": "night rain", "target": "containers",
|
| 181 |
+
"filters": {"lighting": "night"}, "use_llm": False})
|
| 182 |
+
assert all(x["lighting"] == "night" for x in r.json()["results"])
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_consensus_endpoint(client, api_env):
|
| 186 |
+
store, ds = api_env["store"], api_env["ds"]
|
| 187 |
+
from sensorflow.evaluation.graders import grade_annotation
|
| 188 |
+
frames = {f.frame_id: f for f in store.where("frames", dataset_id=ds.dataset_id)}
|
| 189 |
+
for ann in [a for a in store.where("annotations", dataset_id=ds.dataset_id)
|
| 190 |
+
if a.bbox_3d][:6]:
|
| 191 |
+
grade_annotation(store, ann, frames[ann.frame_id])
|
| 192 |
+
|
| 193 |
+
r = client.get("/api/safety/consensus/summary",
|
| 194 |
+
params={"dataset_id": ds.dataset_id, "examples": 3})
|
| 195 |
+
assert r.status_code == 200
|
| 196 |
+
body = r.json()
|
| 197 |
+
assert "kendall_tau_confidence_vs_consensus" in body["statistics"]
|
| 198 |
+
assert body["examples"]
|
| 199 |
+
ex = body["examples"][0]
|
| 200 |
+
assert "consensus_score_vector" in ex and "mbr" in ex
|
| 201 |
+
|
| 202 |
+
assert client.get("/api/safety/consensus/summary",
|
| 203 |
+
params={"dataset_id": "ds-none"}).status_code == 404
|
tests/test_safety/test_calibration.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-sensor calibration validation: clean scenes pass, an injected 2-degree
|
| 2 |
+
extrinsic rotation must be flagged as MISCALIBRATED (systematic bias), tampered
|
| 3 |
+
2D boxes must be flagged as PERCEPTION_FAILURE (random disagreement) mapped to
|
| 4 |
+
the platform's SENSOR_DISAGREEMENT failure reason."""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from sensorflow.safety import calibration as cal
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_clean_scene_passes(fresh_safety_root):
|
| 14 |
+
res = cal.run_validation(mode="clean", seed=3)
|
| 15 |
+
assert res["status"] == "CALIBRATED"
|
| 16 |
+
assert res["passed"] is True
|
| 17 |
+
assert all(c["passed"] for c in res["checks"])
|
| 18 |
+
assert not any(o["flagged"] for o in res["per_object"])
|
| 19 |
+
assert res["metrics"]["bias_px"] <= res["thresholds"]["bias_px"]
|
| 20 |
+
assert res["simulated"] is True
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_two_degree_rotation_flagged_as_miscalibration(fresh_safety_root):
|
| 24 |
+
res = cal.run_validation(mode="miscalibrated", rotation_offset_deg=2.0, seed=3)
|
| 25 |
+
assert res["status"] == "MISCALIBRATED"
|
| 26 |
+
assert res["passed"] is False
|
| 27 |
+
# systematic: every object shares the bias
|
| 28 |
+
assert all(o["flagged"] for o in res["per_object"])
|
| 29 |
+
assert all(o["failure_reason"] == "MISCALIBRATION" for o in res["per_object"])
|
| 30 |
+
# recovered rotation magnitude is in the right ballpark
|
| 31 |
+
assert res["metrics"]["estimated_rotation_offset_deg"] == pytest.approx(2.0, abs=0.8)
|
| 32 |
+
bias_check = next(c for c in res["checks"] if c["name"] == "systematic_bias_px")
|
| 33 |
+
assert not bias_check["passed"]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_perception_failure_distinguished_from_miscalibration(fresh_safety_root):
|
| 37 |
+
res = cal.run_validation(mode="perception_failure", tamper_fraction=0.3, seed=3)
|
| 38 |
+
assert res["status"] == "PERCEPTION_FAILURE"
|
| 39 |
+
flagged = [o for o in res["per_object"] if o["flagged"]]
|
| 40 |
+
assert flagged and len(flagged) < len(res["per_object"]) # minority, not systematic
|
| 41 |
+
# maps onto the existing platform failure reason
|
| 42 |
+
assert all(o["failure_reason"] == "SENSOR_DISAGREEMENT" for o in flagged)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_status_persisted_and_alert_emitted(fresh_safety_root, tmp_path):
|
| 46 |
+
from sensorflow.evaluation.records import reset_store
|
| 47 |
+
store = reset_store(tmp_path / "eval")
|
| 48 |
+
|
| 49 |
+
assert cal.latest_status() is None
|
| 50 |
+
cal.run_validation(mode="miscalibrated", rotation_offset_deg=2.0, seed=3)
|
| 51 |
+
status = cal.latest_status()
|
| 52 |
+
assert status is not None and status["status"] == "MISCALIBRATED"
|
| 53 |
+
# failure surfaced in the existing labeleval alert store
|
| 54 |
+
alerts = [a for a in store.all("alerts") if a.kind == "calibration"]
|
| 55 |
+
assert alerts and alerts[0].severity == "critical"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_extrinsic_projection_roundtrip():
|
| 59 |
+
import numpy as np
|
| 60 |
+
ext = cal.make_extrinsic()
|
| 61 |
+
scene = cal.generate_scene(num_objects=6, seed=5)
|
| 62 |
+
for obj in scene["objects"]:
|
| 63 |
+
uv = cal.project_points(np.asarray(obj["points_lidar"]), ext)
|
| 64 |
+
assert uv.shape[1] == 2
|
| 65 |
+
assert np.isfinite(uv).all()
|
tests/test_safety/test_consensus.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additive consensus extensions: consensus score vector, Kendall's tau, and
|
| 2 |
+
Minimum Bayes Risk selection — exact values on small fixtures, plus wiring into
|
| 3 |
+
the grader comparison records."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from sensorflow.evaluation.graders import (
|
| 10 |
+
consensus_score_vector,
|
| 11 |
+
dataset_grader_statistics,
|
| 12 |
+
grade_annotation,
|
| 13 |
+
kendalls_tau,
|
| 14 |
+
mbr_consensus_select,
|
| 15 |
+
mbr_utility,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_kendalls_tau_exact():
|
| 20 |
+
assert kendalls_tau([1, 2, 3], [1, 2, 3]) == (1.0, pytest.approx(0.333333, abs=1e-3))
|
| 21 |
+
tau, _ = kendalls_tau([1, 2, 3], [3, 2, 1])
|
| 22 |
+
assert tau == -1.0
|
| 23 |
+
# one swapped pair of three: tau-b = (2-1)/3 = 1/3
|
| 24 |
+
tau, _ = kendalls_tau([1, 2, 3], [1, 3, 2])
|
| 25 |
+
assert tau == pytest.approx(1 / 3, abs=1e-4)
|
| 26 |
+
# degenerate (constant) input handled
|
| 27 |
+
assert kendalls_tau([1, 1], [2, 2]) == (0.0, 1.0)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_consensus_score_vector_exact():
|
| 31 |
+
votes = {"a": "vehicle", "b": "vehicle", "c": "vehicle", "d": "vehicle",
|
| 32 |
+
"e": "truck"}
|
| 33 |
+
detail = {"iou": 0.5, "center_distance": 1.0, "dimension_diff": 0.2,
|
| 34 |
+
"orientation_diff_deg": 45.0}
|
| 35 |
+
v = consensus_score_vector(votes, detail, temporal=1.0)
|
| 36 |
+
assert v["class_majority_share"] == pytest.approx(0.8)
|
| 37 |
+
assert v["class_weighted_agreement"] == pytest.approx(0.8) # uniform weights
|
| 38 |
+
assert v["spatial_iou"] == pytest.approx(0.5)
|
| 39 |
+
assert v["spatial_center"] == pytest.approx(0.5) # 1 - 1.0/2.0
|
| 40 |
+
assert v["spatial_dims"] == pytest.approx(0.8) # 1 - 0.2
|
| 41 |
+
assert v["spatial_orientation"] == pytest.approx(0.75) # 1 - 45/180
|
| 42 |
+
assert v["temporal"] == 1.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
BOX = [0.0, 0.0, 0.8, 4.5, 1.9, 1.6, 0.0]
|
| 46 |
+
FAR_BOX = [50.0, 50.0, 0.8, 4.5, 1.9, 1.6, 0.0]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_mbr_utility_exact():
|
| 50 |
+
a = {"class_name": "vehicle", "bbox_3d": BOX}
|
| 51 |
+
b = {"class_name": "vehicle", "bbox_3d": list(BOX)}
|
| 52 |
+
c = {"class_name": "pedestrian", "bbox_3d": FAR_BOX}
|
| 53 |
+
assert mbr_utility(a, b) == pytest.approx(1.0) # class match + IoU 1
|
| 54 |
+
assert mbr_utility(a, c) == pytest.approx(0.0) # no match, no overlap
|
| 55 |
+
d = {"class_name": "vehicle", "bbox_3d": FAR_BOX}
|
| 56 |
+
assert mbr_utility(a, d) == pytest.approx(0.5) # class only
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_mbr_selects_minimum_expected_risk():
|
| 60 |
+
candidates = [
|
| 61 |
+
{"source": "auto_label", "class_name": "vehicle", "bbox_3d": BOX},
|
| 62 |
+
{"source": "vendor_gt", "class_name": "vehicle", "bbox_3d": list(BOX)},
|
| 63 |
+
{"source": "vlm_label", "class_name": "pedestrian", "bbox_3d": FAR_BOX},
|
| 64 |
+
]
|
| 65 |
+
res = mbr_consensus_select(candidates)
|
| 66 |
+
# risks: agreeing pair 1 - mean(1, 0) = 0.5 each; outlier 1 - mean(0, 0) = 1
|
| 67 |
+
risks = [c["expected_risk"] for c in res["candidates"]]
|
| 68 |
+
assert risks == [0.5, 0.5, 1.0]
|
| 69 |
+
assert res["selected_index"] == 0 # first of the tied minimum
|
| 70 |
+
assert res["selected"]["source"] == "auto_label"
|
| 71 |
+
|
| 72 |
+
with pytest.raises(ValueError):
|
| 73 |
+
mbr_consensus_select([])
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_grade_annotation_carries_extension_evidence(eval_env):
|
| 77 |
+
store, ds = eval_env
|
| 78 |
+
frames = {f.frame_id: f for f in store.where("frames", dataset_id=ds.dataset_id)}
|
| 79 |
+
anns = [a for a in store.where("annotations", dataset_id=ds.dataset_id)
|
| 80 |
+
if a.bbox_3d][:6]
|
| 81 |
+
assert len(anns) >= 2
|
| 82 |
+
for ann in anns:
|
| 83 |
+
cmp = grade_annotation(store, ann, frames[ann.frame_id])
|
| 84 |
+
vector_keys = {k for k in cmp.kappa_stats if k.startswith("csv_")}
|
| 85 |
+
assert {"csv_class_majority_share", "csv_spatial_iou",
|
| 86 |
+
"csv_temporal"} <= vector_keys
|
| 87 |
+
assert "mbr_selected_idx" in cmp.kappa_stats
|
| 88 |
+
assert "mbr_risk_auto_label" in cmp.kappa_stats
|
| 89 |
+
# canonical scalar consensus still present and bounded
|
| 90 |
+
assert cmp.consensus is not None and 0.0 <= cmp.consensus <= 1.0
|
| 91 |
+
|
| 92 |
+
stats = dataset_grader_statistics(store, ds.dataset_id)
|
| 93 |
+
assert "cohen_kappa" in stats
|
| 94 |
+
assert "kendall_tau_confidence_vs_consensus" in stats
|
| 95 |
+
assert -1.0 <= stats["kendall_tau_confidence_vs_consensus"] <= 1.0
|
tests/test_safety/test_discrepancy.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Auto-label discrepancy mining: injected online misses are found, cohort
|
| 2 |
+
rates are consistent, and findings feed the rare-event store + scenario DB."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.safety import discrepancy
|
| 9 |
+
from sensorflow.safety.scenario_db import get_db
|
| 10 |
+
|
| 11 |
+
AGGRESSIVE = {"extra_miss_rate": 0.5, "class_confusion_rate": 0.2,
|
| 12 |
+
"position_noise_m": 1.2}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_mine_finds_injected_online_misses(eval_env, fresh_safety_root):
|
| 16 |
+
store, ds = eval_env
|
| 17 |
+
report = discrepancy.mine(store, ds.dataset_id, profile=AGGRESSIVE, seed=5)
|
| 18 |
+
|
| 19 |
+
t = report["totals"]
|
| 20 |
+
assert t["objects"] > 0
|
| 21 |
+
assert t["discrepancies"] > 0
|
| 22 |
+
assert t["discrepancy_rate"] == pytest.approx(
|
| 23 |
+
t["discrepancies"] / t["objects"], abs=1e-3)
|
| 24 |
+
# a 50% extra miss rate must produce MISSED_ONLINE findings
|
| 25 |
+
assert report["by_type"].get("MISSED_ONLINE", 0) > 0
|
| 26 |
+
assert sum(report["by_type"].values()) == t["discrepancies"]
|
| 27 |
+
assert report["simulated"] is True
|
| 28 |
+
|
| 29 |
+
# cohort accounting: rates in [0,1], hits never exceed objects
|
| 30 |
+
for c in report["cohorts"]:
|
| 31 |
+
assert 0.0 <= c["discrepancy_rate"] <= 1.0
|
| 32 |
+
assert c["discrepancies"] <= c["objects"]
|
| 33 |
+
assert sum(c["objects"] for c in report["cohorts"]) == t["objects"]
|
| 34 |
+
|
| 35 |
+
# every discrepancy is a queryable scenario-mining record
|
| 36 |
+
for d in report["discrepancies"][:10]:
|
| 37 |
+
assert {"discrepancy_id", "frame_id", "gt_id", "type", "class_name",
|
| 38 |
+
"weather", "time_of_day", "severity"} <= set(d)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_mine_is_deterministic(eval_env, fresh_safety_root):
|
| 42 |
+
store, ds = eval_env
|
| 43 |
+
r1 = discrepancy.mine(store, ds.dataset_id, profile=AGGRESSIVE, seed=5,
|
| 44 |
+
persist=False)
|
| 45 |
+
r2 = discrepancy.mine(store, ds.dataset_id, profile=AGGRESSIVE, seed=5,
|
| 46 |
+
persist=False)
|
| 47 |
+
assert r1["by_type"] == r2["by_type"]
|
| 48 |
+
assert [d["gt_id"] for d in r1["discrepancies"]] == \
|
| 49 |
+
[d["gt_id"] for d in r2["discrepancies"]]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_mine_feeds_rare_events_and_scenario_db(eval_env, fresh_safety_root):
|
| 53 |
+
store, ds = eval_env
|
| 54 |
+
before_events = len(store.all("rare_events"))
|
| 55 |
+
report = discrepancy.mine(store, ds.dataset_id, profile=AGGRESSIVE, seed=5)
|
| 56 |
+
|
| 57 |
+
critical = [d for d in report["discrepancies"] if d["severity"] == "critical"]
|
| 58 |
+
if critical: # pedestrian/cyclist online misses
|
| 59 |
+
mined_events = [e for e in store.all("rare_events")
|
| 60 |
+
if e.scenario_type == "online_perception_miss"]
|
| 61 |
+
assert len(store.all("rare_events")) > before_events
|
| 62 |
+
assert mined_events and all(e.severity == "critical" for e in mined_events)
|
| 63 |
+
|
| 64 |
+
recs = get_db().search(source="discrepancy", limit=1000)
|
| 65 |
+
high = [d for d in report["discrepancies"] if d["severity"] in ("high", "critical")]
|
| 66 |
+
assert len(recs) == len({(d["gt_id"], d["type"]) for d in high})
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_latest_summary_roundtrip(eval_env, fresh_safety_root):
|
| 70 |
+
store, ds = eval_env
|
| 71 |
+
assert discrepancy.latest_summary(ds.dataset_id) is None
|
| 72 |
+
discrepancy.mine(store, ds.dataset_id, profile=AGGRESSIVE, seed=5)
|
| 73 |
+
summary = discrepancy.latest_summary(ds.dataset_id)
|
| 74 |
+
assert summary is not None
|
| 75 |
+
assert summary["dataset_id"] == ds.dataset_id
|
| 76 |
+
assert "discrepancies" not in summary # slimmed
|
| 77 |
+
assert len(summary["sample_discrepancies"]) <= 25
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_mine_unknown_dataset_raises(eval_env, fresh_safety_root):
|
| 81 |
+
store, _ = eval_env
|
| 82 |
+
with pytest.raises(KeyError):
|
| 83 |
+
discrepancy.mine(store, "ds-nonexistent")
|
tests/test_safety/test_gates.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Layered release gating: policy handling, blocking behavior with the right
|
| 2 |
+
evidence, and the Safety Evidence Package (JSON + markdown)."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.safety import gates
|
| 9 |
+
|
| 10 |
+
LENIENT = {
|
| 11 |
+
"scenario_quality": {"min_geometric_pass_rate": 0.05},
|
| 12 |
+
"coverage": {"min_coverage_rate": 0.0, "min_production_weighted_coverage": 0.0,
|
| 13 |
+
"min_samples": 5, "max_ci_width": 0.99},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture()
|
| 18 |
+
def eval_store(tmp_path):
|
| 19 |
+
from sensorflow.evaluation.records import reset_store
|
| 20 |
+
return reset_store(tmp_path / "eval")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_policy_defaults_merge_and_persist(fresh_safety_root):
|
| 24 |
+
p = gates.get_policy()
|
| 25 |
+
assert p == gates.DEFAULT_POLICY
|
| 26 |
+
updated = gates.set_policy({"safety": {"max_csi_increase_ratio": 0.5}})
|
| 27 |
+
assert updated["safety"]["max_csi_increase_ratio"] == 0.5
|
| 28 |
+
# untouched sections keep defaults
|
| 29 |
+
assert updated["coverage"]["min_samples"] == gates.DEFAULT_POLICY["coverage"]["min_samples"]
|
| 30 |
+
assert gates.get_policy()["safety"]["max_csi_increase_ratio"] == 0.5
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_all_gates_pass_for_good_candidate(mega_env, fresh_safety_root, eval_store):
|
| 34 |
+
res = gates.evaluate_gates(mega_env["store"], mega_env["good"], mega_env["good"],
|
| 35 |
+
policy_overrides=LENIENT)
|
| 36 |
+
assert res["decision"] == "RELEASE_READY"
|
| 37 |
+
assert res["blocking_gates"] == []
|
| 38 |
+
by_gate = {g["gate"]: g for g in res["gates"]}
|
| 39 |
+
assert set(by_gate) == {"scenario_quality", "coverage", "regression",
|
| 40 |
+
"safety", "release_readiness"}
|
| 41 |
+
assert all(g["status"] == "PASS" for g in res["gates"])
|
| 42 |
+
# candidate == baseline -> zero CSI delta, PROMOTE recommendation
|
| 43 |
+
assert by_gate["safety"]["evidence"]["csi_increase_ratio"] == pytest.approx(0.0)
|
| 44 |
+
assert by_gate["regression"]["evidence"]["blockers"] == []
|
| 45 |
+
# every gate carries checks with thresholds and actuals
|
| 46 |
+
for g in res["gates"]:
|
| 47 |
+
for c in g["checks"]:
|
| 48 |
+
assert {"check", "actual", "threshold", "passed"} <= set(c)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_regressed_candidate_blocks_regression_and_safety(mega_env, fresh_safety_root,
|
| 52 |
+
eval_store):
|
| 53 |
+
overrides = {**LENIENT, "safety": {"max_csi_increase_ratio": 0.05}}
|
| 54 |
+
res = gates.evaluate_gates(mega_env["store"], mega_env["bad"], mega_env["good"],
|
| 55 |
+
policy_overrides=overrides)
|
| 56 |
+
assert res["decision"] == "BLOCKED"
|
| 57 |
+
assert "regression" in res["blocking_gates"]
|
| 58 |
+
assert "safety" in res["blocking_gates"]
|
| 59 |
+
by_gate = {g["gate"]: g for g in res["gates"]}
|
| 60 |
+
# regression gate carries the megaeval blockers as evidence
|
| 61 |
+
assert by_gate["regression"]["evidence"]["blockers"]
|
| 62 |
+
# safety gate shows candidate CSI above baseline
|
| 63 |
+
ev = by_gate["safety"]["evidence"]
|
| 64 |
+
assert ev["candidate_csi"] > ev["baseline_csi"]
|
| 65 |
+
# readiness gate blocked and names the first blocking gate
|
| 66 |
+
rr = by_gate["release_readiness"]
|
| 67 |
+
assert rr["status"] == "BLOCK"
|
| 68 |
+
assert rr["evidence"]["first_blocking_gate"] in res["blocking_gates"]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_coverage_gate_blocks_on_impossible_threshold(mega_env, fresh_safety_root,
|
| 72 |
+
eval_store):
|
| 73 |
+
overrides = {**LENIENT,
|
| 74 |
+
"coverage": {"min_coverage_rate": 1.01,
|
| 75 |
+
"min_production_weighted_coverage": 0.0}}
|
| 76 |
+
res = gates.evaluate_gates(mega_env["store"], mega_env["good"], mega_env["good"],
|
| 77 |
+
policy_overrides=overrides)
|
| 78 |
+
assert "coverage" in res["blocking_gates"]
|
| 79 |
+
cov = next(g for g in res["gates"] if g["gate"] == "coverage")
|
| 80 |
+
failed = [c for c in cov["checks"] if not c["passed"]]
|
| 81 |
+
assert failed and failed[0]["check"] == "coverage_rate"
|
| 82 |
+
assert cov["evidence"]["top_gaps"] is not None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_scenario_quality_gate_blocks_on_impossible_threshold(mega_env,
|
| 86 |
+
fresh_safety_root,
|
| 87 |
+
eval_store):
|
| 88 |
+
overrides = {**LENIENT, "scenario_quality": {"min_geometric_pass_rate": 1.01}}
|
| 89 |
+
res = gates.evaluate_gates(mega_env["store"], mega_env["good"], mega_env["good"],
|
| 90 |
+
policy_overrides=overrides)
|
| 91 |
+
assert "scenario_quality" in res["blocking_gates"]
|
| 92 |
+
sq = next(g for g in res["gates"] if g["gate"] == "scenario_quality")
|
| 93 |
+
assert sq["evidence"]["annotations_sampled"] > 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_evidence_package_structure_and_wording(mega_env, fresh_safety_root, eval_store):
|
| 97 |
+
gates.evaluate_gates(mega_env["store"], mega_env["good"], mega_env["good"],
|
| 98 |
+
policy_overrides=LENIENT)
|
| 99 |
+
run_id = mega_env["good"].run_id
|
| 100 |
+
pkg = gates.load_evidence(run_id)
|
| 101 |
+
assert pkg is not None
|
| 102 |
+
assert pkg["package_id"] == f"sep-{run_id}"
|
| 103 |
+
# precise wording: supports a safety case, does not certify
|
| 104 |
+
assert "SUPPORT a safety case" in pkg["disclaimer"]
|
| 105 |
+
assert "not certify" in pkg["disclaimer"]
|
| 106 |
+
# standard mappings cover the three families
|
| 107 |
+
standards = " ".join(m["standard"] for m in pkg["standard_mappings"])
|
| 108 |
+
for token in ("ISO 26262", "21448", "UL 4600"):
|
| 109 |
+
assert token in standards
|
| 110 |
+
# methodology + lineage present
|
| 111 |
+
assert pkg["sampling_methodology"]["config"] is not None
|
| 112 |
+
assert pkg["lineage"]["candidate"]["evaluation_id"] == run_id
|
| 113 |
+
assert pkg["decision"]["release_ready"] is True
|
| 114 |
+
|
| 115 |
+
# persisted gate result retrievable
|
| 116 |
+
result = gates.latest_gate_result(run_id)
|
| 117 |
+
assert result is not None and result["evidence_package_id"] == pkg["package_id"]
|
| 118 |
+
|
| 119 |
+
# markdown rendering
|
| 120 |
+
md = gates.render_markdown(pkg)
|
| 121 |
+
assert "# Safety Evidence Package" in md
|
| 122 |
+
assert "RELEASE READY" in md
|
| 123 |
+
assert "Standard mappings" in md
|
| 124 |
+
assert pkg["disclaimer"][:40] in md
|
tests/test_safety/test_odd.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ODD taxonomy + combinatorial coverage math (exact expectations on a
|
| 2 |
+
hand-built cube) + run-level coverage and gap filling."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from sensorflow.megaeval.cube import STAT_COLS
|
| 12 |
+
from sensorflow.megaeval.population import DIM_NAMES, DIMENSIONS
|
| 13 |
+
from sensorflow.safety import odd
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_taxonomy_structure():
|
| 17 |
+
tax = odd.taxonomy()
|
| 18 |
+
dims = tax["dimensions"]
|
| 19 |
+
for name in DIM_NAMES:
|
| 20 |
+
assert dims[name]["values"] == DIMENSIONS[name]
|
| 21 |
+
assert dims[name]["instrumented"] is True
|
| 22 |
+
assert dims[name]["iso34503_category"] in (
|
| 23 |
+
"scenery", "environmental_conditions", "dynamic_elements",
|
| 24 |
+
"sensor_configuration")
|
| 25 |
+
# declared-but-not-instrumented dimension is honest about itself
|
| 26 |
+
assert dims["geography"]["instrumented"] is False
|
| 27 |
+
assert "ISO 34503" in tax["standard_basis"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _mini_cube() -> pd.DataFrame:
|
| 31 |
+
"""Cube with data in exactly two weather cells:
|
| 32 |
+
clear: tp=90 fn=10 (n=100), rain: tp=8 fn=2 (n=10). fog/snow empty."""
|
| 33 |
+
rows = []
|
| 34 |
+
for weather_code, tp, fn in ((0, 90, 10), (1, 8, 2)):
|
| 35 |
+
row = {d: 0 for d in DIM_NAMES}
|
| 36 |
+
row["weather"] = weather_code
|
| 37 |
+
row.update({c: 0 for c in STAT_COLS})
|
| 38 |
+
row.update({"n": tp + fn, "tp": tp, "fn": fn, "sum_iou": 0.8 * tp})
|
| 39 |
+
rows.append(row)
|
| 40 |
+
return pd.DataFrame(rows)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _wilson(k, n, z=1.959963984540054):
|
| 44 |
+
if n == 0:
|
| 45 |
+
return 0.0, 1.0
|
| 46 |
+
p = k / n
|
| 47 |
+
denom = 1 + z ** 2 / n
|
| 48 |
+
center = (p + z ** 2 / (2 * n)) / denom
|
| 49 |
+
margin = z * math.sqrt(p * (1 - p) / n + z ** 2 / (4 * n ** 2)) / denom
|
| 50 |
+
return max(0.0, center - margin), min(1.0, center + margin)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_coverage_exact_math_on_known_population():
|
| 54 |
+
dim_counts = {"weather": {"clear": 700, "rain": 200, "fog": 80, "snow": 20}}
|
| 55 |
+
cov = odd.compute_coverage(_mini_cube(), dims=["weather"], dim_counts=dim_counts,
|
| 56 |
+
min_samples=20, max_ci_width=0.25, target_recall=0.9)
|
| 57 |
+
cells = {c["cell"]["weather"]: c for c in cov["cells"]}
|
| 58 |
+
assert set(cells) == {"clear", "rain", "fog", "snow"}
|
| 59 |
+
|
| 60 |
+
# clear: n=100, recall 0.9, Wilson CI hand-checked, adequate
|
| 61 |
+
clear = cells["clear"]
|
| 62 |
+
lo, hi = _wilson(90, 100)
|
| 63 |
+
assert clear["n"] == 100
|
| 64 |
+
assert clear["recall"] == pytest.approx(0.9)
|
| 65 |
+
assert clear["wilson_ci"][0] == pytest.approx(lo, abs=2e-3)
|
| 66 |
+
assert clear["wilson_ci"][1] == pytest.approx(hi, abs=2e-3)
|
| 67 |
+
assert clear["adequate"] and not clear["is_gap"]
|
| 68 |
+
assert clear["production_share"] == pytest.approx(0.7)
|
| 69 |
+
# risk = share * max(0, target - wilson_lo)
|
| 70 |
+
assert clear["risk"] == pytest.approx(0.7 * max(0.0, 0.9 - lo), abs=2e-3)
|
| 71 |
+
|
| 72 |
+
# rain: n=10 < 20 -> insufficient samples gap
|
| 73 |
+
rain = cells["rain"]
|
| 74 |
+
assert rain["is_gap"] and "insufficient_samples" in rain["gap_reasons"]
|
| 75 |
+
assert rain["recall"] == pytest.approx(0.8)
|
| 76 |
+
|
| 77 |
+
# empty cells: both gap reasons, CI width 1
|
| 78 |
+
fog = cells["fog"]
|
| 79 |
+
assert fog["n"] == 0 and fog["recall"] is None
|
| 80 |
+
assert set(fog["gap_reasons"]) == {"insufficient_samples", "ci_too_wide"}
|
| 81 |
+
assert fog["ci_width"] == pytest.approx(1.0)
|
| 82 |
+
|
| 83 |
+
# summary: exactly 1 adequate of 4 cells; weighted coverage = clear share
|
| 84 |
+
s = cov["summary"]
|
| 85 |
+
assert s["total_cells"] == 4
|
| 86 |
+
assert s["adequate_cells"] == 1 and s["gap_cells"] == 3
|
| 87 |
+
assert s["coverage_rate"] == pytest.approx(0.25)
|
| 88 |
+
assert s["production_weighted_coverage"] == pytest.approx(0.7)
|
| 89 |
+
|
| 90 |
+
# gaps ranked by risk = share * deficit: rain(share .2) > fog(.08) > snow(.02)
|
| 91 |
+
gap_order = [g["cell"]["weather"] for g in cov["gaps"]]
|
| 92 |
+
assert gap_order == ["rain", "fog", "snow"]
|
| 93 |
+
for g in cov["gaps"]:
|
| 94 |
+
assert g["risk"] == pytest.approx(
|
| 95 |
+
g["production_share"] * g["performance_deficit"], abs=1e-4)
|
| 96 |
+
|
| 97 |
+
# every gap gets a fulfillable request
|
| 98 |
+
assert len(cov["fill_requests"]) == 3
|
| 99 |
+
req = cov["fill_requests"][0]
|
| 100 |
+
assert req["cell"]["weather"] == "rain"
|
| 101 |
+
assert req["needed_samples"] == 10
|
| 102 |
+
assert req["generator"] == "sensorflow.evaluation.synthetic.generate_dataset"
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_coverage_rejects_oversized_cell_product():
|
| 106 |
+
with pytest.raises(ValueError):
|
| 107 |
+
odd.compute_coverage(_mini_cube(), dims=DIM_NAMES) # way over 2000 cells
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_coverage_for_run_consistency(mega_env, safety_root):
|
| 111 |
+
cov = odd.coverage_for_run(mega_env["store"], mega_env["good"],
|
| 112 |
+
dims=["weather", "lighting"], include_cells=True)
|
| 113 |
+
s = cov["summary"]
|
| 114 |
+
assert s["total_cells"] == len(DIMENSIONS["weather"]) * len(DIMENSIONS["lighting"])
|
| 115 |
+
assert s["populated_cells"] + s["empty_cells"] == s["total_cells"]
|
| 116 |
+
assert s["adequate_cells"] + s["gap_cells"] == s["total_cells"]
|
| 117 |
+
# cell mass equals the population's object count (every object in one cell)
|
| 118 |
+
assert sum(c["n"] for c in cov["cells"]) == mega_env["meta"]["num_objects"]
|
| 119 |
+
# adequacy consistent with the thresholds echoed in the response
|
| 120 |
+
th = cov["thresholds"]
|
| 121 |
+
for c in cov["cells"]:
|
| 122 |
+
expected = c["n"] >= th["min_samples"] and c["ci_width"] <= th["max_ci_width"]
|
| 123 |
+
assert c["adequate"] == expected
|
| 124 |
+
# gaps sorted by descending risk
|
| 125 |
+
risks = [g["risk"] for g in cov["gaps"]]
|
| 126 |
+
assert risks == sorted(risks, reverse=True)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_fill_gap_generates_data_and_improves_cell(mega_env, fresh_safety_root, tmp_path):
|
| 130 |
+
from sensorflow.evaluation.records import reset_store
|
| 131 |
+
reset_store(tmp_path / "eval")
|
| 132 |
+
|
| 133 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 134 |
+
cov = odd.coverage_for_run(store, run, dims=["weather", "lighting"],
|
| 135 |
+
include_cells=True, min_samples=10_000)
|
| 136 |
+
gap = cov["gaps"][0] # min_samples=10k guarantees gaps exist
|
| 137 |
+
|
| 138 |
+
result = odd.fill_gap(store, run, gap["cell"], num_sequences=1,
|
| 139 |
+
frames_per_sequence=10)
|
| 140 |
+
assert result["objects_added"] > 0
|
| 141 |
+
assert result["cell_after"]["n"] > result["cell_before"]["n"]
|
| 142 |
+
assert result["cell_after"]["n"] == (result["cell_before"]["n"]
|
| 143 |
+
+ result["objects_added"])
|
| 144 |
+
assert result["supplement"]["source"] == "synthetic"
|
| 145 |
+
|
| 146 |
+
# supplement persisted and folded into subsequent coverage computations
|
| 147 |
+
supplements = odd.load_supplements(run.run_id)
|
| 148 |
+
assert len(supplements) == 1
|
| 149 |
+
cov2 = odd.coverage_for_run(store, run, dims=["weather", "lighting"],
|
| 150 |
+
include_cells=True, min_samples=10_000)
|
| 151 |
+
cell2 = next(c for c in cov2["cells"] if c["cell_id"] == gap["cell_id"])
|
| 152 |
+
assert cell2["n"] == result["cell_after"]["n"]
|
| 153 |
+
assert cell2["synthetic_supplement"]["n_added"] == result["objects_added"]
|
| 154 |
+
|
| 155 |
+
# generated frames carry the requested conditions
|
| 156 |
+
from sensorflow.evaluation.records import get_store
|
| 157 |
+
legacy = get_store()
|
| 158 |
+
frames = legacy.where("frames", dataset_id=result["generated_dataset_id"])
|
| 159 |
+
assert frames
|
| 160 |
+
weather = gap["cell"].get("weather")
|
| 161 |
+
if weather:
|
| 162 |
+
assert all(f.weather == weather for f in frames)
|
| 163 |
+
|
| 164 |
+
# scenario DB registered the synthetic gap-fill scenario
|
| 165 |
+
from sensorflow.safety.scenario_db import get_db
|
| 166 |
+
recs = get_db().search(source="synthetic")
|
| 167 |
+
assert any(r.scenario_type == "odd_gap_fill" for r in recs)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_fill_gap_rejects_unknown_cell(mega_env, fresh_safety_root):
|
| 171 |
+
with pytest.raises(ValueError):
|
| 172 |
+
odd.fill_gap(mega_env["store"], mega_env["good"], {"weather": "hurricane"})
|
tests/test_safety/test_scenario_db.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scenario database: populate (idempotently) from rare events, search/filter,
|
| 2 |
+
export bundle."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from sensorflow.evaluation.records import RareEvent
|
| 7 |
+
from sensorflow.safety.scenario_db import ScenarioRecord, get_db
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _make_rare_event(store, ds, frame, severity="high"):
|
| 11 |
+
ev = RareEvent(
|
| 12 |
+
event_id=f"evt-test-{severity}",
|
| 13 |
+
dataset_id=ds.dataset_id,
|
| 14 |
+
scenario_type="near_collision",
|
| 15 |
+
severity=severity,
|
| 16 |
+
rarity_score=0.9,
|
| 17 |
+
confidence=0.8,
|
| 18 |
+
evidence_frames=[frame.frame_id],
|
| 19 |
+
description="test rare event",
|
| 20 |
+
)
|
| 21 |
+
store.put("rare_events", ev)
|
| 22 |
+
return ev
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_populate_from_rare_events_idempotent(eval_env, fresh_safety_root):
|
| 26 |
+
store, ds = eval_env
|
| 27 |
+
frame = store.where("frames", dataset_id=ds.dataset_id)[0]
|
| 28 |
+
_make_rare_event(store, ds, frame)
|
| 29 |
+
|
| 30 |
+
db = get_db()
|
| 31 |
+
assert db.add_from_rare_events(store) == 1
|
| 32 |
+
assert db.add_from_rare_events(store) == 0 # idempotent
|
| 33 |
+
|
| 34 |
+
recs = db.search(source="rare_event")
|
| 35 |
+
assert len(recs) == 1
|
| 36 |
+
r = recs[0]
|
| 37 |
+
assert r.scenario_type == "near_collision"
|
| 38 |
+
assert r.odd_tags.get("weather") == frame.weather
|
| 39 |
+
assert f"rare_event:{'evt-test-high'}" in r.evidence_refs
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_search_filters_and_ordering(fresh_safety_root):
|
| 43 |
+
db = get_db()
|
| 44 |
+
db.add(ScenarioRecord(scenario_id="s1", scenario_type="cut_in", source="mined",
|
| 45 |
+
severity="low", odd_tags={"weather": "rain"},
|
| 46 |
+
description="cut-in in rain"))
|
| 47 |
+
db.add(ScenarioRecord(scenario_id="s2", scenario_type="cut_in", source="synthetic",
|
| 48 |
+
severity="critical", odd_tags={"weather": "clear"},
|
| 49 |
+
description="synthetic cut-in"))
|
| 50 |
+
db.add(ScenarioRecord(scenario_id="s3", scenario_type="jaywalk", source="mined",
|
| 51 |
+
severity="high", odd_tags={"weather": "rain"},
|
| 52 |
+
description="pedestrian jaywalking at night"))
|
| 53 |
+
|
| 54 |
+
assert {r.scenario_id for r in db.search(scenario_type="cut_in")} == {"s1", "s2"}
|
| 55 |
+
assert {r.scenario_id for r in db.search(source="mined")} == {"s1", "s3"}
|
| 56 |
+
assert {r.scenario_id for r in db.search(severity="critical")} == {"s2"}
|
| 57 |
+
assert {r.scenario_id for r in db.search(odd_tags={"weather": "rain"})} == {"s1", "s3"}
|
| 58 |
+
assert {r.scenario_id for r in db.search(text="jaywalk")} == {"s3"}
|
| 59 |
+
# ordering: highest severity first
|
| 60 |
+
assert [r.scenario_id for r in db.search()][:2] == ["s2", "s3"]
|
| 61 |
+
|
| 62 |
+
counts = db.counts()
|
| 63 |
+
assert counts["total"] == 3
|
| 64 |
+
assert counts["by_source"] == {"mined": 2, "synthetic": 1}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_persistence_across_reload(fresh_safety_root):
|
| 68 |
+
db = get_db()
|
| 69 |
+
db.add(ScenarioRecord(scenario_id="sp1", scenario_type="cut_in", source="mined",
|
| 70 |
+
severity="medium"))
|
| 71 |
+
from sensorflow.safety import scenario_db as mod
|
| 72 |
+
mod.reset_db() # force reload from disk
|
| 73 |
+
assert [r.scenario_id for r in get_db().search()] == ["sp1"]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_export_bundle(fresh_safety_root):
|
| 77 |
+
db = get_db()
|
| 78 |
+
db.add(ScenarioRecord(scenario_id="e1", scenario_type="cut_in", source="mined",
|
| 79 |
+
severity="high"))
|
| 80 |
+
db.add(ScenarioRecord(scenario_id="e2", scenario_type="jaywalk",
|
| 81 |
+
source="discrepancy", severity="low"))
|
| 82 |
+
bundle = db.export_bundle(source="mined")
|
| 83 |
+
assert bundle["bundle_format"] == "sensorflow-scenario-bundle/v1"
|
| 84 |
+
assert bundle["count"] == 1
|
| 85 |
+
assert bundle["scenarios"][0]["scenario_id"] == "e1"
|
| 86 |
+
full = db.export_bundle()
|
| 87 |
+
assert full["count"] == 2
|
tests/test_safety/test_semantic.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Neuro-symbolic semantic mining: deterministic offline ranking with per-stage
|
| 2 |
+
explanations, symbolic filters, and hybrid retrieval over containers +
|
| 3 |
+
scenario-DB records."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from sensorflow.safety import semantic
|
| 8 |
+
from sensorflow.safety.scenario_db import ScenarioRecord, get_db
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_container_search_deterministic_and_explained(mega_env, safety_root):
|
| 12 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 13 |
+
q = "pedestrian near miss at night in rain"
|
| 14 |
+
r1 = semantic.search_containers(store, run, q, k=10, use_llm=False)
|
| 15 |
+
r2 = semantic.search_containers(store, run, q, k=10, use_llm=False)
|
| 16 |
+
assert r1["results"] == r2["results"] # deterministic offline ranking
|
| 17 |
+
|
| 18 |
+
assert r1["stage2"]["provider"] == "offline_deterministic"
|
| 19 |
+
results = r1["results"]
|
| 20 |
+
assert results
|
| 21 |
+
scores = [r["score"] for r in results]
|
| 22 |
+
assert scores == sorted(scores, reverse=True)
|
| 23 |
+
top = results[0]
|
| 24 |
+
# concept terms must be reflected in structured evidence
|
| 25 |
+
assert top["lighting"] == "night"
|
| 26 |
+
assert top["weather"] != "clear"
|
| 27 |
+
reasons = top["explanations"]["stage2_reasoning"]
|
| 28 |
+
assert reasons and all({"term", "meaning", "evidence"} <= set(m) for m in reasons)
|
| 29 |
+
# hybrid retrieval blended embedding similarity
|
| 30 |
+
assert r1["hybrid"]["embedding_weight"] > 0
|
| 31 |
+
assert top["embedding_similarity"] is not None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_container_search_symbolic_filter(mega_env, safety_root):
|
| 35 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 36 |
+
res = semantic.search_containers(store, run, "risky scenes",
|
| 37 |
+
filters={"lighting": "night"}, k=20,
|
| 38 |
+
use_llm=False)
|
| 39 |
+
assert res["stage1"]["applied_filters"] == {"lighting": ["night"]}
|
| 40 |
+
assert res["stage1"]["candidates_after"] < res["stage1"]["candidates_before"]
|
| 41 |
+
assert all(r["lighting"] == "night" for r in res["results"])
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_container_search_fallback_on_unmatched_concept(mega_env, safety_root):
|
| 45 |
+
store, run = mega_env["store"], mega_env["good"]
|
| 46 |
+
res = semantic.search_containers(store, run, "xylophone zeitgeist", k=5,
|
| 47 |
+
use_llm=False)
|
| 48 |
+
assert res["results"]
|
| 49 |
+
reasons = res["results"][0]["explanations"]["stage2_reasoning"]
|
| 50 |
+
assert any("no lexicon term matched" in m["meaning"] for m in reasons)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_scenario_search_ranks_matching_odd_tags(fresh_safety_root):
|
| 54 |
+
db = get_db()
|
| 55 |
+
db.add(ScenarioRecord(scenario_id="n1", scenario_type="near_miss",
|
| 56 |
+
source="mined", severity="high",
|
| 57 |
+
odd_tags={"weather": "rain", "lighting": "night",
|
| 58 |
+
"actor_class": "pedestrian"},
|
| 59 |
+
description="pedestrian near miss in night rain"))
|
| 60 |
+
db.add(ScenarioRecord(scenario_id="d1", scenario_type="nominal",
|
| 61 |
+
source="synthetic", severity="low",
|
| 62 |
+
odd_tags={"weather": "clear", "lighting": "day"},
|
| 63 |
+
description="clear day nominal drive"))
|
| 64 |
+
|
| 65 |
+
res = semantic.search_scenarios("pedestrian near miss at night in rain", k=5)
|
| 66 |
+
assert res["results"][0]["scenario_id"] == "n1"
|
| 67 |
+
assert res["results"][0]["score"] > res["results"][-1]["score"]
|
| 68 |
+
assert res["stage2"]["provider"] == "offline_deterministic"
|
| 69 |
+
|
| 70 |
+
filtered = semantic.search_scenarios("anything", filters={"source": "synthetic"})
|
| 71 |
+
assert [r["scenario_id"] for r in filtered["results"]] == ["d1"]
|
tests/test_safety/test_ssam.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extended SSAM measures against hand-computed values (TTC, DRAC, collision
|
| 2 |
+
probability, DeltaS/MaxS, CSI aggregation, zone-grid PET) plus the synthetic
|
| 3 |
+
scenario suite and model-conditioned CSI."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from sensorflow.safety import ssam_ext
|
| 12 |
+
|
| 13 |
+
CAR = (4.5, 1.9)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _state(t, x, y, speed, heading=0.0):
|
| 17 |
+
return {"t": t, "x": x, "y": y, "speed": speed, "heading": heading}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_projected_ttc_hand_computed():
|
| 21 |
+
# A at x=0 doing 10 m/s toward stationary B at x=20, both 4.5 m long:
|
| 22 |
+
# bumper gap = 20 - 2.25 - 2.25 = 15.5 m -> TTC = 1.55 s.
|
| 23 |
+
a = _state(0.0, 0.0, 0.0, 10.0)
|
| 24 |
+
b = _state(0.0, 20.0, 0.0, 0.0)
|
| 25 |
+
ttc = ssam_ext.projected_ttc(a, CAR, b, CAR, look_ahead=3.0, dt=0.05)
|
| 26 |
+
assert ttc == pytest.approx(1.55, abs=0.02)
|
| 27 |
+
|
| 28 |
+
# diverging pair -> no TTC
|
| 29 |
+
b_away = _state(0.0, 20.0, 0.0, 15.0) # same heading, faster, pulling away
|
| 30 |
+
assert ssam_ext.projected_ttc(a, CAR, b_away, CAR, 3.0, 0.05) is None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_drac_hand_computed():
|
| 34 |
+
# closing speed 10 m/s, gap 15.5 m -> DRAC = 10^2 / (2*15.5) = 3.2258 m/s^2
|
| 35 |
+
a = _state(0.0, 0.0, 0.0, 10.0)
|
| 36 |
+
b = _state(0.0, 20.0, 0.0, 0.0)
|
| 37 |
+
assert ssam_ext.drac(a, CAR, b, CAR) == pytest.approx(100 / 31, abs=1e-6)
|
| 38 |
+
# opening geometry -> None
|
| 39 |
+
a_rev = _state(0.0, 0.0, 0.0, 10.0, heading=math.pi)
|
| 40 |
+
assert ssam_ext.drac(a_rev, CAR, b, CAR) is None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_collision_probability_formula():
|
| 44 |
+
assert ssam_ext.collision_probability(None, 1.5) == 0.0
|
| 45 |
+
assert ssam_ext.collision_probability(1.5, 1.5) == 0.0
|
| 46 |
+
assert ssam_ext.collision_probability(0.75, 1.5) == pytest.approx(0.5)
|
| 47 |
+
assert ssam_ext.collision_probability(0.0, 1.5) == 1.0
|
| 48 |
+
assert ssam_ext.collision_probability(9.0, 1.5) == 0.0 # clipped
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _head_on_pair(dt=0.1, duration=2.0):
|
| 52 |
+
"""A drives at 10 m/s toward stationary B at x=30 (same lane)."""
|
| 53 |
+
a = {"vehicle_id": "A", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 54 |
+
"states": [_state(round(i * dt, 6), 10.0 * i * dt, 0.0, 10.0)
|
| 55 |
+
for i in range(int(duration / dt) + 1)]}
|
| 56 |
+
b = {"vehicle_id": "B", "vehicle_type": "car", "length": 4.5, "width": 1.9,
|
| 57 |
+
"states": [_state(round(i * dt, 6), 30.0, 0.0, 0.0)
|
| 58 |
+
for i in range(int(duration / dt) + 1)]}
|
| 59 |
+
return a, b
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_analyze_trajectories_measures_and_csi_aggregation():
|
| 63 |
+
a, b = _head_on_pair()
|
| 64 |
+
res = ssam_ext.analyze_trajectories([a, b])
|
| 65 |
+
agg = res["aggregate"]
|
| 66 |
+
assert agg["num_conflicts"] == 1
|
| 67 |
+
conflict = res["conflicts"][0]
|
| 68 |
+
|
| 69 |
+
# severity proxies: relative speed 10, max abs speed 10, rear-end geometry
|
| 70 |
+
assert conflict["delta_s_mps"] == pytest.approx(10.0, abs=1e-6)
|
| 71 |
+
assert conflict["max_s_mps"] == pytest.approx(10.0, abs=1e-6)
|
| 72 |
+
assert conflict["conflict_type"] == "rear_end"
|
| 73 |
+
assert 0.0 < conflict["collision_probability"] <= 1.0
|
| 74 |
+
|
| 75 |
+
# CSI aggregation re-derived with an independent loop over the same
|
| 76 |
+
# primitives: CSI = sum 0.5 * mu * DeltaS^2 * p(t) * dt (mu reduced mass)
|
| 77 |
+
p = res["params"]
|
| 78 |
+
mu = 0.5 # two cars: 1*1/(1+1)
|
| 79 |
+
expected = 0.0
|
| 80 |
+
for sa, sb in zip(a["states"], b["states"]):
|
| 81 |
+
ttc = ssam_ext.projected_ttc(sa, CAR, sb, CAR,
|
| 82 |
+
p["look_ahead_s"], p["projection_dt_s"])
|
| 83 |
+
if ttc is None or ttc > p["ttc_threshold_s"]:
|
| 84 |
+
continue
|
| 85 |
+
prob = ssam_ext.collision_probability(ttc, p["ttc_threshold_s"])
|
| 86 |
+
expected += 0.5 * mu * 10.0 ** 2 * prob * 0.1
|
| 87 |
+
assert agg["aggregate_csi"] == pytest.approx(expected, rel=1e-6)
|
| 88 |
+
assert agg["aggregate_csi"] > 0
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_zone_grid_pet_hand_computed():
|
| 92 |
+
# A crosses the origin cell around t=1.0 (moving +x through x=0),
|
| 93 |
+
# B crosses the same spot around t=3.0 (moving +y through y=0):
|
| 94 |
+
# PET ~= 2.0 s (grid quantization tolerance from finite footprints).
|
| 95 |
+
dt = 0.1
|
| 96 |
+
a = {"vehicle_id": "A", "length": 1.0, "width": 1.0,
|
| 97 |
+
"states": [_state(round(i * dt, 6), -10.0 + 10.0 * i * dt, 0.0, 10.0)
|
| 98 |
+
for i in range(21)]}
|
| 99 |
+
b = {"vehicle_id": "B", "length": 1.0, "width": 1.0,
|
| 100 |
+
"states": [{"t": round(2.0 + i * dt, 6), "x": 0.0, "y": -10.0 + 10.0 * i * dt,
|
| 101 |
+
"speed": 10.0, "heading": math.pi / 2} for i in range(21)]}
|
| 102 |
+
pet = ssam_ext.zone_grid_pet(a, b, cell=0.5)
|
| 103 |
+
assert pet is not None
|
| 104 |
+
assert pet == pytest.approx(2.0, abs=0.35)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_scenario_suite_produces_conflicts():
|
| 108 |
+
for scenario in ("crossing", "rear_end", "lane_change"):
|
| 109 |
+
trajs = ssam_ext.generate_trajectories(seed=0, scenario=scenario,
|
| 110 |
+
reaction_delay_s=0.6)
|
| 111 |
+
res = ssam_ext.analyze_trajectories(trajs)
|
| 112 |
+
assert res["aggregate"]["num_conflicts"] >= 1, scenario
|
| 113 |
+
assert res["aggregate"]["aggregate_csi"] > 0, scenario
|
| 114 |
+
with pytest.raises(ValueError):
|
| 115 |
+
ssam_ext.generate_trajectories(scenario="teleportation")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def test_reaction_delay_monotonic_and_csi_increases():
|
| 119 |
+
base = ssam_ext.reaction_delay_for_model("model-v41")
|
| 120 |
+
worse = ssam_ext.reaction_delay_for_model(
|
| 121 |
+
"model-v42-regressed", overrides={"night_penalty": 0.35, "vru_penalty": 0.12})
|
| 122 |
+
assert worse["reaction_delay_s"] > base["reaction_delay_s"]
|
| 123 |
+
assert base["simulated"] is True
|
| 124 |
+
|
| 125 |
+
csis = []
|
| 126 |
+
for delay in (base["reaction_delay_s"], worse["reaction_delay_s"]):
|
| 127 |
+
total = 0.0
|
| 128 |
+
for scenario in ("crossing", "rear_end", "lane_change"):
|
| 129 |
+
trajs = ssam_ext.generate_trajectories(seed=0, scenario=scenario,
|
| 130 |
+
reaction_delay_s=delay)
|
| 131 |
+
total += ssam_ext.analyze_trajectories(trajs)["aggregate"]["aggregate_csi"]
|
| 132 |
+
csis.append(total)
|
| 133 |
+
assert csis[1] > csis[0]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_csi_for_run_cached_and_ordered(mega_env, safety_root):
|
| 137 |
+
good = ssam_ext.csi_for_run(mega_env["good"])
|
| 138 |
+
bad = ssam_ext.csi_for_run(mega_env["bad"])
|
| 139 |
+
assert good["aggregate_csi"] > 0
|
| 140 |
+
assert bad["aggregate_csi"] > good["aggregate_csi"]
|
| 141 |
+
assert good["simulated"] is True
|
| 142 |
+
# cached second read is identical
|
| 143 |
+
again = ssam_ext.csi_for_run(mega_env["good"])
|
| 144 |
+
assert again == good
|
tests/test_seqeval/__init__.py
ADDED
|
File without changes
|
tests/test_seqeval/conftest.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures: isolated megaeval + seqeval roots and one mid-size
|
| 2 |
+
population reused by every seqeval test module."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from sensorflow.megaeval import population as pop_mod
|
| 9 |
+
from sensorflow.seqeval import ledger as ledger_mod
|
| 10 |
+
from sensorflow.seqeval import paired as paired_mod
|
| 11 |
+
from sensorflow.seqeval.controller import reset_seqeval_store
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture(scope="session")
|
| 15 |
+
def seq_env(tmp_path_factory):
|
| 16 |
+
root = tmp_path_factory.mktemp("seqeval_env")
|
| 17 |
+
pop_mod.set_mega_root(str(root / "mega"))
|
| 18 |
+
ledger_mod.set_seqeval_root(str(root / "seq"))
|
| 19 |
+
reset_seqeval_store()
|
| 20 |
+
paired_mod.reset_prediction_cache()
|
| 21 |
+
meta = pop_mod.generate_population("seq-pop", num_objects=90_000, seed=11)
|
| 22 |
+
yield {"root": root, "meta": meta}
|
| 23 |
+
pop_mod.set_mega_root("runs/megaeval")
|
| 24 |
+
ledger_mod.set_seqeval_root("runs/seqeval")
|
| 25 |
+
reset_seqeval_store()
|
| 26 |
+
paired_mod.reset_prediction_cache()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# A compact policy for fast Monte-Carlo style tests.
|
| 30 |
+
FAST_POLICY = {
|
| 31 |
+
"target_n": 6000,
|
| 32 |
+
"safety_floor": 800,
|
| 33 |
+
"min_per_stratum": 120,
|
| 34 |
+
"escalation": {"enabled": True, "max_extra_per_stratum": 600,
|
| 35 |
+
"batch_objects": 300},
|
| 36 |
+
}
|