whassan commited on
Commit
9762da0
·
verified ·
1 Parent(s): fb23187

Deploy Sensorflow Studio

Browse files
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
+ }