whassan commited on
Commit
e1c927b
·
verified ·
1 Parent(s): 95eecc3

Deploy Sensorflow Studio (part 2)

Browse files
sensorflow/rotr/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ROTR — Right-of-the-Road violation detection, triage, evaluation & training.
2
+
3
+ Causal-layer separation is the load-bearing constraint of this package: a
4
+ ROTR violation is NEVER auto-attributed to perception. Detection (rules.py),
5
+ attribution (attribution.py), consequence (consequence.py), statistics
6
+ (metrics.py, delegating to seqeval), mining (taxonomy.py) and governance
7
+ (flywheel.py, stopship.py) are separate modules with typed contracts
8
+ (models.py). Architecture: docs/architecture/rotr-architecture.md.
9
+ """
10
+
11
+ SOFTWARE_VERSION = "rotr-0.1.0"
sensorflow/rotr/api.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """REST surface for the ROTR capability: APIRouter under /api/rotr.
2
+
3
+ Request-scoped over file persistence (platform convention); no background
4
+ services. Endpoints cover the full loop: bank generation, detection runs,
5
+ violation inspection with taxonomy queries, the attribution matrix,
6
+ consequence replay, metrics + regression, clusters, HITL actions, flywheel
7
+ artifacts + contamination guard, stop-ship policy, and docs serving."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ from fastapi import APIRouter, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ from sensorflow.rotr import SOFTWARE_VERSION, pipeline, store
18
+ from sensorflow.rotr import flywheel as flywheel_mod
19
+ from sensorflow.rotr.flywheel import ContaminationError
20
+ from sensorflow.rotr.metrics import evaluate_candidate
21
+ from sensorflow.rotr.models import now_iso
22
+ from sensorflow.rotr.rules import CONFIG, RULES, RULESET_VERSION
23
+ from sensorflow.rotr.scenes import MODEL_PROFILES
24
+ from sensorflow.rotr.stopship import STOPSHIP_POLICY, policy_version
25
+ from sensorflow.rotr.taxonomy import ROTRQuery, matches, parse_query
26
+
27
+ router = APIRouter(prefix="/api/rotr", tags=["rotr"])
28
+
29
+
30
+ @router.get("/health")
31
+ def health() -> Dict:
32
+ return {"status": "ok", "software_version": SOFTWARE_VERSION,
33
+ "ruleset_version": RULESET_VERSION,
34
+ "stopship_policy_version": policy_version(),
35
+ "time": now_iso()}
36
+
37
+
38
+ # ------------------------------------------------------------ banks
39
+
40
+
41
+ class BankRequest(BaseModel):
42
+ n_scenarios: int = 28
43
+ seed: int = 7
44
+ model_version: str = "stack-v1"
45
+
46
+
47
+ @router.post("/banks")
48
+ def create_bank(req: BankRequest) -> Dict:
49
+ if req.model_version not in MODEL_PROFILES:
50
+ raise HTTPException(422, f"unknown model_version {req.model_version}; "
51
+ f"known: {sorted(MODEL_PROFILES)}")
52
+ if not (1 <= req.n_scenarios <= 500):
53
+ raise HTTPException(422, "n_scenarios must be in [1, 500]")
54
+ return pipeline.create_bank(req.n_scenarios, req.seed, req.model_version)
55
+
56
+
57
+ @router.get("/banks")
58
+ def list_banks() -> List[Dict]:
59
+ return pipeline.list_banks()
60
+
61
+
62
+ @router.get("/banks/{bank_id}")
63
+ def get_bank(bank_id: str) -> Dict:
64
+ doc = pipeline.load_bank(bank_id)
65
+ if doc is None:
66
+ raise HTTPException(404, f"unknown bank {bank_id}")
67
+ return pipeline.bank_summary(doc)
68
+
69
+
70
+ @router.get("/banks/{bank_id}/scenarios/{scenario_id}")
71
+ def get_scenario(bank_id: str, scenario_id: str) -> Dict:
72
+ sc = pipeline.load_scenario(bank_id, scenario_id)
73
+ if sc is None:
74
+ raise HTTPException(404, f"unknown scenario {scenario_id}")
75
+ return sc.model_dump()
76
+
77
+
78
+ # ------------------------------------------------------------ runs
79
+
80
+
81
+ class RunRequest(BaseModel):
82
+ bank_id: Optional[str] = None
83
+ n_scenarios: int = 28
84
+ seed: int = 7
85
+ model_version: str = "stack-v1"
86
+ forward_to_agentic: bool = False
87
+
88
+
89
+ @router.post("/runs")
90
+ def create_run(req: RunRequest) -> Dict:
91
+ try:
92
+ doc = pipeline.execute_run(req.bank_id, req.n_scenarios, req.seed,
93
+ req.model_version,
94
+ forward_to_agentic=req.forward_to_agentic)
95
+ except KeyError as e:
96
+ raise HTTPException(404, str(e))
97
+ except ValueError as e:
98
+ raise HTTPException(422, str(e))
99
+ return _run_summary(doc)
100
+
101
+
102
+ @router.get("/runs")
103
+ def list_runs() -> List[Dict]:
104
+ return pipeline.list_runs()
105
+
106
+
107
+ def _run_summary(doc: Dict) -> Dict:
108
+ return {k: doc[k] for k in
109
+ ("run_id", "bank_id", "model_version", "ruleset_version",
110
+ "n_scenarios", "scenario_summaries", "metrics", "clusters",
111
+ "gate")} | {"n_violations": len(doc["violations"])}
112
+
113
+
114
+ @router.get("/runs/{run_id}")
115
+ def get_run(run_id: str) -> Dict:
116
+ doc = pipeline.load_run(run_id)
117
+ if doc is None:
118
+ raise HTTPException(404, f"unknown run {run_id}")
119
+ return _run_summary(doc)
120
+
121
+
122
+ # ------------------------------------------------------------ violations
123
+
124
+
125
+ @router.get("/runs/{run_id}/violations")
126
+ def list_violations(run_id: str, actor: Optional[str] = None,
127
+ vulnerability: Optional[str] = None,
128
+ legality: Optional[str] = None,
129
+ environment: Optional[str] = None,
130
+ interaction: Optional[str] = None,
131
+ behavior: Optional[str] = None,
132
+ road_geometry: Optional[str] = None,
133
+ traffic_control: Optional[str] = None,
134
+ visibility: Optional[str] = None,
135
+ lighting: Optional[str] = None,
136
+ weather: Optional[str] = None,
137
+ consequence_class: Optional[str] = None,
138
+ primary_layer: Optional[str] = None,
139
+ text: Optional[str] = None) -> Dict:
140
+ doc = pipeline.load_run(run_id)
141
+ if doc is None:
142
+ raise HTTPException(404, f"unknown run {run_id}")
143
+ query = parse_query(text) if text else ROTRQuery()
144
+ explicit = {"actor": actor, "vulnerability": vulnerability,
145
+ "legality": legality, "environment": environment,
146
+ "interaction": interaction, "behavior": behavior,
147
+ "road_geometry": road_geometry,
148
+ "traffic_control": traffic_control, "visibility": visibility,
149
+ "lighting": lighting, "weather": weather,
150
+ "consequence_class": consequence_class,
151
+ "primary_layer": primary_layer}
152
+ for axis, val in explicit.items():
153
+ if val is not None:
154
+ setattr(query, axis, val)
155
+ return _query_run(doc, query)
156
+
157
+
158
+ class QueryRequest(BaseModel):
159
+ run_id: str
160
+ text: Optional[str] = None
161
+ filters: Optional[Dict[str, str]] = None
162
+
163
+
164
+ @router.post("/query")
165
+ def structured_query(req: QueryRequest) -> Dict:
166
+ doc = pipeline.load_run(req.run_id)
167
+ if doc is None:
168
+ raise HTTPException(404, f"unknown run {req.run_id}")
169
+ query = parse_query(req.text) if req.text else ROTRQuery()
170
+ for k, v in (req.filters or {}).items():
171
+ if not hasattr(query, k):
172
+ raise HTTPException(422, f"unknown filter axis {k!r}")
173
+ setattr(query, k, v)
174
+ return _query_run(doc, query)
175
+
176
+
177
+ def _query_run(doc: Dict, query: ROTRQuery) -> Dict:
178
+ env_by_scenario = {s["scenario_id"]: s for s in doc["scenario_summaries"]}
179
+ cluster_by_vid = {}
180
+ for c in doc["clusters"]:
181
+ for vid in c["member_violation_ids"]:
182
+ cluster_by_vid[vid] = c["cluster_id"]
183
+ results = []
184
+ for v in doc["violations"]:
185
+ attr = doc["attributions"].get(v["violation_id"], {})
186
+ cons = doc["consequences"].get(v["violation_id"], {})
187
+ env = env_by_scenario.get(v["scenario_id"], {})
188
+ if not matches(query, v.get("taxonomy", {}),
189
+ cons.get("consequence_class"),
190
+ attr.get("primary_layer"),
191
+ {"lighting": env.get("lighting"),
192
+ "weather": env.get("weather")}):
193
+ continue
194
+ results.append({
195
+ "violation_id": v["violation_id"],
196
+ "scenario_id": v["scenario_id"],
197
+ "rule_id": v["rule_id"],
198
+ "rule_version": v["rule_version"],
199
+ "description": v["description"],
200
+ "taxonomy": v.get("taxonomy", {}),
201
+ "evidence": v["evidence"],
202
+ "confidence": v["confidence"],
203
+ "primary_layer": attr.get("primary_layer"),
204
+ "consequence_class": cons.get("consequence_class"),
205
+ "cluster_id": cluster_by_vid.get(v["violation_id"]),
206
+ "environment": {"visibility": env.get("visibility"),
207
+ "lighting": env.get("lighting"),
208
+ "weather": env.get("weather")},
209
+ "provenance": v["provenance"],
210
+ })
211
+ return {"query": query.model_dump(), "n_results": len(results),
212
+ "results": results}
213
+
214
+
215
+ @router.get("/runs/{run_id}/violations/{violation_id}")
216
+ def get_violation(run_id: str, violation_id: str) -> Dict:
217
+ doc = pipeline.load_run(run_id)
218
+ if doc is None:
219
+ raise HTTPException(404, f"unknown run {run_id}")
220
+ v = next((v for v in doc["violations"]
221
+ if v["violation_id"] == violation_id), None)
222
+ if v is None:
223
+ raise HTTPException(404, f"unknown violation {violation_id}")
224
+ return {"violation": v,
225
+ "attribution": doc["attributions"].get(violation_id),
226
+ "consequence": doc["consequences"].get(violation_id)}
227
+
228
+
229
+ @router.get("/runs/{run_id}/attribution")
230
+ def attribution_matrix(run_id: str) -> Dict:
231
+ doc = pipeline.load_run(run_id)
232
+ if doc is None:
233
+ raise HTTPException(404, f"unknown run {run_id}")
234
+ rows = []
235
+ for v in doc["violations"]:
236
+ attr = doc["attributions"].get(v["violation_id"], {})
237
+ rows.append({
238
+ "violation_id": v["violation_id"],
239
+ "scenario_id": v["scenario_id"],
240
+ "rule_id": v["rule_id"],
241
+ "primary_layer": attr.get("primary_layer"),
242
+ "layers": attr.get("layers", {}),
243
+ "note": attr.get("note", ""),
244
+ })
245
+ return {"run_id": run_id, "rows": rows,
246
+ "invariant": "downstream failure never auto-implies perception; "
247
+ "layers are implicated only by their own positive "
248
+ "evidence"}
249
+
250
+
251
+ @router.get("/runs/{run_id}/violations/{violation_id}/consequence")
252
+ def get_consequence(run_id: str, violation_id: str) -> Dict:
253
+ doc = pipeline.load_consequence(run_id, violation_id)
254
+ if doc is None:
255
+ raise HTTPException(404, "no consequence replay for "
256
+ f"{violation_id} in {run_id}")
257
+ # Attach the scenario geometry the UI needs for the BEV replay view.
258
+ run = pipeline.load_run(run_id)
259
+ if run:
260
+ v = next((v for v in run["violations"]
261
+ if v["violation_id"] == violation_id), None)
262
+ if v:
263
+ sc = pipeline.load_scenario(run["bank_id"], v["scenario_id"])
264
+ if sc:
265
+ doc["scenario_geometry"] = {
266
+ "actual_context": sc.actual_context.model_dump(),
267
+ "actors": [{
268
+ "actor_id": a.actor_id, "class_name": a.class_name,
269
+ "dims": a.dims,
270
+ "states": [{"t": s.t, "x": s.x, "y": s.y,
271
+ "yaw": s.yaw} for s in a.states],
272
+ } for a in sc.actors],
273
+ }
274
+ return doc
275
+
276
+
277
+ # ------------------------------------------------------------ metrics + regression
278
+
279
+
280
+ @router.get("/runs/{run_id}/metrics")
281
+ def get_metrics(run_id: str) -> Dict:
282
+ doc = pipeline.load_run(run_id)
283
+ if doc is None:
284
+ raise HTTPException(404, f"unknown run {run_id}")
285
+ return doc["metrics"]
286
+
287
+
288
+ @router.get("/runs/{run_id}/clusters")
289
+ def get_clusters(run_id: str) -> List[Dict]:
290
+ doc = pipeline.load_run(run_id)
291
+ if doc is None:
292
+ raise HTTPException(404, f"unknown run {run_id}")
293
+ return doc["clusters"]
294
+
295
+
296
+ class RegressionRequest(BaseModel):
297
+ baseline_run_id: str
298
+ candidate_run_id: str
299
+ seed: int = 11
300
+
301
+
302
+ @router.post("/regression")
303
+ def run_regression(req: RegressionRequest) -> Dict:
304
+ base = pipeline.load_run(req.baseline_run_id)
305
+ cand = pipeline.load_run(req.candidate_run_id)
306
+ if base is None or cand is None:
307
+ raise HTTPException(404, "unknown baseline or candidate run")
308
+ rid = f"reg-{req.baseline_run_id}-vs-{req.candidate_run_id}"
309
+ result = evaluate_candidate(rid, base, cand, seed=req.seed)
310
+ store.write_json(result.model_dump(), "regressions", f"{rid}.json")
311
+ return result.model_dump()
312
+
313
+
314
+ @router.get("/regressions")
315
+ def list_regressions() -> List[Dict]:
316
+ out = []
317
+ for name in store.list_dir("regressions"):
318
+ doc = store.read_json("regressions", name)
319
+ if doc:
320
+ out.append(doc)
321
+ return out
322
+
323
+
324
+ # ------------------------------------------------------------ HITL + flywheel
325
+
326
+
327
+ @router.get("/runs/{run_id}/hitl")
328
+ def hitl_queue(run_id: str) -> List[Dict]:
329
+ if pipeline.load_run(run_id) is None:
330
+ raise HTTPException(404, f"unknown run {run_id}")
331
+ return flywheel_mod.get_queue(run_id)
332
+
333
+
334
+ class HITLActionRequest(BaseModel):
335
+ run_id: str
336
+ review_id: str
337
+ action: str # VALIDATE | REJECT
338
+ actor: str
339
+ notes: str = ""
340
+
341
+
342
+ @router.post("/hitl/action")
343
+ def hitl_action(req: HITLActionRequest) -> Dict:
344
+ try:
345
+ return flywheel_mod.act(req.run_id, req.review_id, req.action,
346
+ req.actor, req.notes)
347
+ except KeyError as e:
348
+ raise HTTPException(404, str(e))
349
+ except ValueError as e:
350
+ raise HTTPException(422, str(e))
351
+
352
+
353
+ @router.get("/flywheel/suite")
354
+ def regression_suite() -> Dict:
355
+ return flywheel_mod.get_suite()
356
+
357
+
358
+ @router.get("/flywheel/candidates")
359
+ def training_candidates() -> List[Dict]:
360
+ return flywheel_mod.list_candidates()
361
+
362
+
363
+ class PromoteRequest(BaseModel):
364
+ candidate_id: str
365
+ actor: str
366
+
367
+
368
+ @router.post("/flywheel/promote")
369
+ def promote(req: PromoteRequest) -> Dict:
370
+ try:
371
+ return flywheel_mod.promote_to_training(req.candidate_id, req.actor)
372
+ except ContaminationError as e:
373
+ raise HTTPException(409, str(e))
374
+ except KeyError as e:
375
+ raise HTTPException(404, str(e))
376
+
377
+
378
+ class OverrideRequest(BaseModel):
379
+ candidate_id: str
380
+ actor: str
381
+ reason: str
382
+
383
+
384
+ @router.post("/flywheel/override")
385
+ def override(req: OverrideRequest) -> Dict:
386
+ try:
387
+ return flywheel_mod.governance_override(req.candidate_id, req.actor,
388
+ req.reason)
389
+ except KeyError as e:
390
+ raise HTTPException(404, str(e))
391
+ except ValueError as e:
392
+ raise HTTPException(422, str(e))
393
+
394
+
395
+ # ------------------------------------------------------------ policy + docs
396
+
397
+
398
+ @router.get("/stopship/policy")
399
+ def stopship_policy() -> Dict:
400
+ return {"policy": STOPSHIP_POLICY, "policy_version": policy_version(),
401
+ "note": "deterministic conjunction; explicitly NOT LLM-driven"}
402
+
403
+
404
+ @router.get("/runs/{run_id}/gate")
405
+ def run_gate(run_id: str) -> Dict:
406
+ doc = pipeline.load_run(run_id)
407
+ if doc is None:
408
+ raise HTTPException(404, f"unknown run {run_id}")
409
+ return doc["gate"]
410
+
411
+
412
+ @router.get("/rules")
413
+ def list_rules() -> Dict:
414
+ return {"ruleset_version": RULESET_VERSION,
415
+ "config": CONFIG,
416
+ "config_note": "thresholds are ILLUSTRATIVE (synthetic substrate)",
417
+ "rules": [{"rule_id": rid, "description": desc}
418
+ for rid, desc, _fn in RULES]}
419
+
420
+
421
+ @router.get("/docs/architecture")
422
+ def architecture_doc() -> Dict:
423
+ path = Path(__file__).resolve().parents[2] / "docs" / "architecture" / \
424
+ "rotr-architecture.md"
425
+ if not path.exists():
426
+ raise HTTPException(404, "architecture doc missing")
427
+ return {"path": str(path), "markdown": path.read_text()}
sensorflow/rotr/attribution.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Per-violation causal-layer attribution.
2
+
3
+ THE invariant of this module: a downstream failure NEVER auto-implies an
4
+ upstream cause. Every causal layer gets an INDEPENDENT evidence test that
5
+ returns SUPPORTED, RULED_OUT, or UNKNOWN; a layer is implicated only by its
6
+ own positive evidence, never by another layer's silence. Missing evidence
7
+ is UNKNOWN — an honest triage outcome, not a defect.
8
+
9
+ The planning test is the keystone of perception/planning separation: the
10
+ plan is re-judged by the SAME rule engine, but against the stack's OWN
11
+ world view (mapped context, detections it actually made, intents it
12
+ actually predicted, in its believed localization frame). A compliant plan
13
+ rules planning out; a non-compliant plan given the stack's own information
14
+ implicates planning regardless of what perception did.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Dict, List, Optional
20
+
21
+ from sensorflow.rotr import SOFTWARE_VERSION
22
+ from sensorflow.rotr.models import (
23
+ CAUSAL_LAYERS, Actor, FailureAttribution, LayerEvidence, Provenance,
24
+ ROTRScenario, ROTRViolation,
25
+ )
26
+ from sensorflow.rotr.rules import plan_violations
27
+
28
+ ATTRIBUTION_VERSION = "rotr-attr-1.0.0"
29
+
30
+ # ILLUSTRATIVE evidence thresholds (synthetic substrate).
31
+ DETECTION_RATE_RULED_OUT = 0.8
32
+ DETECTION_RATE_SUPPORTED = 0.5
33
+ POSITION_ERROR_SUPPORTED_M = 1.0
34
+ LOCALIZATION_ERROR_SUPPORTED_M = 1.0
35
+ CONTROL_POS_DEV_SUPPORTED_M = 1.5
36
+ CONTROL_SPEED_DEV_SUPPORTED_MPS = 2.0
37
+
38
+
39
+ def _material_actors(scenario: ROTRScenario,
40
+ violation: ROTRViolation) -> List[Actor]:
41
+ ids = set(violation.actor_ids)
42
+ return [a for a in scenario.actors if a.actor_id in ids]
43
+
44
+
45
+ # ------------------------------------------------------------ layer tests
46
+
47
+
48
+ def _test_perception(scenario: ROTRScenario,
49
+ violation: ROTRViolation) -> LayerEvidence:
50
+ actors = _material_actors(scenario, violation)
51
+ if not actors:
52
+ return LayerEvidence(
53
+ layer="perception", status="RULED_OUT", confidence=0.7,
54
+ evidence="rule trigger is ego/map-geometric; no actor whose "
55
+ "perception could have caused it")
56
+ # Judge detection quality in the window that mattered: from the actor
57
+ # becoming relevant (within 60 m) up to the violation instant.
58
+ worst: Optional[LayerEvidence] = None
59
+ for actor in actors:
60
+ n_frames = n_detected = n_absent = 0
61
+ pos_err_sum = 0.0
62
+ for i, s in enumerate(actor.states):
63
+ e = scenario.ego[min(i, len(scenario.ego) - 1)]
64
+ if s.t > violation.t_end + 0.5:
65
+ break
66
+ if abs(s.x - e.x) > 60 or abs(s.y - e.y) > 30:
67
+ continue
68
+ n_frames += 1
69
+ if s.observation is None:
70
+ n_absent += 1
71
+ elif s.observation.detected:
72
+ n_detected += 1
73
+ if s.observation.x is not None:
74
+ pos_err_sum += abs(s.observation.x - s.x) + \
75
+ abs((s.observation.y or s.y) - s.y)
76
+ if n_frames == 0:
77
+ continue
78
+ if n_absent / n_frames > 0.5:
79
+ cand = LayerEvidence(
80
+ layer="perception", status="UNKNOWN", confidence=0.0,
81
+ evidence=f"{actor.actor_id}: perception telemetry missing for "
82
+ f"{n_absent}/{n_frames} relevant frames")
83
+ else:
84
+ assessable = n_frames - n_absent
85
+ rate = n_detected / assessable if assessable else 0.0
86
+ mean_err = pos_err_sum / n_detected if n_detected else 0.0
87
+ if rate < DETECTION_RATE_SUPPORTED:
88
+ cand = LayerEvidence(
89
+ layer="perception", status="SUPPORTED", confidence=0.9,
90
+ evidence=f"{actor.actor_id}: missed detection — GT visible "
91
+ f"{assessable} frames, detected {n_detected} "
92
+ f"(rate {rate:.2f})")
93
+ elif mean_err > POSITION_ERROR_SUPPORTED_M:
94
+ cand = LayerEvidence(
95
+ layer="perception", status="SUPPORTED", confidence=0.75,
96
+ evidence=f"{actor.actor_id}: detected but mislocalized "
97
+ f"(mean |err| {mean_err:.2f} m)")
98
+ elif rate >= DETECTION_RATE_RULED_OUT:
99
+ cand = LayerEvidence(
100
+ layer="perception", status="RULED_OUT", confidence=0.9,
101
+ evidence=f"{actor.actor_id}: detected {n_detected}/"
102
+ f"{assessable} relevant frames (rate {rate:.2f}, "
103
+ f"mean |err| {mean_err:.2f} m) — GT-vs-detected "
104
+ f"diff is clean")
105
+ else:
106
+ cand = LayerEvidence(
107
+ layer="perception", status="SUPPORTED", confidence=0.6,
108
+ evidence=f"{actor.actor_id}: degraded detection "
109
+ f"(rate {rate:.2f})")
110
+ worst = _worse(worst, cand)
111
+ return worst or LayerEvidence(layer="perception", status="UNKNOWN",
112
+ evidence="no relevant frames to assess")
113
+
114
+
115
+ def _worse(a: Optional[LayerEvidence], b: LayerEvidence) -> LayerEvidence:
116
+ """Prefer SUPPORTED > UNKNOWN > RULED_OUT when merging actor evidence."""
117
+ if a is None:
118
+ return b
119
+ order = {"SUPPORTED": 2, "UNKNOWN": 1, "RULED_OUT": 0}
120
+ if order[b.status] > order[a.status]:
121
+ return b
122
+ if order[b.status] == order[a.status] and b.confidence > a.confidence:
123
+ return b
124
+ return a
125
+
126
+
127
+ def _test_prediction(scenario: ROTRScenario,
128
+ violation: ROTRViolation) -> LayerEvidence:
129
+ actors = _material_actors(scenario, violation)
130
+ if not actors:
131
+ return LayerEvidence(layer="prediction", status="RULED_OUT",
132
+ confidence=0.7,
133
+ evidence="no actor whose intent needed predicting")
134
+ worst: Optional[LayerEvidence] = None
135
+ for actor in actors:
136
+ if actor.predicted_intent is None:
137
+ cand = LayerEvidence(layer="prediction", status="UNKNOWN",
138
+ evidence=f"{actor.actor_id}: no recorded "
139
+ "intent prediction")
140
+ elif actor.predicted_intent != actor.intent:
141
+ cand = LayerEvidence(
142
+ layer="prediction", status="SUPPORTED", confidence=0.85,
143
+ evidence=f"{actor.actor_id}: GT intent {actor.intent} but "
144
+ f"stack predicted {actor.predicted_intent}")
145
+ else:
146
+ cand = LayerEvidence(
147
+ layer="prediction", status="RULED_OUT", confidence=0.85,
148
+ evidence=f"{actor.actor_id}: predicted intent matches GT "
149
+ f"({actor.intent})")
150
+ worst = _worse(worst, cand)
151
+ return worst
152
+
153
+
154
+ def _test_planning(scenario: ROTRScenario,
155
+ violation: ROTRViolation) -> LayerEvidence:
156
+ if not scenario.planned:
157
+ return LayerEvidence(layer="planning", status="UNKNOWN",
158
+ evidence="no planned trajectory recorded")
159
+ hits = plan_violations(scenario)
160
+ same_rule = [h for h in hits if h["rule_id"] == violation.rule_id]
161
+ if same_rule:
162
+ return LayerEvidence(
163
+ layer="planning", status="SUPPORTED", confidence=0.85,
164
+ evidence=f"plan violates {violation.rule_id} given the stack's "
165
+ f"OWN world view (detections + predicted intents + "
166
+ f"believed frame): {same_rule[0]['evidence']}")
167
+ if hits:
168
+ return LayerEvidence(
169
+ layer="planning", status="SUPPORTED", confidence=0.6,
170
+ evidence="plan violates a different rule given the stack's own "
171
+ f"view: {hits[0]['rule_id']}")
172
+ return LayerEvidence(
173
+ layer="planning", status="RULED_OUT", confidence=0.85,
174
+ evidence="plan is rule-compliant given the stack's own world view — "
175
+ "the plan was reasonable for what the stack knew")
176
+
177
+
178
+ def _test_localization(scenario: ROTRScenario,
179
+ violation: ROTRViolation) -> LayerEvidence:
180
+ errs = []
181
+ for e in scenario.ego:
182
+ if e.believed_x is None or e.believed_y is None:
183
+ return LayerEvidence(layer="localization", status="UNKNOWN",
184
+ evidence="no believed-pose telemetry")
185
+ errs.append(abs(e.believed_x - e.x) + abs(e.believed_y - e.y))
186
+ max_err = max(errs) if errs else 0.0
187
+ lane_mismatch = any(e.believed_lane_id != e.lane_id for e in scenario.ego
188
+ if e.lane_id and e.believed_lane_id)
189
+ if max_err > LOCALIZATION_ERROR_SUPPORTED_M or lane_mismatch:
190
+ return LayerEvidence(
191
+ layer="localization", status="SUPPORTED", confidence=0.9,
192
+ evidence=f"believed-vs-true pose error up to {max_err:.2f} m"
193
+ + ("; believed lane differs from true lane"
194
+ if lane_mismatch else ""))
195
+ return LayerEvidence(layer="localization", status="RULED_OUT",
196
+ confidence=0.9,
197
+ evidence=f"pose error <= {max_err:.2f} m; lane "
198
+ "association consistent")
199
+
200
+
201
+ def _test_map(scenario: ROTRScenario,
202
+ violation: ROTRViolation) -> LayerEvidence:
203
+ m, a = scenario.map_context, scenario.actual_context
204
+ diffs = []
205
+ if m.control != a.control:
206
+ diffs.append(f"control mapped={m.control!r} actual={a.control!r}")
207
+ if m.signal_state_for_ego != a.signal_state_for_ego:
208
+ diffs.append(f"signal mapped={m.signal_state_for_ego!r} "
209
+ f"actual={a.signal_state_for_ego!r}")
210
+ a_lanes = {l.lane_id: l for l in a.lanes}
211
+ for lane in m.lanes:
212
+ al = a_lanes.get(lane.lane_id)
213
+ if al is None:
214
+ diffs.append(f"lane {lane.lane_id} not in actual geometry")
215
+ continue
216
+ if lane.restricted_to != al.restricted_to:
217
+ diffs.append(f"lane {lane.lane_id} restriction mapped="
218
+ f"{lane.restricted_to!r} actual={al.restricted_to!r}")
219
+ if set(lane.permitted_maneuvers) != set(al.permitted_maneuvers):
220
+ diffs.append(f"lane {lane.lane_id} permitted maneuvers differ")
221
+ if diffs:
222
+ return LayerEvidence(layer="map", status="SUPPORTED", confidence=0.9,
223
+ evidence="map-vs-actual mismatch: " + "; ".join(diffs))
224
+ return LayerEvidence(layer="map", status="RULED_OUT", confidence=0.9,
225
+ evidence="mapped context matches as-built geometry")
226
+
227
+
228
+ def _test_control(scenario: ROTRScenario,
229
+ violation: ROTRViolation) -> LayerEvidence:
230
+ if not scenario.planned:
231
+ return LayerEvidence(layer="control", status="UNKNOWN",
232
+ evidence="no planned trajectory to compare "
233
+ "execution against")
234
+ n = min(len(scenario.planned), len(scenario.ego))
235
+ max_pos = max_spd = 0.0
236
+ for k in range(n):
237
+ p, e = scenario.planned[k], scenario.ego[k]
238
+ max_pos = max(max_pos, abs(p.x - e.x) + abs(p.y - e.y))
239
+ max_spd = max(max_spd, abs(p.v - e.speed))
240
+ if max_pos > CONTROL_POS_DEV_SUPPORTED_M or \
241
+ max_spd > CONTROL_SPEED_DEV_SUPPORTED_MPS:
242
+ return LayerEvidence(
243
+ layer="control", status="SUPPORTED", confidence=0.85,
244
+ evidence=f"executed trajectory deviates from plan: max position "
245
+ f"dev {max_pos:.2f} m, max speed dev {max_spd:.2f} m/s")
246
+ return LayerEvidence(
247
+ layer="control", status="RULED_OUT", confidence=0.85,
248
+ evidence=f"execution tracks the plan (max pos dev {max_pos:.2f} m, "
249
+ f"max speed dev {max_spd:.2f} m/s)")
250
+
251
+
252
+ def _test_data_label(scenario: ROTRScenario,
253
+ violation: ROTRViolation) -> LayerEvidence:
254
+ problems = []
255
+ ts = [e.t for e in scenario.ego]
256
+ if any(t2 <= t1 for t1, t2 in zip(ts, ts[1:])):
257
+ problems.append("ego timestamps not strictly increasing")
258
+ for a in scenario.actors:
259
+ if len(a.states) != len(scenario.ego):
260
+ problems.append(f"{a.actor_id}: state array length mismatch")
261
+ if problems:
262
+ return LayerEvidence(layer="data_label", status="SUPPORTED",
263
+ confidence=0.95,
264
+ evidence="record integrity failed: "
265
+ + "; ".join(problems))
266
+ return LayerEvidence(layer="data_label", status="RULED_OUT",
267
+ confidence=0.9,
268
+ evidence="record integrity checks pass "
269
+ "(monotone timestamps, consistent arrays)")
270
+
271
+
272
+ def _test_policy_rule(others: Dict[str, LayerEvidence]) -> LayerEvidence:
273
+ statuses = {le.status for le in others.values()}
274
+ if statuses == {"RULED_OUT"}:
275
+ return LayerEvidence(
276
+ layer="policy_rule", status="SUPPORTED", confidence=0.4,
277
+ evidence="every stack layer independently RULED_OUT — the rule "
278
+ "itself needs HITL review (possible rule misfire)")
279
+ if "SUPPORTED" in statuses:
280
+ return LayerEvidence(layer="policy_rule", status="RULED_OUT",
281
+ confidence=0.7,
282
+ evidence="a stack layer has positive evidence; "
283
+ "no indication the rule misfired")
284
+ return LayerEvidence(layer="policy_rule", status="UNKNOWN",
285
+ evidence="stack evidence incomplete; rule quality "
286
+ "cannot be judged")
287
+
288
+
289
+ # ------------------------------------------------------------ entry point
290
+
291
+
292
+ def attribute(scenario: ROTRScenario,
293
+ violation: ROTRViolation) -> FailureAttribution:
294
+ layers: Dict[str, LayerEvidence] = {
295
+ "perception": _test_perception(scenario, violation),
296
+ "prediction": _test_prediction(scenario, violation),
297
+ "planning": _test_planning(scenario, violation),
298
+ "localization": _test_localization(scenario, violation),
299
+ "map": _test_map(scenario, violation),
300
+ "control": _test_control(scenario, violation),
301
+ "data_label": _test_data_label(scenario, violation),
302
+ }
303
+ layers["policy_rule"] = _test_policy_rule(layers)
304
+
305
+ supported = [(name, le) for name, le in layers.items()
306
+ if le.status == "SUPPORTED"]
307
+ primary = None
308
+ if supported:
309
+ supported.sort(key=lambda kv: (-kv[1].confidence,
310
+ CAUSAL_LAYERS.index(kv[0])))
311
+ primary = supported[0][0]
312
+ note = ("no layer has positive evidence — triaged to HITL, NOT "
313
+ "defaulted to perception" if primary is None else
314
+ f"primary={primary} by highest-confidence positive evidence; "
315
+ f"{len(supported)} layer(s) SUPPORTED")
316
+
317
+ return FailureAttribution(
318
+ violation_id=violation.violation_id,
319
+ scenario_id=scenario.scenario_id,
320
+ layers=layers, primary_layer=primary, note=note,
321
+ provenance=Provenance(
322
+ scenario_id=scenario.scenario_id,
323
+ dataset_version=scenario.provenance.dataset_version,
324
+ model_version=scenario.provenance.model_version,
325
+ software_version=f"{SOFTWARE_VERSION}/{ATTRIBUTION_VERSION}",
326
+ source=scenario.provenance.source))
sensorflow/rotr/consequence.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Behavioral-consequence classification via counterfactual replay.
2
+
3
+ For each violation we construct the trajectory the vehicle WOULD have
4
+ driven with the attributed layer's inputs corrected, and diff it against
5
+ the observed trajectory. The corrected trajectory is produced per primary
6
+ layer:
7
+
8
+ * control -> the recorded plan itself (the plan was compliant;
9
+ correct the actuation): engine "planned-trajectory".
10
+ * perception / -> re-plan with the corrected world view (ground-truth
11
+ prediction actors): IDM-style replay. REUSE: the planner is
12
+ nextgen.closedloop.plan_acceleration when importable
13
+ (nextgen is an in-progress workstream — guarded
14
+ import); otherwise a self-contained IDM fallback
15
+ reusing sensorflow.safety.ssam_ext math (landed).
16
+ * yield/signal/stop -> compliant-envelope stop-at-line trajectory
17
+ rules attributed (a rule-compliant stack brakes to the line; the
18
+ to planning/map/ synthetic stop is labeled engine
19
+ localization/data "compliant-envelope").
20
+ * lane-keeping rules -> stay-in-lane replay (target y = initial y).
21
+
22
+ Classes (thresholds ILLUSTRATIVE): NO_MATERIAL_CONSEQUENCE <
23
+ DEGRADED_COMFORT < PLANNER_INTERVENTION < SAFETY_CRITICAL, with measured
24
+ trajectory divergence, TTC, PET, min clearance, stopping distance, braking
25
+ intensity and lateral deviation on BOTH branches. Surrogate caveat: these
26
+ measures prioritize triage; they never gate a release alone.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import math
32
+ from typing import Dict, List, Optional
33
+
34
+ import numpy as np
35
+
36
+ # REUSE (landed, read-only): SSAM surrogate-safety math.
37
+ from sensorflow.safety.ssam_ext import (
38
+ projected_ttc, rect_gap, rects_overlap, zone_grid_pet,
39
+ )
40
+
41
+ from sensorflow.rotr import SOFTWARE_VERSION
42
+ from sensorflow.rotr.models import (
43
+ CounterfactualScenario, FailureAttribution, PlannerEvaluation, Provenance,
44
+ ROTRScenario, ROTRViolation, SafetyAssessment,
45
+ )
46
+ from sensorflow.rotr.scenes import DT, V_NOM
47
+
48
+ CONSEQUENCE_VERSION = "rotr-consequence-1.0.0"
49
+
50
+ EGO_DIMS = (4.5, 1.9)
51
+ ACTUATION_TAU_S = 0.25
52
+
53
+ # REUSE nextgen's planner when importable (in-progress workstream: guarded).
54
+ try:
55
+ from sensorflow.nextgen.closedloop import plan_acceleration as _ng_plan
56
+ _PLANNER, PLANNER_ENGINE = _ng_plan, "nextgen.plan_acceleration"
57
+ except Exception: # pragma: no cover
58
+ _PLANNER, PLANNER_ENGINE = None, "rotr-idm-fallback"
59
+
60
+ STOP_RULES = {"R-YIELD-PED-01", "R-INT-CONFLICT-04", "R-STOP-06"}
61
+ LANE_RULES = {"R-PATH-RESTRICT-02", "R-LANE-MANEUVER-03", "R-MERGE-GAP-05"}
62
+
63
+
64
+ # ------------------------------------------------------------ fallback planner
65
+
66
+
67
+ def _fallback_plan(ego: Dict, detections: List[Dict]) -> Dict:
68
+ """Self-contained IDM-style longitudinal command + projected-TTC braking
69
+ (mirrors the reused planner's contract; ssam_ext math for conflicts)."""
70
+ v = ego["v"]
71
+ a_cmd = 2.5 * (1.0 - (v / max(V_NOM, 0.1)) ** 4)
72
+ intervention = False
73
+ ego_state = {"x": ego["x"], "y": ego["y"], "speed": v,
74
+ "heading": ego["heading"]}
75
+ for det in detections:
76
+ rel_x = det["x"] - ego["x"]
77
+ if abs(det["y"] - ego["y"]) <= 1.6 and rel_x > 0:
78
+ gap = max(rel_x - 0.5 * (EGO_DIMS[0] + det["dims"][0]), 0.5)
79
+ dv = v - det["vx"]
80
+ s_star = 2.0 + max(0.0, v * 1.5 + v * dv / (2 * math.sqrt(2.5 * 3.5)))
81
+ a_idm = 2.5 * (1.0 - (v / max(V_NOM, 0.1)) ** 4 - (s_star / gap) ** 2)
82
+ a_cmd = min(a_cmd, a_idm)
83
+ spd = math.hypot(det["vx"], det["vy"])
84
+ obj = {"x": det["x"], "y": det["y"], "speed": spd,
85
+ "heading": math.atan2(det["vy"], det["vx"]) if spd else 0.0}
86
+ ttc = projected_ttc(ego_state, EGO_DIMS, obj,
87
+ (det["dims"][0], det["dims"][1]),
88
+ look_ahead=4.0, dt=0.1)
89
+ if ttc is not None:
90
+ if ttc <= 1.5:
91
+ a_cmd, intervention = -8.0, True
92
+ elif ttc <= 4.0:
93
+ a_cmd = min(a_cmd, -3.5 * (4.0 - ttc) / 2.5)
94
+ intervention = True
95
+ return {"a_cmd": float(np.clip(a_cmd, -8.0, 2.5)),
96
+ "intervention": intervention, "reason": "fallback"}
97
+
98
+
99
+ def _planner(ego: Dict, detections: List[Dict]) -> Dict:
100
+ if _PLANNER is not None:
101
+ return _PLANNER(ego, detections)
102
+ return _fallback_plan(ego, detections)
103
+
104
+
105
+ # ------------------------------------------------------------ replay engines
106
+
107
+
108
+ def _gt_detections(scenario: ROTRScenario, fi: int, ego_x: float) -> List[Dict]:
109
+ out = []
110
+ for a in scenario.actors:
111
+ s = a.states[min(fi, len(a.states) - 1)]
112
+ if s.x - ego_x < -5.0 or math.hypot(s.x - ego_x, s.y) > 75.0:
113
+ continue
114
+ out.append({"instance_id": a.actor_id, "class_name": a.class_name,
115
+ "x": s.x, "y": s.y, "vx": s.vx, "vy": s.vy,
116
+ "dims": a.dims, "yaw": s.yaw})
117
+ return out
118
+
119
+
120
+ def _idm_replay(scenario: ROTRScenario) -> Dict:
121
+ """Re-plan with the CORRECTED world view (ground-truth actors)."""
122
+ e0 = scenario.ego[0]
123
+ n = len(scenario.ego)
124
+ ego = {"x": e0.x, "y": e0.y, "v": e0.speed, "a": 0.0, "heading": 0.0}
125
+ traj, intervention = [], False
126
+ for step in range(n):
127
+ t = step * DT
128
+ plan = _planner(ego, _gt_detections(scenario, step, ego["x"]))
129
+ intervention = intervention or bool(plan.get("intervention"))
130
+ alpha = DT / (DT + ACTUATION_TAU_S)
131
+ ego["a"] += alpha * (plan["a_cmd"] - ego["a"])
132
+ ego["v"] = max(0.0, ego["v"] + ego["a"] * DT)
133
+ ego["x"] += ego["v"] * DT
134
+ traj.append({"t": round(t, 2), "x": round(ego["x"], 3),
135
+ "y": round(ego["y"], 3), "v": round(ego["v"], 3),
136
+ "a": round(ego["a"], 3)})
137
+ return {"traj": traj, "intervention": intervention,
138
+ "engine": PLANNER_ENGINE}
139
+
140
+
141
+ def _stop_at_line_replay(scenario: ROTRScenario) -> Dict:
142
+ """Compliant-envelope reference: brake to the stop line and hold."""
143
+ ctx = scenario.actual_context
144
+ line = ctx.stop_line_x if ctx.stop_line_x is not None else 38.0
145
+ e0 = scenario.ego[0]
146
+ v0 = max(e0.speed, 0.1)
147
+ decel = 3.0
148
+ x_brake = (line - 0.5) - v0 * v0 / (2 * decel)
149
+ x, v, traj = e0.x, e0.speed, []
150
+ for step in range(len(scenario.ego)):
151
+ t = step * DT
152
+ a = -decel if (x >= x_brake and v > 0) else 0.0
153
+ traj.append({"t": round(t, 2), "x": round(x, 3), "y": round(e0.y, 3),
154
+ "v": round(v, 3), "a": round(a, 3)})
155
+ v = max(0.0, v + a * DT)
156
+ x += v * DT
157
+ return {"traj": traj, "intervention": True, "engine": "compliant-envelope"}
158
+
159
+
160
+ def _planned_replay(scenario: ROTRScenario) -> Dict:
161
+ traj = [{"t": round(p.t, 2), "x": round(p.x, 3), "y": round(p.y, 3),
162
+ "v": round(p.v, 3), "a": 0.0} for p in scenario.planned]
163
+ return {"traj": traj, "intervention": False, "engine": "planned-trajectory"}
164
+
165
+
166
+ def corrected_replay(scenario: ROTRScenario, violation: ROTRViolation,
167
+ attribution: FailureAttribution) -> Dict:
168
+ primary = attribution.primary_layer
169
+ if primary == "control" and scenario.planned:
170
+ return {**_planned_replay(scenario), "corrected_layers": ["control"]}
171
+ if primary in ("perception", "prediction"):
172
+ return {**_idm_replay(scenario), "corrected_layers": [primary]}
173
+ if violation.rule_id in STOP_RULES:
174
+ return {**_stop_at_line_replay(scenario),
175
+ "corrected_layers": [primary or "unknown"]}
176
+ if violation.rule_id in LANE_RULES:
177
+ return {**_idm_replay(scenario),
178
+ "corrected_layers": [primary or "unknown"]}
179
+ return {**_idm_replay(scenario), "corrected_layers": [primary or "unknown"]}
180
+
181
+
182
+ # ------------------------------------------------------------ measurement
183
+
184
+
185
+ def _assess(traj: List[Dict], scenario: ROTRScenario) -> SafetyAssessment:
186
+ min_ttc = min_gap = None
187
+ collision = False
188
+ max_brake = max_lat = 0.0
189
+ y0 = traj[0]["y"] if traj else 0.0
190
+ brake_x: Optional[float] = None
191
+ stop_dist: Optional[float] = None
192
+ for k, p in enumerate(traj):
193
+ a = p.get("a", 0.0)
194
+ v = p.get("v", p.get("speed", 0.0))
195
+ max_brake = max(max_brake, -a)
196
+ max_lat = max(max_lat, abs(p["y"] - y0))
197
+ if a < -2.0 and brake_x is None:
198
+ brake_x = p["x"]
199
+ if brake_x is not None and stop_dist is None and v < 0.1:
200
+ stop_dist = p["x"] - brake_x
201
+ ego_state = {"x": p["x"], "y": p["y"], "speed": v, "heading": 0.0}
202
+ for actor in scenario.actors:
203
+ s = actor.states[min(k, len(actor.states) - 1)]
204
+ if abs(s.x - p["x"]) > 60 or abs(s.y - p["y"]) > 25:
205
+ continue
206
+ gap = rect_gap((p["x"], p["y"]), 0.0, *EGO_DIMS,
207
+ (s.x, s.y), s.yaw, actor.dims[0], actor.dims[1])
208
+ if min_gap is None or gap < min_gap:
209
+ min_gap = gap
210
+ obj = {"x": s.x, "y": s.y, "speed": math.hypot(s.vx, s.vy),
211
+ "heading": s.yaw}
212
+ ttc = projected_ttc(ego_state, EGO_DIMS, obj,
213
+ (actor.dims[0], actor.dims[1]),
214
+ look_ahead=4.0, dt=0.1)
215
+ if ttc is not None and (min_ttc is None or ttc < min_ttc):
216
+ min_ttc = ttc
217
+ if rects_overlap((p["x"], p["y"]), 0.0, *EGO_DIMS,
218
+ (s.x, s.y), s.yaw, actor.dims[0], actor.dims[1]):
219
+ collision = True
220
+ pet = _min_pet(traj, scenario)
221
+ return SafetyAssessment(
222
+ min_ttc_s=_r(min_ttc), pet_s=_r(pet), min_clearance_m=_r(min_gap),
223
+ stopping_distance_m=_r(stop_dist), max_braking_mps2=round(max_brake, 3),
224
+ max_lateral_deviation_m=round(max_lat, 3), collision=collision)
225
+
226
+
227
+ def _min_pet(traj: List[Dict], scenario: ROTRScenario) -> Optional[float]:
228
+ ego_traj = {"vehicle_id": "ego", "vehicle_type": "car",
229
+ "length": EGO_DIMS[0], "width": EGO_DIMS[1],
230
+ "states": [{"t": p["t"], "x": p["x"], "y": p["y"],
231
+ "speed": p.get("v", p.get("speed", 0.0)),
232
+ "heading": 0.0} for p in traj]}
233
+ best = None
234
+ for actor in scenario.actors:
235
+ other = {"vehicle_id": actor.actor_id, "vehicle_type": actor.class_name,
236
+ "length": actor.dims[0], "width": actor.dims[1],
237
+ "states": [{"t": s.t, "x": s.x, "y": s.y,
238
+ "speed": math.hypot(s.vx, s.vy),
239
+ "heading": s.yaw} for s in actor.states]}
240
+ pet = zone_grid_pet(ego_traj, other, cell=2.0)
241
+ if pet is not None and (best is None or pet < best):
242
+ best = pet
243
+ return best
244
+
245
+
246
+ def _r(v: Optional[float]) -> Optional[float]:
247
+ return None if v is None else round(float(v), 3)
248
+
249
+
250
+ # ------------------------------------------------------------ classification
251
+ # ILLUSTRATIVE thresholds (synthetic substrate).
252
+
253
+ SC_VRU_CLEARANCE_M = 1.5
254
+ SC_CLEARANCE_M = 0.5
255
+ DEGRADED_DIVERGENCE_M = 2.0
256
+ DEGRADED_BRAKING_MPS2 = 1.5
257
+
258
+
259
+ def classify(scenario: ROTRScenario, violation: ROTRViolation,
260
+ attribution: FailureAttribution) -> CounterfactualScenario:
261
+ replay = corrected_replay(scenario, violation, attribution)
262
+ observed = [{"t": e.t, "x": e.x, "y": e.y, "v": e.speed, "a": e.accel}
263
+ for e in scenario.ego]
264
+ corrected = replay["traj"]
265
+
266
+ n = min(len(observed), len(corrected))
267
+ max_pos = max_spd = 0.0
268
+ for k in range(n):
269
+ o, c = observed[k], corrected[k]
270
+ max_pos = max(max_pos, math.hypot(o["x"] - c["x"], o["y"] - c["y"]))
271
+ max_spd = max(max_spd, abs(o["v"] - c["v"]))
272
+
273
+ obs = _assess(observed, scenario)
274
+ corr = _assess(corrected, scenario)
275
+
276
+ vru = any(a.class_name in ("pedestrian", "cyclist")
277
+ for a in scenario.actors if a.actor_id in set(violation.actor_ids))
278
+ # Observed danger classifies on its own; whether the corrected branch
279
+ # avoided it is reported as evidence, never used to downgrade severity
280
+ # (the replay planner is simplified and must not launder real exposure).
281
+ if (obs.collision
282
+ or (vru and obs.min_clearance_m is not None
283
+ and obs.min_clearance_m < SC_VRU_CLEARANCE_M)
284
+ or (obs.min_clearance_m is not None
285
+ and obs.min_clearance_m < SC_CLEARANCE_M)):
286
+ cls = "SAFETY_CRITICAL"
287
+ elif replay["intervention"] and max_pos > DEGRADED_DIVERGENCE_M:
288
+ cls = "PLANNER_INTERVENTION"
289
+ elif max_pos > DEGRADED_DIVERGENCE_M or \
290
+ corr.max_braking_mps2 > DEGRADED_BRAKING_MPS2:
291
+ cls = "DEGRADED_COMFORT"
292
+ else:
293
+ cls = "NO_MATERIAL_CONSEQUENCE"
294
+
295
+ return CounterfactualScenario(
296
+ counterfactual_id=f"{violation.violation_id}-cf",
297
+ violation_id=violation.violation_id,
298
+ scenario_id=scenario.scenario_id,
299
+ corrected_layers=replay["corrected_layers"],
300
+ consequence_class=cls,
301
+ planner_evaluation=PlannerEvaluation(
302
+ engine=replay["engine"],
303
+ observed_trajectory=observed,
304
+ corrected_trajectory=corrected,
305
+ max_position_divergence_m=round(max_pos, 3),
306
+ max_speed_divergence_mps=round(max_spd, 3),
307
+ corrected_max_braking_mps2=corr.max_braking_mps2,
308
+ corrected_intervention=bool(replay["intervention"])),
309
+ observed_safety=obs, corrected_safety=corr,
310
+ provenance=Provenance(
311
+ scenario_id=scenario.scenario_id,
312
+ dataset_version=scenario.provenance.dataset_version,
313
+ model_version=scenario.provenance.model_version,
314
+ software_version=f"{SOFTWARE_VERSION}/{CONSEQUENCE_VERSION}",
315
+ source="COUNTERFACTUAL"))
sensorflow/rotr/flywheel.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluation flywheel: confirmed violation -> failure signature -> cluster
2
+ -> HITL validation -> regression-test artifact, under dataset-role
3
+ governance with a contamination guard.
4
+
5
+ Roles are immutable once assigned; members of PROTECTED roles (REGRESSION,
6
+ LAUNCH) are NEVER training-eligible without a recorded governance override
7
+ — semantics mirrored from raremine.lineage (ContaminationError subclasses
8
+ raremine's LeakageError when importable so callers can catch either).
9
+ `sensorflow.studio2`'s registry is used when it lands (guarded import,
10
+ re-checked at integration time); until then the local implementation
11
+ persists to runs/rotr/.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from typing import Dict, List, Optional
18
+
19
+ from sensorflow.rotr import SOFTWARE_VERSION
20
+ from sensorflow.rotr import store
21
+ from sensorflow.rotr.models import (
22
+ HITLReview, PROTECTED_ROLES, Provenance, TrainingCandidate, now_iso,
23
+ )
24
+
25
+ FLYWHEEL_VERSION = "rotr-flywheel-1.0.0"
26
+
27
+ # Mirror raremine's guard exception when importable (landed package,
28
+ # read-only reuse) so platform callers can catch one exception type.
29
+ try:
30
+ from sensorflow.raremine.lineage import LeakageError as _LeakageBase
31
+ except Exception: # pragma: no cover
32
+ _LeakageBase = RuntimeError
33
+
34
+
35
+ class ContaminationError(_LeakageBase):
36
+ """Raised when a protected evaluation member would leak into training."""
37
+
38
+
39
+ # Registry backend: studio2 when it lands, else local (recorded on artifacts).
40
+ # The local store is ALWAYS written (the guard never depends on the
41
+ # concurrent workstream); studio2 registration is a best-effort mirror.
42
+ try:
43
+ from sensorflow.studio2.registry import get_registry as _studio2_get_registry
44
+ REGISTRY_BACKEND = "studio2+local"
45
+ except Exception:
46
+ _studio2_get_registry = None
47
+ REGISTRY_BACKEND = "local"
48
+
49
+
50
+ def _mirror_to_studio2(candidate: "TrainingCandidate") -> Optional[str]:
51
+ """Best-effort mirror of the regression artifact into the studio2
52
+ entity registry (guarded: studio2 is a concurrent workstream)."""
53
+ if _studio2_get_registry is None:
54
+ return None
55
+ try:
56
+ entity = _studio2_get_registry().register_dataset(
57
+ name=f"rotr-regression-{candidate.candidate_id}",
58
+ role="REGRESSION",
59
+ provenance=candidate.provenance.model_dump(),
60
+ meta={"violation_id": candidate.violation_id,
61
+ "cluster_id": candidate.cluster_id,
62
+ "run_id": candidate.run_id, "source": "sensorflow.rotr"},
63
+ actor="rotr-flywheel")
64
+ return entity.get("entity_id")
65
+ except Exception:
66
+ return None
67
+
68
+
69
+ def _audit(event: str, subject: str, actor: str, detail: str = "") -> None:
70
+ doc = store.read_json("flywheel", "audit.json") or {"events": []}
71
+ doc["events"].append({"at": now_iso(), "event": event, "subject": subject,
72
+ "actor": actor, "detail": detail})
73
+ store.write_json(doc, "flywheel", "audit.json")
74
+
75
+
76
+ # ------------------------------------------------------------ HITL queue
77
+
78
+
79
+ def build_queue(run_id: str, violations: List[Dict],
80
+ clusters: List[Dict]) -> List[HITLReview]:
81
+ vid_to_cluster = {}
82
+ for c in clusters:
83
+ for vid in c["member_violation_ids"]:
84
+ vid_to_cluster[vid] = c["cluster_id"]
85
+ reviews = []
86
+ for v in violations:
87
+ rid = f"rev-{v['violation_id']}"
88
+ reviews.append(HITLReview(
89
+ review_id=rid, run_id=run_id, violation_id=v["violation_id"],
90
+ cluster_id=vid_to_cluster.get(v["violation_id"]),
91
+ status="PENDING",
92
+ provenance=Provenance(
93
+ scenario_id=v.get("scenario_id"),
94
+ software_version=f"{SOFTWARE_VERSION}/{FLYWHEEL_VERSION}",
95
+ source="SYNTHETIC")))
96
+ store.write_json({"run_id": run_id,
97
+ "reviews": [r.model_dump() for r in reviews]},
98
+ "flywheel", f"queue-{run_id}.json")
99
+ return reviews
100
+
101
+
102
+ def get_queue(run_id: str) -> List[Dict]:
103
+ doc = store.read_json("flywheel", f"queue-{run_id}.json")
104
+ return doc["reviews"] if doc else []
105
+
106
+
107
+ def act(run_id: str, review_id: str, action: str, actor: str,
108
+ notes: str = "") -> Dict:
109
+ """VALIDATE turns a confirmed violation into a regression-test artifact
110
+ (protected role, not training-eligible). REJECT closes the review."""
111
+ doc = store.read_json("flywheel", f"queue-{run_id}.json")
112
+ if not doc:
113
+ raise KeyError(f"no HITL queue for run {run_id}")
114
+ review = next((r for r in doc["reviews"] if r["review_id"] == review_id),
115
+ None)
116
+ if review is None:
117
+ raise KeyError(f"unknown review {review_id}")
118
+ if review["status"] != "PENDING":
119
+ raise ValueError(f"review {review_id} already {review['status']}")
120
+
121
+ action = action.upper()
122
+ if action not in ("VALIDATE", "REJECT"):
123
+ raise ValueError("action must be VALIDATE or REJECT")
124
+ review["status"] = "VALIDATED" if action == "VALIDATE" else "REJECTED"
125
+ review["action"] = action
126
+ review["actor"] = actor
127
+ review["notes"] = notes
128
+ store.write_json(doc, "flywheel", f"queue-{run_id}.json")
129
+ _audit(f"hitl_{action.lower()}", review_id, actor, notes)
130
+
131
+ candidate = None
132
+ if action == "VALIDATE":
133
+ candidate = _create_candidate(run_id, review, actor)
134
+ _append_suite_member(run_id, review, candidate)
135
+ return {"review": review,
136
+ "candidate": candidate.model_dump() if candidate else None}
137
+
138
+
139
+ def _create_candidate(run_id: str, review: Dict, actor: str) -> TrainingCandidate:
140
+ cid = f"tc-{uuid.uuid4().hex[:10]}"
141
+ cand = TrainingCandidate(
142
+ candidate_id=cid, run_id=run_id,
143
+ violation_id=review["violation_id"],
144
+ cluster_id=review.get("cluster_id"),
145
+ dataset_role="REGRESSION", training_eligible=False,
146
+ guard_state="PROTECTED",
147
+ provenance=Provenance(
148
+ software_version=f"{SOFTWARE_VERSION}/{FLYWHEEL_VERSION}",
149
+ source="SYNTHETIC"))
150
+ doc = cand.model_dump()
151
+ doc["studio2_entity_id"] = _mirror_to_studio2(cand)
152
+ doc["registry_backend"] = REGISTRY_BACKEND
153
+ store.write_json(doc, "flywheel", "candidates", f"{cid}.json")
154
+ _audit("candidate_created", cid, actor,
155
+ f"role=REGRESSION (protected) violation={review['violation_id']} "
156
+ f"registry_backend={REGISTRY_BACKEND}")
157
+ return cand
158
+
159
+
160
+ def _append_suite_member(run_id: str, review: Dict,
161
+ candidate: TrainingCandidate) -> None:
162
+ suite = store.read_json("flywheel", "regression-suite.json") or {
163
+ "suite_id": "rotr-regression-suite", "members": [],
164
+ "registry_backend": REGISTRY_BACKEND}
165
+ suite["members"].append({
166
+ "candidate_id": candidate.candidate_id,
167
+ "violation_id": review["violation_id"],
168
+ "cluster_id": review.get("cluster_id"),
169
+ "run_id": run_id, "role": "REGRESSION",
170
+ "added_at": now_iso(), "added_by": review.get("actor"),
171
+ "immutable_role_note": "REGRESSION members re-run against every "
172
+ "candidate; never training-eligible without "
173
+ "a recorded governance override"})
174
+ store.write_json(suite, "flywheel", "regression-suite.json")
175
+
176
+
177
+ def get_suite() -> Dict:
178
+ return store.read_json("flywheel", "regression-suite.json") or {
179
+ "suite_id": "rotr-regression-suite", "members": [],
180
+ "registry_backend": REGISTRY_BACKEND}
181
+
182
+
183
+ # ------------------------------------------------------------ contamination guard
184
+
185
+
186
+ def list_candidates() -> List[Dict]:
187
+ out = []
188
+ for name in store.list_dir("flywheel", "candidates"):
189
+ doc = store.read_json("flywheel", "candidates", name)
190
+ if doc:
191
+ out.append(doc)
192
+ return out
193
+
194
+
195
+ def get_candidate(candidate_id: str) -> Optional[Dict]:
196
+ return store.read_json("flywheel", "candidates", f"{candidate_id}.json")
197
+
198
+
199
+ def promote_to_training(candidate_id: str, actor: str) -> Dict:
200
+ """Refused for protected roles unless a governance override is recorded
201
+ (raremine guard semantics)."""
202
+ doc = get_candidate(candidate_id)
203
+ if doc is None:
204
+ raise KeyError(f"unknown candidate {candidate_id}")
205
+ if doc["dataset_role"] in PROTECTED_ROLES and not doc.get("override"):
206
+ _audit("promotion_refused", candidate_id, actor,
207
+ f"protected role {doc['dataset_role']} without override")
208
+ raise ContaminationError(
209
+ f"candidate {candidate_id} holds protected role "
210
+ f"{doc['dataset_role']}: training promotion refused without a "
211
+ "recorded governance override")
212
+ doc["training_eligible"] = True
213
+ doc["guard_state"] = "OVERRIDDEN" if doc.get("override") else "ELIGIBLE"
214
+ store.write_json(doc, "flywheel", "candidates", f"{candidate_id}.json")
215
+ _audit("promoted_to_training", candidate_id, actor,
216
+ f"guard_state={doc['guard_state']}")
217
+ return doc
218
+
219
+
220
+ def governance_override(candidate_id: str, actor: str, reason: str) -> Dict:
221
+ if not reason.strip():
222
+ raise ValueError("a governance override requires a recorded reason")
223
+ doc = get_candidate(candidate_id)
224
+ if doc is None:
225
+ raise KeyError(f"unknown candidate {candidate_id}")
226
+ doc["override"] = {"actor": actor, "reason": reason, "at": now_iso()}
227
+ doc["guard_state"] = "OVERRIDDEN"
228
+ store.write_json(doc, "flywheel", "candidates", f"{candidate_id}.json")
229
+ _audit("governance_override", candidate_id, actor, reason)
230
+ return doc
sensorflow/rotr/metrics.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ROTR metric hierarchy + baseline-vs-candidate regression.
2
+
3
+ Metrics (architecture doc §6):
4
+ * ROTR recall / precision proxy (false-accusation rate on planted
5
+ NON-violations) — the scenario bank supplies a true denominator.
6
+ * Safety-critical ROTR recall — EXPOSURE-DERIVED weights: the weight of a
7
+ stratum is its measured fraction of SAFETY_CRITICAL consequence outcomes
8
+ on the evaluated bank, normalized by the overall fraction (documented
9
+ calibration, monotone in measured harm — NOT arbitrary class
10
+ multipliers). ILLUSTRATIVE on synthetic data by construction.
11
+ * Behavioral Consequence Rate (BCR), Critical Failure Rate (CFR, with a
12
+ Wilson interval — REUSE rca.stats.wilson_ci).
13
+ * Regression: claims are delegated to seqeval's sequential engine
14
+ (REUSE seqeval.evaluate_regression over a megaeval population, rare-event
15
+ cluster handling included) and forced into the SIX-outcome distinction:
16
+ observed / statistically significant / practically significant /
17
+ safety-critical / insufficient evidence / distribution-driven (the last
18
+ via cohort-mix divergence, megaeval-shift + rca comparison-validity
19
+ concepts).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Dict, List, Optional
25
+
26
+ # REUSE (landed, read-only).
27
+ from sensorflow.rca.stats import js_divergence_categorical, wilson_ci
28
+
29
+ from sensorflow.rotr import SOFTWARE_VERSION
30
+ from sensorflow.rotr.models import Provenance, RegressionResult
31
+
32
+ CALIBRATION_VERSION = "rotr-weights-1.0.0"
33
+
34
+ CONSEQUENCE_RANK = {"NO_MATERIAL_CONSEQUENCE": 0, "DEGRADED_COMFORT": 1,
35
+ "PLANNER_INTERVENTION": 2, "SAFETY_CRITICAL": 3}
36
+
37
+ # ILLUSTRATIVE practical-significance thresholds.
38
+ PRACTICAL_ROTR_RECALL_PP = 0.05
39
+ PRACTICAL_SC_RECALL_PP = 0.02
40
+ DISTRIBUTION_JS_THRESHOLD = 0.05
41
+
42
+
43
+ # ------------------------------------------------------------ run metrics
44
+
45
+
46
+ def _stratum(scenario_summary: Dict) -> str:
47
+ return f"{scenario_summary['kind']}|{scenario_summary['vulnerability']}"
48
+
49
+
50
+ def compute_metrics(scenarios: List[Dict], violations: List[Dict],
51
+ consequences: Dict[str, Dict]) -> Dict:
52
+ """scenarios: summaries with planted truth; violations: detected records;
53
+ consequences: violation_id -> {consequence_class}."""
54
+ by_scenario: Dict[str, List[Dict]] = {}
55
+ for v in violations:
56
+ by_scenario.setdefault(v["scenario_id"], []).append(v)
57
+
58
+ committed = [s for s in scenarios if s["committed"]]
59
+ negatives = [s for s in scenarios if not s["committed"]]
60
+
61
+ def _detected(s: Dict) -> bool:
62
+ return any(v["rule_id"] == s["expected_rule_id"]
63
+ for v in by_scenario.get(s["scenario_id"], []))
64
+
65
+ n_detected = sum(1 for s in committed if _detected(s))
66
+ rotr_recall = n_detected / len(committed) if committed else None
67
+
68
+ false_acc = sum(1 for s in negatives
69
+ if by_scenario.get(s["scenario_id"]))
70
+ false_accusation_rate = false_acc / len(negatives) if negatives else None
71
+
72
+ # --- exposure-derived weight calibration (documented, monotone) ------
73
+ strata: Dict[str, Dict] = {}
74
+ for s in committed:
75
+ st = strata.setdefault(_stratum(s), {"n": 0, "detected": 0, "sc": 0})
76
+ st["n"] += 1
77
+ if _detected(s):
78
+ st["detected"] += 1
79
+ cons = [consequences.get(v["violation_id"], {}).get("consequence_class")
80
+ for v in by_scenario.get(s["scenario_id"], [])]
81
+ if "SAFETY_CRITICAL" in cons or s.get("planted_sc_hint"):
82
+ st["sc"] += 1
83
+ total_n = sum(st["n"] for st in strata.values()) or 1
84
+ total_sc = sum(st["sc"] for st in strata.values())
85
+ overall_harm = total_sc / total_n if total_n else 0.0
86
+ calibration = {}
87
+ for name, st in sorted(strata.items()):
88
+ harm = st["sc"] / st["n"] if st["n"] else 0.0
89
+ # weight = measured harm exposure relative to overall harm; floor at
90
+ # a small epsilon so zero-harm strata still count, never dominate.
91
+ weight = (harm / overall_harm) if overall_harm > 0 else 1.0
92
+ weight = max(weight, 0.1)
93
+ calibration[name] = {"n": st["n"], "harm_fraction": round(harm, 4),
94
+ "weight": round(weight, 4),
95
+ "recall": round(st["detected"] / st["n"], 4)
96
+ if st["n"] else None}
97
+ sc_strata = {k: v for k, v in calibration.items() if v["harm_fraction"] > 0
98
+ or k.endswith("|VRU")}
99
+ num = sum(v["weight"] * v["recall"] * v["n"] for v in sc_strata.values()
100
+ if v["recall"] is not None)
101
+ den = sum(v["weight"] * v["n"] for v in sc_strata.values())
102
+ sc_rotr_recall = (num / den) if den else None
103
+
104
+ # --- consequence-level rates ----------------------------------------
105
+ detected_vids = [v["violation_id"] for v in violations]
106
+ consequential = sum(
107
+ 1 for vid in detected_vids
108
+ if CONSEQUENCE_RANK.get(
109
+ consequences.get(vid, {}).get("consequence_class", ""), 0) >= 1)
110
+ bcr = consequential / len(detected_vids) if detected_vids else None
111
+
112
+ sc_count = sum(1 for vid in detected_vids
113
+ if consequences.get(vid, {}).get("consequence_class")
114
+ == "SAFETY_CRITICAL")
115
+ n_scen = len(scenarios)
116
+ cfr = sc_count / n_scen if n_scen else None
117
+ cfr_ci = wilson_ci(sc_count, n_scen) if n_scen else (0.0, 1.0)
118
+
119
+ # --- cohort breakdowns ----------------------------------------------
120
+ cohorts: Dict[str, Dict] = {}
121
+ for s in committed:
122
+ for key in (f"rule:{s['expected_rule_id']}",
123
+ f"visibility:{s['visibility']}",
124
+ f"lighting:{s['lighting']}",
125
+ f"vulnerability:{s['vulnerability']}"):
126
+ c = cohorts.setdefault(key, {"n": 0, "detected": 0})
127
+ c["n"] += 1
128
+ c["detected"] += 1 if _detected(s) else 0
129
+ for c in cohorts.values():
130
+ c["recall"] = round(c["detected"] / c["n"], 4) if c["n"] else None
131
+
132
+ # --- stack-behavior rates (what regresses between model versions) ----
133
+ # ROTR recall measures the EVALUATOR; violation rates measure the STACK:
134
+ # committed violations per planted opportunity, per vulnerability cohort.
135
+ opportunities = [s for s in scenarios if s["is_violation_opportunity"]]
136
+ behavior_rates: Dict[str, Dict] = {}
137
+ for coh in ("VRU", "NON_VRU", "ALL"):
138
+ opp = [s for s in opportunities
139
+ if coh == "ALL" or s["vulnerability"] == coh]
140
+ com = [s for s in opp if s["committed"]]
141
+ behavior_rates[coh] = {
142
+ "opportunities": len(opp), "committed": len(com),
143
+ "violation_rate": round(len(com) / len(opp), 4) if opp else None}
144
+
145
+ return {
146
+ "n_scenarios": n_scen,
147
+ "n_committed_violations": len(committed),
148
+ "n_planted_non_violations": len(negatives),
149
+ "n_detected": n_detected,
150
+ "n_violation_records": len(violations),
151
+ "rotr_recall": _r(rotr_recall),
152
+ "false_accusation_rate": _r(false_accusation_rate),
153
+ "sc_rotr_recall": _r(sc_rotr_recall),
154
+ "bcr": _r(bcr),
155
+ "cfr": _r(cfr),
156
+ "cfr_wilson_95": [round(cfr_ci[0], 4), round(cfr_ci[1], 4)],
157
+ "weight_calibration": {
158
+ "version": CALIBRATION_VERSION,
159
+ "method": "stratum weight = measured SAFETY_CRITICAL harm "
160
+ "fraction / overall harm fraction (exposure-derived, "
161
+ "monotone in measured harm; floored at 0.1). "
162
+ "ILLUSTRATIVE: calibrated on the synthetic bank itself.",
163
+ "strata": calibration,
164
+ },
165
+ "cohorts": cohorts,
166
+ "behavior_rates": behavior_rates,
167
+ "surrogate_caveat": "TTC/PET are surrogate conflict measures; they "
168
+ "prioritize triage and never gate alone.",
169
+ }
170
+
171
+
172
+ def _r(v: Optional[float]) -> Optional[float]:
173
+ return None if v is None else round(float(v), 4)
174
+
175
+
176
+ # ------------------------------------------------------------ regression
177
+
178
+
179
+ def _seqeval_effects(baseline: Dict, candidate: Dict) -> Dict[str, float]:
180
+ """Translate stack-behavior deltas into seqeval stratum effects.
181
+
182
+ The regressing quantity between MODEL versions is the committed
183
+ violation RATE per cohort (ROTR recall measures the evaluator and is
184
+ invariant across stack profiles). A rate INCREASE is a performance
185
+ DECREASE, so the effect is the negated rate delta in probability
186
+ points. seqeval strata are megaeval "class|lighting" strata: the VRU
187
+ cohort maps onto the pedestrian strata, NON_VRU onto vehicle strata.
188
+ """
189
+ effects: Dict[str, float] = {}
190
+ b = baseline.get("behavior_rates", {})
191
+ c = candidate.get("behavior_rates", {})
192
+ mapping = {"VRU": "pedestrian|night", "NON_VRU": "vehicle|day",
193
+ "ALL": "__global__"}
194
+ for coh, stratum in mapping.items():
195
+ rb = (b.get(coh) or {}).get("violation_rate")
196
+ rc = (c.get(coh) or {}).get("violation_rate")
197
+ if rb is None or rc is None:
198
+ continue
199
+ delta = -(rc - rb)
200
+ if abs(delta) > 1e-9:
201
+ effects[stratum] = round(delta, 4)
202
+ return effects
203
+
204
+
205
+ def evaluate_candidate(regression_id: str, baseline_run: Dict,
206
+ candidate_run: Dict, seed: int = 11) -> RegressionResult:
207
+ bm, cm = baseline_run["metrics"], candidate_run["metrics"]
208
+ deltas = {}
209
+ for k in ("rotr_recall", "sc_rotr_recall", "bcr", "cfr",
210
+ "false_accusation_rate"):
211
+ if bm.get(k) is not None and cm.get(k) is not None:
212
+ deltas[k] = round(cm[k] - bm[k], 4)
213
+ for coh in ("VRU", "NON_VRU", "ALL"):
214
+ rb = (bm.get("behavior_rates", {}).get(coh) or {}).get("violation_rate")
215
+ rc = (cm.get("behavior_rates", {}).get(coh) or {}).get("violation_rate")
216
+ if rb is not None and rc is not None:
217
+ deltas[f"violation_rate_{coh}"] = round(rc - rb, 4)
218
+
219
+ # --- delegation: statistical claims go through seqeval ---------------
220
+ seq_payload = None
221
+ seq_decision = None
222
+ try:
223
+ from sensorflow.megaeval import population as pop_mod
224
+ from sensorflow.seqeval.controller import evaluate_regression
225
+
226
+ pop_name = "rotr-regression-pop"
227
+ pop_id = next((p["population_id"] for p in pop_mod.list_populations()
228
+ if p.get("name") == pop_name), None)
229
+ if pop_id is None:
230
+ pop_id = pop_mod.generate_population(
231
+ pop_name, num_objects=60_000, seed=17)["population_id"]
232
+ effects = _seqeval_effects(bm, cm)
233
+ seq_payload = evaluate_regression(
234
+ pop_id,
235
+ {"model_version": baseline_run.get("model_version", "baseline")},
236
+ {"model_version": candidate_run.get("model_version", "candidate"),
237
+ "effects": effects},
238
+ policy={"target_n": 6000, "safety_floor": 800,
239
+ "min_per_stratum": 120,
240
+ "escalation": {"enabled": True,
241
+ "max_extra_per_stratum": 600,
242
+ "batch_objects": 300}},
243
+ seed=seed)
244
+ seq_payload["translated_effects"] = effects
245
+ seq_decision = seq_payload.get("decision")
246
+ except Exception as e: # honest degradation
247
+ seq_payload = {"error": f"seqeval delegation unavailable: {e}"}
248
+
249
+ # --- distribution-driven check (megaeval shift + rca concepts) -------
250
+ # Compare the OPPORTUNITY mix (bank composition), not the committed mix:
251
+ # committed-mix changes are model behavior, which is the signal, not a
252
+ # confound. Same-seed banks have identical opportunity mixes (JS = 0).
253
+ def _mix(run: Dict) -> List[str]:
254
+ return [f"{s['kind']}|{s['visibility']}|{s['lighting']}"
255
+ for s in run["scenario_summaries"]]
256
+
257
+ js = js_divergence_categorical(_mix(baseline_run), _mix(candidate_run)) \
258
+ if baseline_run.get("scenario_summaries") and \
259
+ candidate_run.get("scenario_summaries") else 0.0
260
+ distribution_driven = js > DISTRIBUTION_JS_THRESHOLD
261
+ dist_note = (f"cohort-mix JS divergence {js:.4f} "
262
+ + ("EXCEEDS" if distribution_driven else "below")
263
+ + f" {DISTRIBUTION_JS_THRESHOLD} — "
264
+ + ("observed deltas may reflect a different committed-"
265
+ "violation mix, not model behavior; verify before acting"
266
+ if distribution_driven else
267
+ "committed-violation mix comparable between runs"))
268
+
269
+ # --- six-outcome distinction -----------------------------------------
270
+ six = {
271
+ "observed_difference": any(abs(d) > 0 for d in deltas.values()),
272
+ "statistically_significant": seq_decision == "REGRESSION",
273
+ "practically_significant": (
274
+ abs(deltas.get("violation_rate_ALL", 0.0))
275
+ >= PRACTICAL_ROTR_RECALL_PP
276
+ or abs(deltas.get("violation_rate_VRU", 0.0))
277
+ >= PRACTICAL_SC_RECALL_PP
278
+ or abs(deltas.get("rotr_recall", 0.0)) >= PRACTICAL_ROTR_RECALL_PP),
279
+ "safety_critical": (deltas.get("violation_rate_VRU", 0.0)
280
+ >= PRACTICAL_SC_RECALL_PP
281
+ or deltas.get("sc_rotr_recall", 0.0)
282
+ <= -PRACTICAL_SC_RECALL_PP
283
+ or deltas.get("cfr", 0.0) > 0),
284
+ "insufficient_evidence": seq_decision == "INSUFFICIENT"
285
+ or seq_decision is None,
286
+ "distribution_driven": distribution_driven,
287
+ }
288
+ if six["safety_critical"] and six["statistically_significant"]:
289
+ primary = "SAFETY_CRITICAL_REGRESSION"
290
+ elif six["statistically_significant"]:
291
+ primary = "STATISTICALLY_SIGNIFICANT_REGRESSION"
292
+ elif six["insufficient_evidence"]:
293
+ primary = "INSUFFICIENT_EVIDENCE"
294
+ elif six["distribution_driven"]:
295
+ primary = "DISTRIBUTION_DRIVEN"
296
+ elif six["practically_significant"]:
297
+ primary = "PRACTICALLY_SIGNIFICANT_DIFFERENCE"
298
+ elif six["observed_difference"]:
299
+ primary = "OBSERVED_DIFFERENCE_ONLY"
300
+ else:
301
+ primary = "NO_DIFFERENCE"
302
+
303
+ return RegressionResult(
304
+ regression_id=regression_id,
305
+ baseline_run_id=baseline_run["run_id"],
306
+ candidate_run_id=candidate_run["run_id"],
307
+ baseline_model=baseline_run.get("model_version", ""),
308
+ candidate_model=candidate_run.get("model_version", ""),
309
+ metric_deltas=deltas, six_outcomes=six, primary_outcome=primary,
310
+ seqeval=seq_payload, distribution_note=dist_note,
311
+ provenance=Provenance(
312
+ software_version=f"{SOFTWARE_VERSION}/metrics",
313
+ calibration_version=CALIBRATION_VERSION, source="SYNTHETIC"))
sensorflow/rotr/models.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed data contracts for the ROTR capability.
2
+
3
+ Design rules (docs/architecture/rotr-architecture.md §7):
4
+ * every persisted record carries a Provenance block;
5
+ * tri-state evidence is an enum, never a boolean;
6
+ * no defaults that could fabricate evidence — missing evidence is UNKNOWN.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ # ---------------------------------------------------------------- enums
17
+
18
+ CAUSAL_LAYERS = ["perception", "prediction", "planning", "localization",
19
+ "map", "control", "policy_rule", "data_label"]
20
+
21
+ EVIDENCE_STATUSES = ["SUPPORTED", "RULED_OUT", "UNKNOWN"]
22
+
23
+ CONSEQUENCE_CLASSES = ["NO_MATERIAL_CONSEQUENCE", "DEGRADED_COMFORT",
24
+ "PLANNER_INTERVENTION", "SAFETY_CRITICAL"]
25
+
26
+ DATASET_ROLES = ["TRAIN", "VALIDATION", "TEST", "REGRESSION", "LAUNCH",
27
+ "MONITORING"]
28
+ # Membership in these roles blocks training eligibility without an override.
29
+ PROTECTED_ROLES = {"REGRESSION", "LAUNCH"}
30
+
31
+ RIGHT_OF_WAY = ["HAS_ROW", "MUST_YIELD", "NONE"]
32
+ INTENTS = ["CROSSING", "MERGING", "YIELDING", "PROCEEDING", "WAITING"]
33
+
34
+ SURROGATE_CAVEAT = (
35
+ "TTC/PET/DRAC are surrogate conflict measures: they do not alone "
36
+ "establish collision risk (controllability, evasive options and actor "
37
+ "reaction are unmodeled). They prioritize triage; they never decide a "
38
+ "gate by themselves.")
39
+
40
+
41
+ def now_iso() -> str:
42
+ return datetime.now(timezone.utc).isoformat()
43
+
44
+
45
+ # ---------------------------------------------------------------- provenance
46
+
47
+
48
+ class Provenance(BaseModel):
49
+ scenario_id: Optional[str] = None
50
+ dataset_version: str = ""
51
+ model_version: str = ""
52
+ software_version: str = ""
53
+ calibration_version: str = ""
54
+ timestamp: str = Field(default_factory=now_iso)
55
+ source: str = "SYNTHETIC" # SYNTHETIC | LOG | COUNTERFACTUAL
56
+ confidence: Optional[float] = None
57
+
58
+
59
+ # ---------------------------------------------------------------- world state
60
+
61
+
62
+ class EgoState(BaseModel):
63
+ t: float
64
+ x: float
65
+ y: float
66
+ yaw: float = 0.0
67
+ speed: float = 0.0
68
+ accel: float = 0.0
69
+ lane_id: Optional[str] = None # true lane association
70
+ believed_x: Optional[float] = None # localization view (None=UNKNOWN)
71
+ believed_y: Optional[float] = None
72
+ believed_lane_id: Optional[str] = None
73
+
74
+
75
+ class ActorObservation(BaseModel):
76
+ """The stack's view of one actor at one frame (None = not detected)."""
77
+
78
+ detected: bool = False
79
+ x: Optional[float] = None
80
+ y: Optional[float] = None
81
+ class_name: Optional[str] = None
82
+
83
+
84
+ class ActorState(BaseModel):
85
+ t: float
86
+ x: float
87
+ y: float
88
+ vx: float = 0.0
89
+ vy: float = 0.0
90
+ yaw: float = 0.0
91
+ occluded: bool = False
92
+ observation: Optional[ActorObservation] = None
93
+
94
+
95
+ class Actor(BaseModel):
96
+ actor_id: str
97
+ class_name: str # pedestrian|cyclist|vehicle|bus
98
+ dims: List[float] = Field(default_factory=lambda: [4.5, 1.9, 1.6])
99
+ right_of_way: str = "NONE" # relative to ego at the conflict
100
+ intent: str = "PROCEEDING" # ground-truth intent
101
+ predicted_intent: Optional[str] = None # stack's prediction (None=UNKNOWN)
102
+ states: List[ActorState] = Field(default_factory=list)
103
+
104
+
105
+ class Lane(BaseModel):
106
+ lane_id: str
107
+ center_y: float
108
+ direction: str = "+x"
109
+ permitted_maneuvers: List[str] = Field(
110
+ default_factory=lambda: ["STRAIGHT", "LANE_CHANGE"])
111
+ restricted_to: Optional[str] = None # e.g. "bus" — None = unrestricted
112
+
113
+
114
+ class Crosswalk(BaseModel):
115
+ crosswalk_id: str
116
+ x_min: float
117
+ x_max: float
118
+
119
+
120
+ class RoadContext(BaseModel):
121
+ context_id: str = "ctx"
122
+ intersection_type: str = "none" # none|uncontrolled|controlled
123
+ control: str = "none" # none|signal|stop_sign|yield_sign
124
+ signal_state_for_ego: Optional[str] = None # green|red (controlled only)
125
+ intersection_x_min: Optional[float] = None
126
+ intersection_x_max: Optional[float] = None
127
+ stop_line_x: Optional[float] = None
128
+ lanes: List[Lane] = Field(default_factory=list)
129
+ crosswalks: List[Crosswalk] = Field(default_factory=list)
130
+ speed_limit_mps: float = 13.4
131
+
132
+
133
+ class Environment(BaseModel):
134
+ visibility: str = "clear" # clear|low
135
+ weather: str = "clear" # clear|rain
136
+ lighting: str = "day" # day|dusk|night
137
+
138
+
139
+ class PlannedPoint(BaseModel):
140
+ t: float
141
+ x: float
142
+ y: float
143
+ v: float
144
+
145
+
146
+ class PlantedTruth(BaseModel):
147
+ """Ground truth planted by the generator, used ONLY for evaluation."""
148
+
149
+ kind: str
150
+ is_violation_opportunity: bool = False
151
+ committed: bool = False # did this stack commit it?
152
+ expected_rule_id: Optional[str] = None
153
+ cause_layer: Optional[str] = None
154
+ notes: str = ""
155
+
156
+
157
+ class ROTRScenario(BaseModel):
158
+ scenario_id: str
159
+ bank_id: str = ""
160
+ seed: int = 0
161
+ description: str = ""
162
+ environment: Environment = Field(default_factory=Environment)
163
+ map_context: RoadContext = Field(default_factory=RoadContext) # as mapped
164
+ actual_context: RoadContext = Field(default_factory=RoadContext) # as built
165
+ ego: List[EgoState] = Field(default_factory=list) # executed trajectory
166
+ planned: List[PlannedPoint] = Field(default_factory=list)
167
+ actors: List[Actor] = Field(default_factory=list)
168
+ planted: PlantedTruth = Field(default_factory=lambda: PlantedTruth(kind=""))
169
+ provenance: Provenance = Field(default_factory=Provenance)
170
+
171
+
172
+ # ---------------------------------------------------------------- detection
173
+
174
+
175
+ class ROTRViolation(BaseModel):
176
+ violation_id: str
177
+ scenario_id: str
178
+ rule_id: str
179
+ rule_version: str
180
+ description: str = ""
181
+ actor_ids: List[str] = Field(default_factory=list)
182
+ t_start: float = 0.0
183
+ t_end: float = 0.0
184
+ evidence: Dict = Field(default_factory=dict)
185
+ confidence: float = 0.0
186
+ taxonomy: Dict[str, str] = Field(default_factory=dict)
187
+ provenance: Provenance = Field(default_factory=Provenance)
188
+
189
+
190
+ # ---------------------------------------------------------------- attribution
191
+
192
+
193
+ class LayerEvidence(BaseModel):
194
+ layer: str
195
+ status: str = "UNKNOWN" # SUPPORTED|RULED_OUT|UNKNOWN
196
+ evidence: str = ""
197
+ confidence: float = 0.0
198
+
199
+
200
+ class FailureAttribution(BaseModel):
201
+ violation_id: str
202
+ scenario_id: str
203
+ layers: Dict[str, LayerEvidence] = Field(default_factory=dict)
204
+ primary_layer: Optional[str] = None # None = triage to HITL
205
+ note: str = ""
206
+ provenance: Provenance = Field(default_factory=Provenance)
207
+
208
+
209
+ # ---------------------------------------------------------------- consequence
210
+
211
+
212
+ class SafetyAssessment(BaseModel):
213
+ min_ttc_s: Optional[float] = None
214
+ pet_s: Optional[float] = None
215
+ min_clearance_m: Optional[float] = None
216
+ stopping_distance_m: Optional[float] = None
217
+ max_braking_mps2: float = 0.0
218
+ max_lateral_deviation_m: float = 0.0
219
+ collision: bool = False
220
+ surrogate_caveat: str = SURROGATE_CAVEAT
221
+
222
+
223
+ class PlannerEvaluation(BaseModel):
224
+ engine: str = "" # which replay planner produced this
225
+ observed_trajectory: List[Dict] = Field(default_factory=list)
226
+ corrected_trajectory: List[Dict] = Field(default_factory=list)
227
+ max_position_divergence_m: float = 0.0
228
+ max_speed_divergence_mps: float = 0.0
229
+ corrected_max_braking_mps2: float = 0.0
230
+ corrected_intervention: bool = False
231
+
232
+
233
+ class CounterfactualScenario(BaseModel):
234
+ counterfactual_id: str
235
+ violation_id: str
236
+ scenario_id: str
237
+ corrected_layers: List[str] = Field(default_factory=list)
238
+ consequence_class: str = "NO_MATERIAL_CONSEQUENCE"
239
+ planner_evaluation: PlannerEvaluation = Field(default_factory=PlannerEvaluation)
240
+ observed_safety: SafetyAssessment = Field(default_factory=SafetyAssessment)
241
+ corrected_safety: SafetyAssessment = Field(default_factory=SafetyAssessment)
242
+ provenance: Provenance = Field(default_factory=Provenance)
243
+
244
+
245
+ # ---------------------------------------------------------------- metrics
246
+
247
+
248
+ class RegressionResult(BaseModel):
249
+ regression_id: str
250
+ baseline_run_id: str
251
+ candidate_run_id: str
252
+ baseline_model: str = ""
253
+ candidate_model: str = ""
254
+ metric_deltas: Dict = Field(default_factory=dict)
255
+ six_outcomes: Dict[str, bool] = Field(default_factory=dict)
256
+ primary_outcome: str = ""
257
+ seqeval: Optional[Dict] = None # delegation payload (None=unavailable)
258
+ distribution_note: str = ""
259
+ provenance: Provenance = Field(default_factory=Provenance)
260
+
261
+
262
+ # ---------------------------------------------------------------- governance
263
+
264
+
265
+ class HITLReview(BaseModel):
266
+ review_id: str
267
+ run_id: str
268
+ violation_id: str
269
+ cluster_id: Optional[str] = None
270
+ status: str = "PENDING" # PENDING|VALIDATED|REJECTED
271
+ action: Optional[str] = None
272
+ actor: Optional[str] = None
273
+ notes: str = ""
274
+ provenance: Provenance = Field(default_factory=Provenance)
275
+
276
+
277
+ class TrainingCandidate(BaseModel):
278
+ candidate_id: str
279
+ run_id: str
280
+ violation_id: str
281
+ cluster_id: Optional[str] = None
282
+ dataset_role: str = "REGRESSION"
283
+ training_eligible: bool = False
284
+ guard_state: str = "PROTECTED" # PROTECTED|ELIGIBLE|OVERRIDDEN
285
+ override: Optional[Dict] = None # {actor, reason, timestamp}
286
+ provenance: Provenance = Field(default_factory=Provenance)
287
+
288
+
289
+ class ReleaseGateResult(BaseModel):
290
+ gate_id: str
291
+ run_id: str
292
+ policy_version: str
293
+ outcome: str = "GO" # GO|NO_GO
294
+ events: List[Dict] = Field(default_factory=list)
295
+ agentic_advisory: Optional[Dict] = None # forwarded policy-engine outcome
296
+ provenance: Provenance = Field(default_factory=Provenance)
sensorflow/rotr/pipeline.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run orchestration: bank -> detection -> attribution -> consequence ->
2
+ metrics -> clusters -> gate -> HITL queue, persisted under runs/rotr/.
3
+
4
+ Deterministic end to end: run ids derive from bank ids, which derive from
5
+ (n_scenarios, seed, model_version, generator version)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Dict, List, Optional
10
+
11
+ from sensorflow.rotr import store
12
+ from sensorflow.rotr.attribution import attribute
13
+ from sensorflow.rotr.consequence import classify
14
+ from sensorflow.rotr.flywheel import build_queue
15
+ from sensorflow.rotr.metrics import compute_metrics
16
+ from sensorflow.rotr.models import ROTRScenario
17
+ from sensorflow.rotr.rules import RULESET_VERSION, detect
18
+ from sensorflow.rotr.scenes import bank_id_for, generate_bank
19
+ from sensorflow.rotr.stopship import evaluate_gate
20
+ from sensorflow.rotr.taxonomy import build_clusters, signature
21
+
22
+ KIND_VULNERABILITY = {"fail_yield_pedestrian": "VRU"}
23
+
24
+
25
+ # ------------------------------------------------------------ banks
26
+
27
+
28
+ def create_bank(n_scenarios: int = 28, seed: int = 7,
29
+ model_version: str = "stack-v1") -> Dict:
30
+ scenarios = generate_bank(n_scenarios, seed, model_version)
31
+ bank_id = scenarios[0].bank_id
32
+ doc = {
33
+ "bank_id": bank_id, "n_scenarios": n_scenarios, "seed": seed,
34
+ "model_version": model_version,
35
+ "scenarios": [s.model_dump() for s in scenarios],
36
+ }
37
+ store.write_json(doc, "banks", f"{bank_id}.json")
38
+ return bank_summary(doc)
39
+
40
+
41
+ def bank_summary(doc: Dict) -> Dict:
42
+ return {
43
+ "bank_id": doc["bank_id"], "n_scenarios": doc["n_scenarios"],
44
+ "seed": doc["seed"], "model_version": doc["model_version"],
45
+ "scenarios": [{
46
+ "scenario_id": s["scenario_id"],
47
+ "description": s["description"],
48
+ "kind": s["planted"]["kind"],
49
+ "committed": s["planted"]["committed"],
50
+ "is_violation_opportunity": s["planted"]["is_violation_opportunity"],
51
+ "environment": s["environment"],
52
+ } for s in doc["scenarios"]],
53
+ }
54
+
55
+
56
+ def load_bank(bank_id: str) -> Optional[Dict]:
57
+ return store.read_json("banks", f"{bank_id}.json")
58
+
59
+
60
+ def list_banks() -> List[Dict]:
61
+ out = []
62
+ for name in store.list_dir("banks"):
63
+ doc = store.read_json("banks", name)
64
+ if doc:
65
+ out.append({k: doc[k] for k in
66
+ ("bank_id", "n_scenarios", "seed", "model_version")})
67
+ return out
68
+
69
+
70
+ def load_scenario(bank_id: str, scenario_id: str) -> Optional[ROTRScenario]:
71
+ doc = load_bank(bank_id)
72
+ if not doc:
73
+ return None
74
+ for s in doc["scenarios"]:
75
+ if s["scenario_id"] == scenario_id:
76
+ return ROTRScenario.model_validate(s)
77
+ return None
78
+
79
+
80
+ # ------------------------------------------------------------ runs
81
+
82
+
83
+ def execute_run(bank_id: Optional[str] = None, n_scenarios: int = 28,
84
+ seed: int = 7, model_version: str = "stack-v1",
85
+ forward_to_agentic: bool = False) -> Dict:
86
+ if bank_id is None:
87
+ bank_id = bank_id_for(n_scenarios, seed, model_version)
88
+ if not store.exists("banks", f"{bank_id}.json"):
89
+ create_bank(n_scenarios, seed, model_version)
90
+ doc = load_bank(bank_id)
91
+ if doc is None:
92
+ raise KeyError(f"unknown bank {bank_id}")
93
+ scenarios = [ROTRScenario.model_validate(s) for s in doc["scenarios"]]
94
+ run_id = f"run-{bank_id}"
95
+
96
+ violations, attributions, consequences, gate_items = [], {}, {}, []
97
+ scenario_summaries = []
98
+ for sc in scenarios:
99
+ vs = detect(sc)
100
+ scenario_summaries.append({
101
+ "scenario_id": sc.scenario_id, "kind": sc.planted.kind,
102
+ "committed": sc.planted.committed,
103
+ "is_violation_opportunity": sc.planted.is_violation_opportunity,
104
+ "expected_rule_id": sc.planted.expected_rule_id,
105
+ "cause_layer": sc.planted.cause_layer,
106
+ "vulnerability": KIND_VULNERABILITY.get(sc.planted.kind, "NON_VRU"),
107
+ "visibility": sc.environment.visibility,
108
+ "lighting": sc.environment.lighting,
109
+ "weather": sc.environment.weather,
110
+ "n_violations_detected": len(vs),
111
+ })
112
+ for v in vs:
113
+ v.taxonomy = signature(v, sc)
114
+ attr = attribute(sc, v)
115
+ cf = classify(sc, v, attr)
116
+ violations.append(v.model_dump())
117
+ attributions[v.violation_id] = attr.model_dump()
118
+ consequences[v.violation_id] = {
119
+ "consequence_class": cf.consequence_class,
120
+ "corrected_layers": cf.corrected_layers,
121
+ "engine": cf.planner_evaluation.engine,
122
+ "max_position_divergence_m":
123
+ cf.planner_evaluation.max_position_divergence_m,
124
+ "observed_safety": cf.observed_safety.model_dump(),
125
+ "corrected_safety": cf.corrected_safety.model_dump(),
126
+ }
127
+ # Full replay (trajectories) persisted separately: large payload.
128
+ store.write_json(cf.model_dump(), "runs", run_id, "consequences",
129
+ f"{v.violation_id}.json")
130
+ gate_items.append({
131
+ "violation_id": v.violation_id,
132
+ "signature": v.taxonomy,
133
+ "attribution": attributions[v.violation_id],
134
+ "consequence": consequences[v.violation_id],
135
+ })
136
+
137
+ metrics = compute_metrics(scenario_summaries, violations, consequences)
138
+ cluster_items = [{
139
+ "violation_id": v["violation_id"], "signature": v["taxonomy"],
140
+ "primary_layer": attributions[v["violation_id"]]["primary_layer"],
141
+ "consequence_class":
142
+ consequences[v["violation_id"]]["consequence_class"],
143
+ } for v in violations]
144
+ clusters = build_clusters(cluster_items)
145
+ gate = evaluate_gate(run_id, gate_items,
146
+ forward_to_agentic=forward_to_agentic)
147
+
148
+ run_doc = {
149
+ "run_id": run_id, "bank_id": bank_id,
150
+ "model_version": doc["model_version"],
151
+ "ruleset_version": RULESET_VERSION,
152
+ "n_scenarios": len(scenarios),
153
+ "scenario_summaries": scenario_summaries,
154
+ "violations": violations,
155
+ "attributions": attributions,
156
+ "consequences": consequences,
157
+ "metrics": metrics,
158
+ "clusters": clusters,
159
+ "gate": gate.model_dump(),
160
+ }
161
+ store.write_json(run_doc, "runs", run_id, "run.json")
162
+ build_queue(run_id, violations, clusters)
163
+ return run_doc
164
+
165
+
166
+ def load_run(run_id: str) -> Optional[Dict]:
167
+ return store.read_json("runs", run_id, "run.json")
168
+
169
+
170
+ def list_runs() -> List[Dict]:
171
+ out = []
172
+ for name in store.list_dir("runs"):
173
+ doc = store.read_json("runs", name, "run.json")
174
+ if doc:
175
+ out.append({
176
+ "run_id": doc["run_id"], "bank_id": doc["bank_id"],
177
+ "model_version": doc["model_version"],
178
+ "n_scenarios": doc["n_scenarios"],
179
+ "n_violations": len(doc["violations"]),
180
+ "gate_outcome": doc["gate"]["outcome"],
181
+ "rotr_recall": doc["metrics"]["rotr_recall"],
182
+ })
183
+ return out
184
+
185
+
186
+ def load_consequence(run_id: str, violation_id: str) -> Optional[Dict]:
187
+ return store.read_json("runs", run_id, "consequences",
188
+ f"{violation_id}.json")
sensorflow/rotr/rules.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic, versioned ROTR rule engine.
2
+
3
+ The engine evaluates a TRAJECTORY against an EXPECTED-BEHAVIOR ENVELOPE
4
+ derived from a road context and a set of actor views. It is deliberately
5
+ source-agnostic and is used twice:
6
+
7
+ * detection — executed ego trajectory + ACTUAL (as-built) context + ground-
8
+ truth actor states: "did the vehicle violate right-of-the-road?"
9
+ * plan compliance (attribution.py) — planned trajectory + MAPPED context +
10
+ the STACK'S OWN actor view (detections + predicted intents), shifted into
11
+ the believed localization frame: "was the plan compliant given what the
12
+ stack believed?" — the planning layer is implicated only when this fails.
13
+
14
+ This dual use is what keeps causal layers separated: the same rule text,
15
+ different evidence sources. Rules never guess causes; they emit structured
16
+ ROTRViolation records with the evidence fields that drove the decision.
17
+
18
+ All thresholds in CONFIG are ILLUSTRATIVE (synthetic substrate).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import math
26
+ from typing import Dict, List, Optional
27
+
28
+ # REUSE (landed, read-only): rotated-rectangle separation from SSAM.
29
+ from sensorflow.safety.ssam_ext import rect_gap
30
+
31
+ from sensorflow.rotr import SOFTWARE_VERSION
32
+ from sensorflow.rotr.models import (
33
+ Provenance, ROTRScenario, ROTRViolation, RoadContext,
34
+ )
35
+
36
+ RULESET_VERSION = "rotr-rules-1.0.0"
37
+
38
+ CONFIG: Dict[str, float] = {
39
+ # ILLUSTRATIVE thresholds — calibrated only against the synthetic bank.
40
+ "yield_max_transit_speed_mps": 2.0, # faster than this through an
41
+ # occupied crosswalk = not yielding
42
+ "ped_roadway_half_width_m": 4.5, # |y| below which a ped is "in roadway"
43
+ "crosswalk_margin_m": 0.5,
44
+ "restricted_min_frames": 5,
45
+ "lane_assoc_tolerance_m": 2.1, # 0.6 * lane width
46
+ "signal_entry_min_speed_mps": 0.5,
47
+ "merge_min_time_gap_s": 1.5,
48
+ "stop_max_speed_mps": 0.7, # above this at the line = no stop
49
+ "stop_window_m": 15.0,
50
+ }
51
+
52
+ EGO_DIMS = (4.5, 1.9)
53
+
54
+
55
+ def ruleset_fingerprint() -> str:
56
+ blob = json.dumps({"version": RULESET_VERSION, "config": CONFIG},
57
+ sort_keys=True)
58
+ return hashlib.sha256(blob.encode()).hexdigest()[:12]
59
+
60
+
61
+ # ------------------------------------------------------------ view builders
62
+
63
+
64
+ def gt_actor_views(scenario: ROTRScenario) -> List[Dict]:
65
+ """The world as it is: ground-truth actor states + true intent."""
66
+ return [{
67
+ "actor_id": a.actor_id, "class_name": a.class_name, "dims": a.dims,
68
+ "intent": a.intent, "right_of_way": a.right_of_way,
69
+ "states": [{"t": s.t, "x": s.x, "y": s.y, "vx": s.vx, "vy": s.vy,
70
+ "yaw": s.yaw} for s in a.states],
71
+ } for a in scenario.actors]
72
+
73
+
74
+ def stack_actor_views(scenario: ROTRScenario) -> List[Dict]:
75
+ """The world as the stack saw it: detected positions + predicted intent.
76
+
77
+ Frames where the actor was not detected are simply absent from the view
78
+ (the stack cannot plan around what it did not see). Actors with no
79
+ telemetry at all (observation=None) yield an empty view.
80
+ """
81
+ out = []
82
+ for a in scenario.actors:
83
+ states = []
84
+ for s in a.states:
85
+ o = s.observation
86
+ if o is None or not o.detected:
87
+ continue
88
+ states.append({"t": s.t, "x": o.x, "y": o.y,
89
+ "vx": s.vx, "vy": s.vy, "yaw": s.yaw})
90
+ out.append({
91
+ "actor_id": a.actor_id, "class_name": a.class_name,
92
+ "dims": a.dims,
93
+ "intent": a.predicted_intent, # the STACK's belief
94
+ "right_of_way": a.right_of_way,
95
+ "states": states,
96
+ })
97
+ return out
98
+
99
+
100
+ def ego_trajectory(scenario: ROTRScenario) -> List[Dict]:
101
+ return [{"t": e.t, "x": e.x, "y": e.y, "speed": e.speed}
102
+ for e in scenario.ego]
103
+
104
+
105
+ def planned_trajectory_believed_frame(scenario: ROTRScenario) -> List[Dict]:
106
+ """Plan shifted into the believed localization frame: the plan is judged
107
+ where the stack believed the vehicle to be, not where it truly was."""
108
+ if not scenario.planned or not scenario.ego:
109
+ return []
110
+ e0 = scenario.ego[0]
111
+ dy = (e0.believed_y - e0.y) if e0.believed_y is not None else 0.0
112
+ dx = (e0.believed_x - e0.x) if e0.believed_x is not None else 0.0
113
+ return [{"t": p.t, "x": p.x + dx, "y": p.y + dy, "speed": p.v}
114
+ for p in scenario.planned]
115
+
116
+
117
+ # ------------------------------------------------------------ helpers
118
+
119
+
120
+ def _nearest_lane(ctx: RoadContext, y: float, tol: float):
121
+ best, best_d = None, 1e9
122
+ for lane in ctx.lanes:
123
+ d = abs(lane.center_y - y)
124
+ if d < best_d:
125
+ best, best_d = lane, d
126
+ return (best, best_d) if best is not None and best_d <= tol else (None, None)
127
+
128
+
129
+ def _state_at(actor: Dict, t: float) -> Optional[Dict]:
130
+ best, best_dt = None, 0.25
131
+ for s in actor["states"]:
132
+ dt = abs(s["t"] - t)
133
+ if dt < best_dt:
134
+ best, best_dt = s, dt
135
+ return best
136
+
137
+
138
+ def _clearance(traj_pt: Dict, actor: Dict, st: Dict) -> float:
139
+ return rect_gap((traj_pt["x"], traj_pt["y"]), 0.0, *EGO_DIMS,
140
+ (st["x"], st["y"]), st.get("yaw", 0.0),
141
+ actor["dims"][0], actor["dims"][1])
142
+
143
+
144
+ # ------------------------------------------------------------ rules
145
+ # Each rule: (traj, ctx, actors) -> Optional[evidence dict]. A returned dict
146
+ # means the trajectory exits the expected-behavior envelope for that rule.
147
+
148
+
149
+ def _rule_yield_ped(traj: List[Dict], ctx: RoadContext,
150
+ actors: List[Dict]) -> Optional[Dict]:
151
+ """R-YIELD-PED-01: yield to a crossing pedestrian at a crosswalk."""
152
+ if not ctx.crosswalks:
153
+ return None
154
+ cw = ctx.crosswalks[0]
155
+ margin = CONFIG["crosswalk_margin_m"]
156
+ for actor in actors:
157
+ if actor["class_name"] not in ("pedestrian", "cyclist"):
158
+ continue
159
+ if actor["intent"] != "CROSSING" or actor["right_of_way"] != "HAS_ROW":
160
+ continue
161
+ # Ped must actually enter the roadway for the obligation to bind.
162
+ if not any(abs(s["y"]) < 3.5 for s in actor["states"]):
163
+ continue
164
+ worst = None
165
+ for p in traj:
166
+ if not (cw.x_min - margin <= p["x"] <= cw.x_max + margin):
167
+ continue
168
+ st = _state_at(actor, p["t"])
169
+ if st is None:
170
+ continue
171
+ if not (cw.x_min - margin <= st["x"] <= cw.x_max + margin):
172
+ continue
173
+ if abs(st["y"]) > CONFIG["ped_roadway_half_width_m"]:
174
+ continue
175
+ if p["speed"] > CONFIG["yield_max_transit_speed_mps"]:
176
+ gap = _clearance(p, actor, st)
177
+ if worst is None or gap < worst["min_clearance_m"]:
178
+ worst = {"t": round(p["t"], 2),
179
+ "ego_speed_mps": round(p["speed"], 2),
180
+ "ped_y_m": round(st["y"], 2),
181
+ "min_clearance_m": round(gap, 3),
182
+ "actor_id": actor["actor_id"]}
183
+ if worst:
184
+ worst["threshold_speed_mps"] = CONFIG["yield_max_transit_speed_mps"]
185
+ return worst
186
+ return None
187
+
188
+
189
+ def _rule_restricted_path(traj: List[Dict], ctx: RoadContext,
190
+ actors: List[Dict]) -> Optional[Dict]:
191
+ """R-PATH-RESTRICT-02: do not occupy a lane restricted to another class."""
192
+ frames_in, lane_id = 0, None
193
+ for p in traj:
194
+ lane, _ = _nearest_lane(ctx, p["y"], CONFIG["lane_assoc_tolerance_m"])
195
+ if lane is not None and lane.restricted_to not in (None, "any", "car"):
196
+ frames_in += 1
197
+ lane_id = lane.lane_id
198
+ if frames_in >= CONFIG["restricted_min_frames"]:
199
+ return {"lane_id": lane_id, "frames_in_restricted": frames_in,
200
+ "duration_s": round(frames_in * (traj[1]["t"] - traj[0]["t"]), 2)
201
+ if len(traj) > 1 else 0.0}
202
+ return None
203
+
204
+
205
+ def _rule_lane_maneuver(traj: List[Dict], ctx: RoadContext,
206
+ actors: List[Dict]) -> Optional[Dict]:
207
+ """R-LANE-MANEUVER-03: only maneuvers permitted from the occupied lane."""
208
+ if ctx.intersection_x_min is None or ctx.stop_line_x is None:
209
+ return None
210
+ entry = [p for p in traj if p["x"] < ctx.stop_line_x]
211
+ through = [p for p in traj if p["x"] > (ctx.intersection_x_max or 0)]
212
+ if not entry or not through:
213
+ return None
214
+ lane, _ = _nearest_lane(ctx, entry[-1]["y"], CONFIG["lane_assoc_tolerance_m"])
215
+ if lane is None:
216
+ return None
217
+ went_straight = abs(through[0]["y"] - entry[-1]["y"]) < 1.5
218
+ if went_straight and "STRAIGHT" not in lane.permitted_maneuvers:
219
+ return {"lane_id": lane.lane_id, "maneuver": "STRAIGHT",
220
+ "permitted": lane.permitted_maneuvers,
221
+ "entry_y_m": round(entry[-1]["y"], 2)}
222
+ return None
223
+
224
+
225
+ def _rule_signal_conflict(traj: List[Dict], ctx: RoadContext,
226
+ actors: List[Dict]) -> Optional[Dict]:
227
+ """R-INT-CONFLICT-04: do not enter a signalized intersection on red."""
228
+ if ctx.control != "signal" or ctx.signal_state_for_ego != "red":
229
+ return None
230
+ if ctx.intersection_x_min is None:
231
+ return None
232
+ for p in traj:
233
+ if p["x"] >= ctx.intersection_x_min and \
234
+ p["speed"] > CONFIG["signal_entry_min_speed_mps"]:
235
+ ev = {"t": round(p["t"], 2), "entry_speed_mps": round(p["speed"], 2),
236
+ "signal_state": "red"}
237
+ # Conflicting right-of-way actor evidence (severity, not verdict).
238
+ worst_gap, worst_id = None, None
239
+ for actor in actors:
240
+ if actor["right_of_way"] != "HAS_ROW":
241
+ continue
242
+ for q in traj:
243
+ if q["x"] < ctx.intersection_x_min:
244
+ continue
245
+ st = _state_at(actor, q["t"])
246
+ if st is None:
247
+ continue
248
+ gap = _clearance(q, actor, st)
249
+ if worst_gap is None or gap < worst_gap:
250
+ worst_gap, worst_id = gap, actor["actor_id"]
251
+ if worst_gap is not None:
252
+ ev["min_clearance_m"] = round(worst_gap, 3)
253
+ ev["actor_id"] = worst_id
254
+ return ev
255
+ return None
256
+
257
+
258
+ def _rule_merge_gap(traj: List[Dict], ctx: RoadContext,
259
+ actors: List[Dict]) -> Optional[Dict]:
260
+ """R-MERGE-GAP-05: merging in front of right-of-way traffic needs a gap."""
261
+ tol = CONFIG["lane_assoc_tolerance_m"]
262
+ lane_seq = []
263
+ for p in traj:
264
+ lane, _ = _nearest_lane(ctx, p["y"], tol)
265
+ lane_seq.append(lane.lane_id if lane else None)
266
+ change_idx = None
267
+ for k in range(1, len(lane_seq)):
268
+ if lane_seq[k] and lane_seq[k - 1] and lane_seq[k] != lane_seq[k - 1]:
269
+ change_idx = k
270
+ if change_idx is None:
271
+ return None
272
+ p = traj[change_idx]
273
+ target = lane_seq[change_idx]
274
+ for actor in actors:
275
+ if actor["right_of_way"] != "HAS_ROW":
276
+ continue
277
+ st = _state_at(actor, p["t"])
278
+ if st is None:
279
+ continue
280
+ lane, _ = _nearest_lane(ctx, st["y"], tol)
281
+ if lane is None or lane.lane_id != target:
282
+ continue
283
+ if st["x"] >= p["x"]:
284
+ continue # only trailing traffic holds the gap
285
+ speed = math.hypot(st["vx"], st["vy"])
286
+ bumper_gap = (p["x"] - st["x"]) - 0.5 * (EGO_DIMS[0] + actor["dims"][0])
287
+ time_gap = bumper_gap / max(speed, 0.1)
288
+ if time_gap < CONFIG["merge_min_time_gap_s"]:
289
+ return {"t": round(p["t"], 2), "target_lane": target,
290
+ "actor_id": actor["actor_id"],
291
+ "bumper_gap_m": round(bumper_gap, 2),
292
+ "time_gap_s": round(time_gap, 2),
293
+ "threshold_time_gap_s": CONFIG["merge_min_time_gap_s"]}
294
+ return None
295
+
296
+
297
+ def _rule_stop(traj: List[Dict], ctx: RoadContext,
298
+ actors: List[Dict]) -> Optional[Dict]:
299
+ """R-STOP-06: a stop control requires an actual stop before the line."""
300
+ if ctx.control != "stop_sign" or ctx.stop_line_x is None:
301
+ return None
302
+ line = ctx.stop_line_x
303
+ window = [p for p in traj
304
+ if line - CONFIG["stop_window_m"] <= p["x"] <= line + 1.0]
305
+ crossed = any(p["x"] > line for p in traj)
306
+ if not window or not crossed:
307
+ return None
308
+ min_speed = min(p["speed"] for p in window)
309
+ if min_speed > CONFIG["stop_max_speed_mps"]:
310
+ return {"min_speed_in_window_mps": round(min_speed, 2),
311
+ "stop_line_x": line,
312
+ "threshold_mps": CONFIG["stop_max_speed_mps"]}
313
+ return None
314
+
315
+
316
+ RULES = [
317
+ ("R-YIELD-PED-01", "failure to yield to crossing pedestrian", _rule_yield_ped),
318
+ ("R-PATH-RESTRICT-02", "restricted-path entry", _rule_restricted_path),
319
+ ("R-LANE-MANEUVER-03", "maneuver not permitted from occupied lane",
320
+ _rule_lane_maneuver),
321
+ ("R-INT-CONFLICT-04", "entered signalized intersection on red",
322
+ _rule_signal_conflict),
323
+ ("R-MERGE-GAP-05", "merge with insufficient gap to right-of-way traffic",
324
+ _rule_merge_gap),
325
+ ("R-STOP-06", "failed to stop at stop control", _rule_stop),
326
+ ]
327
+
328
+
329
+ # ------------------------------------------------------------ entry points
330
+
331
+
332
+ def evaluate_trajectory(traj: List[Dict], ctx: RoadContext,
333
+ actors: List[Dict]) -> List[Dict]:
334
+ """Run every rule; return [{rule_id, description, evidence}]."""
335
+ hits = []
336
+ for rule_id, desc, fn in RULES:
337
+ ev = fn(traj, ctx, actors)
338
+ if ev is not None:
339
+ hits.append({"rule_id": rule_id, "description": desc,
340
+ "evidence": ev})
341
+ return hits
342
+
343
+
344
+ def detect(scenario: ROTRScenario) -> List[ROTRViolation]:
345
+ """Detection: executed trajectory vs the ACTUAL world (GT evidence)."""
346
+ traj = ego_trajectory(scenario)
347
+ hits = evaluate_trajectory(traj, scenario.actual_context,
348
+ gt_actor_views(scenario))
349
+ out = []
350
+ for h in hits:
351
+ ev = h["evidence"]
352
+ vid = f"{scenario.scenario_id}-{h['rule_id']}"
353
+ out.append(ROTRViolation(
354
+ violation_id=vid, scenario_id=scenario.scenario_id,
355
+ rule_id=h["rule_id"], rule_version=RULESET_VERSION,
356
+ description=f"{h['description']} — {scenario.description}",
357
+ actor_ids=[ev["actor_id"]] if "actor_id" in ev else [],
358
+ t_start=float(ev.get("t", 0.0)), t_end=float(ev.get("t", 0.0)),
359
+ evidence={**ev, "ruleset_fingerprint": ruleset_fingerprint(),
360
+ "thresholds_note": "ILLUSTRATIVE (synthetic substrate)"},
361
+ confidence=0.9,
362
+ provenance=Provenance(
363
+ scenario_id=scenario.scenario_id,
364
+ dataset_version=scenario.provenance.dataset_version,
365
+ model_version=scenario.provenance.model_version,
366
+ software_version=f"{SOFTWARE_VERSION}/{RULESET_VERSION}",
367
+ source=scenario.provenance.source, confidence=0.9)))
368
+ return out
369
+
370
+
371
+ def plan_violations(scenario: ROTRScenario) -> List[Dict]:
372
+ """Plan compliance given the stack's OWN world view (mapped context,
373
+ detections, predicted intents, believed localization frame). Used by
374
+ attribution: a compliant plan RULES OUT the planning layer."""
375
+ traj = planned_trajectory_believed_frame(scenario)
376
+ if not traj:
377
+ return []
378
+ return evaluate_trajectory(traj, scenario.map_context,
379
+ stack_actor_views(scenario))
sensorflow/rotr/scenes.py ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic road-context scenario substrate for ROTR.
2
+
3
+ Extends the platform's scene conventions (REUSE, read-only:
4
+ bevfusion.scenes DT / EGO_SPEED / seeded default_rng discipline) with what
5
+ bevfusion deliberately does not model: lane geometry with permitted
6
+ maneuvers, intersection topology (controlled/uncontrolled, signals/signs),
7
+ crosswalks, and multi-actor right-of-way states + intent.
8
+
9
+ A bank is a pure function of (n_scenarios, seed, model_version): the same
10
+ inputs produce byte-identical scenarios. `model_version` selects a stack
11
+ profile that determines — deterministically per scenario — whether the
12
+ stack COMMITS each planted violation opportunity; this is how
13
+ baseline-vs-candidate regression banks are materialized (the bank is "the
14
+ log of that software driving the same scenario set", i.e. resimulation).
15
+
16
+ Planted truth includes NON-violations that superficially resemble
17
+ violations (legal assertive merge; ego yielding while holding right-of-way)
18
+ so the rule engine's false-accusation rate is measurable.
19
+
20
+ All numeric thresholds/kinematics here are ILLUSTRATIVE (synthetic
21
+ substrate); the generator's contract, not its constants, is the deliverable.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import hashlib
27
+ import math
28
+ from typing import Dict, List, Optional, Tuple
29
+
30
+ import numpy as np
31
+
32
+ # REUSE (landed, read-only): platform time base and nominal ego speed.
33
+ from sensorflow.bevfusion.scenes import DT, EGO_SPEED
34
+
35
+ from sensorflow.rotr import SOFTWARE_VERSION
36
+ from sensorflow.rotr.models import (
37
+ Actor, ActorObservation, ActorState, Crosswalk, EgoState, Environment,
38
+ Lane, PlannedPoint, PlantedTruth, Provenance, ROTRScenario, RoadContext,
39
+ )
40
+
41
+ N_FRAMES = 70
42
+ X0 = -10.0
43
+ V_NOM = float(EGO_SPEED) # 10 m/s
44
+ LANE_W = 3.5
45
+
46
+ # Intersection geometry shared by intersection templates.
47
+ STOP_LINE_X = 38.0
48
+ CW_X_MIN, CW_X_MAX = 39.5, 44.5 # crosswalk span along x
49
+ BOX_X_MIN, BOX_X_MAX = 39.5, 52.0
50
+
51
+ DATASET_VERSION = "rotr-bank-1"
52
+ GENERATOR_VERSION = "rotr-scenes-1.0.0"
53
+
54
+ # Stack profiles: per-cause commit probability for planted violation
55
+ # opportunities. Deterministic per scenario (seeded rng draw).
56
+ MODEL_PROFILES: Dict[str, Dict[str, float]] = {
57
+ "stack-v1": {"perception": 0.90, "prediction": 0.90, "planning": 0.50,
58
+ "localization": 1.00, "map": 1.00, "control": 0.80,
59
+ "data_label": 1.00},
60
+ "stack-v2-improved": {"perception": 0.25, "prediction": 0.90,
61
+ "planning": 0.50, "localization": 1.00,
62
+ "map": 1.00, "control": 0.80, "data_label": 1.00},
63
+ "stack-v3-planning-regression": {"perception": 0.25, "prediction": 0.90,
64
+ "planning": 1.00, "localization": 1.00,
65
+ "map": 1.00, "control": 0.80,
66
+ "data_label": 1.00},
67
+ }
68
+ DEFAULT_MODEL = "stack-v1"
69
+
70
+
71
+ # ------------------------------------------------------------ contexts
72
+
73
+
74
+ def _lane(lane_id: str, y: float, maneuvers: List[str],
75
+ restricted_to: Optional[str] = None) -> Lane:
76
+ return Lane(lane_id=lane_id, center_y=y, permitted_maneuvers=maneuvers,
77
+ restricted_to=restricted_to)
78
+
79
+
80
+ def _ctx_uncontrolled() -> RoadContext:
81
+ return RoadContext(
82
+ context_id="uncontrolled-xing", intersection_type="uncontrolled",
83
+ control="none", stop_line_x=STOP_LINE_X,
84
+ intersection_x_min=BOX_X_MIN, intersection_x_max=BOX_X_MAX,
85
+ lanes=[_lane("L0", 0.0, ["STRAIGHT", "LANE_CHANGE"]),
86
+ _lane("L-1", -LANE_W, ["STRAIGHT", "LANE_CHANGE"])],
87
+ crosswalks=[Crosswalk(crosswalk_id="cw1", x_min=CW_X_MIN, x_max=CW_X_MAX)])
88
+
89
+
90
+ def _ctx_bus(restricted: bool) -> RoadContext:
91
+ return RoadContext(
92
+ context_id="bus-corridor", intersection_type="none", control="none",
93
+ lanes=[_lane("L0", 0.0, ["STRAIGHT", "LANE_CHANGE"]),
94
+ _lane("LBUS", -LANE_W, ["STRAIGHT"],
95
+ restricted_to="bus" if restricted else None)])
96
+
97
+
98
+ def _ctx_turnlane() -> RoadContext:
99
+ return RoadContext(
100
+ context_id="turn-lane-xing", intersection_type="uncontrolled",
101
+ control="none", stop_line_x=STOP_LINE_X,
102
+ intersection_x_min=BOX_X_MIN, intersection_x_max=BOX_X_MAX,
103
+ lanes=[_lane("L0", 0.0, ["STRAIGHT", "LANE_CHANGE"]),
104
+ _lane("LT", LANE_W, ["LEFT_TURN"])])
105
+
106
+
107
+ def _ctx_signal(mapped_signal: bool) -> RoadContext:
108
+ return RoadContext(
109
+ context_id="signal-xing", intersection_type="controlled",
110
+ control="signal" if mapped_signal else "none",
111
+ signal_state_for_ego="red" if mapped_signal else None,
112
+ stop_line_x=STOP_LINE_X,
113
+ intersection_x_min=BOX_X_MIN, intersection_x_max=BOX_X_MAX,
114
+ lanes=[_lane("L0", 0.0, ["STRAIGHT"])],
115
+ crosswalks=[Crosswalk(crosswalk_id="cw1", x_min=CW_X_MIN, x_max=CW_X_MAX)])
116
+
117
+
118
+ def _ctx_stopsign() -> RoadContext:
119
+ return RoadContext(
120
+ context_id="stopsign-xing", intersection_type="controlled",
121
+ control="stop_sign", stop_line_x=STOP_LINE_X,
122
+ intersection_x_min=BOX_X_MIN, intersection_x_max=BOX_X_MAX,
123
+ lanes=[_lane("L0", 0.0, ["STRAIGHT"])],
124
+ crosswalks=[Crosswalk(crosswalk_id="cw1", x_min=CW_X_MIN, x_max=CW_X_MAX)])
125
+
126
+
127
+ def _ctx_merge() -> RoadContext:
128
+ return RoadContext(
129
+ context_id="merge-corridor", intersection_type="none", control="none",
130
+ lanes=[_lane("L0", 0.0, ["STRAIGHT", "LANE_CHANGE"]),
131
+ _lane("L-1", -LANE_W, ["STRAIGHT", "LANE_CHANGE"])])
132
+
133
+
134
+ # ------------------------------------------------------------ trajectories
135
+
136
+
137
+ def _traj_const(v: float = V_NOM, y: float = 0.0) -> List[Dict]:
138
+ return [{"t": fi * DT, "x": X0 + v * fi * DT, "y": y, "speed": v,
139
+ "accel": 0.0} for fi in range(N_FRAMES)]
140
+
141
+
142
+ def _traj_stop(x_stop: float, decel: float = 3.0, v0: float = V_NOM,
143
+ y: float = 0.0, resume_t: Optional[float] = None,
144
+ resume_accel: float = 2.0) -> List[Dict]:
145
+ """Cruise, brake to a stop at x_stop, optionally resume."""
146
+ d_brake = v0 * v0 / (2 * decel)
147
+ x_brake = x_stop - d_brake
148
+ pts, x, v, a = [], X0, v0, 0.0
149
+ for fi in range(N_FRAMES):
150
+ t = fi * DT
151
+ pts.append({"t": t, "x": x, "y": y, "speed": v, "accel": a})
152
+ if v > 0 and x >= x_brake:
153
+ a = -decel
154
+ elif v <= 0 and resume_t is not None and t >= resume_t:
155
+ a = resume_accel
156
+ elif v <= 0:
157
+ a = 0.0
158
+ v = max(0.0, min(v0, v + a * DT))
159
+ if v == 0.0:
160
+ a = 0.0
161
+ x += v * DT
162
+ return pts
163
+
164
+
165
+ def _traj_rolling(x_line: float, v_min: float = 3.0, v0: float = V_NOM,
166
+ y: float = 0.0) -> List[Dict]:
167
+ """Slow to v_min at the stop line, never stop, resume to v0."""
168
+ decel = 2.5
169
+ d = (v0 * v0 - v_min * v_min) / (2 * decel)
170
+ x_brake = x_line - d
171
+ pts, x, v, a = [], X0, v0, 0.0
172
+ for fi in range(N_FRAMES):
173
+ t = fi * DT
174
+ pts.append({"t": t, "x": x, "y": y, "speed": v, "accel": a})
175
+ if x >= x_brake and v > v_min and x < x_line:
176
+ a = -decel
177
+ elif x >= x_line and v < v0:
178
+ a = 2.0
179
+ else:
180
+ a = 0.0
181
+ v = max(v_min if x >= x_brake else 0.0, min(v0, v + a * DT))
182
+ x += v * DT
183
+ return pts
184
+
185
+
186
+ def _traj_lane_change(t_start: float, t_end: float, y_from: float,
187
+ y_to: float, v: float = V_NOM) -> List[Dict]:
188
+ pts = []
189
+ for fi in range(N_FRAMES):
190
+ t = fi * DT
191
+ if t <= t_start:
192
+ y = y_from
193
+ elif t >= t_end:
194
+ y = y_to
195
+ else:
196
+ frac = (t - t_start) / (t_end - t_start)
197
+ y = y_from + (y_to - y_from) * (1 - math.cos(math.pi * frac)) / 2
198
+ pts.append({"t": t, "x": X0 + v * t, "y": y, "speed": v, "accel": 0.0})
199
+ return pts
200
+
201
+
202
+ def _ego_states(pts: List[Dict], context: RoadContext,
203
+ loc_offset_y: float = 0.0) -> List[EgoState]:
204
+ """Attach true lane association + localization (believed) view."""
205
+ out = []
206
+ for p in pts:
207
+ lane = _nearest_lane(context, p["y"])
208
+ out.append(EgoState(
209
+ t=p["t"], x=p["x"], y=p["y"], yaw=0.0, speed=p["speed"],
210
+ accel=p["accel"], lane_id=lane.lane_id if lane else None,
211
+ believed_x=p["x"], believed_y=p["y"] + loc_offset_y,
212
+ believed_lane_id=(_nearest_lane(context, p["y"] + loc_offset_y).lane_id
213
+ if _nearest_lane(context, p["y"] + loc_offset_y)
214
+ else None)))
215
+ return out
216
+
217
+
218
+ def _nearest_lane(context: RoadContext, y: float) -> Optional[Lane]:
219
+ best, best_d = None, 1e9
220
+ for lane in context.lanes:
221
+ d = abs(lane.center_y - y)
222
+ if d < best_d:
223
+ best, best_d = lane, d
224
+ return best if best_d <= LANE_W * 0.6 else None
225
+
226
+
227
+ def _planned(pts: List[Dict]) -> List[PlannedPoint]:
228
+ return [PlannedPoint(t=p["t"], x=p["x"], y=p["y"], v=p["speed"])
229
+ for p in pts]
230
+
231
+
232
+ # ------------------------------------------------------------ actors
233
+
234
+
235
+ def _observe(rng: np.random.Generator, st: Dict, cls: str,
236
+ mode: str) -> Optional[ActorObservation]:
237
+ """Plant the stack's observation of a GT actor state.
238
+
239
+ mode: good | missed | late(handled by caller) | absent(None=telemetry gap)
240
+ """
241
+ if mode == "absent":
242
+ return None
243
+ if mode == "missed":
244
+ return ActorObservation(detected=False)
245
+ return ActorObservation(
246
+ detected=True,
247
+ x=st["x"] + float(rng.normal(0, 0.12)),
248
+ y=st["y"] + float(rng.normal(0, 0.08)),
249
+ class_name=cls)
250
+
251
+
252
+ def _actor(rng: np.random.Generator, actor_id: str, cls: str,
253
+ dims: List[float], row: str, intent: str,
254
+ predicted_intent: Optional[str],
255
+ state_fn, obs_mode_fn) -> Actor:
256
+ states = []
257
+ for fi in range(N_FRAMES):
258
+ t = fi * DT
259
+ x, y, vx, vy, yaw = state_fn(t)
260
+ st = {"x": x, "y": y}
261
+ states.append(ActorState(
262
+ t=t, x=x, y=y, vx=vx, vy=vy, yaw=yaw,
263
+ observation=_observe(rng, st, cls, obs_mode_fn(t))))
264
+ return Actor(actor_id=actor_id, class_name=cls, dims=dims,
265
+ right_of_way=row, intent=intent,
266
+ predicted_intent=predicted_intent, states=states)
267
+
268
+
269
+ def _crossing_ped_fn(x: float = 44.9, y0: float = 6.0, vy: float = -1.4,
270
+ y_end: float = -7.0):
271
+ # x=44.9 places the crossing at the far edge of the crosswalk so a
272
+ # non-yielding ego produces a deterministic NEAR-miss (sub-meter
273
+ # clearance), not a synthetic collision.
274
+ def fn(t: float) -> Tuple[float, float, float, float, float]:
275
+ y = max(y_end, y0 + vy * t)
276
+ moving = y > y_end
277
+ return x, y, 0.0, vy if moving else 0.0, -math.pi / 2
278
+ return fn
279
+
280
+
281
+ def _waiting_ped_fn(x: float = 42.5, y: float = 5.5):
282
+ def fn(t: float):
283
+ return x, y, 0.0, 0.0, -math.pi / 2
284
+ return fn
285
+
286
+
287
+ def _crossing_vehicle_fn(x: float = 45.5, y0: float = 20.0, vy: float = -2.8):
288
+ def fn(t: float):
289
+ return x, y0 + vy * t, 0.0, vy, -math.pi / 2
290
+ return fn
291
+
292
+
293
+ def _trailing_vehicle_fn(x0: float, vx: float = 13.0,
294
+ brake_t: Optional[float] = None,
295
+ brake_a: float = 2.5, v_min: float = 8.5):
296
+ def fn(t: float):
297
+ if brake_t is None or t <= brake_t:
298
+ return x0 + vx * t, -LANE_W, vx, 0.0, 0.0
299
+ tb = t - brake_t
300
+ v = max(v_min, vx - brake_a * tb)
301
+ t_ramp = (vx - v_min) / brake_a
302
+ if tb <= t_ramp:
303
+ x = x0 + vx * brake_t + vx * tb - 0.5 * brake_a * tb * tb
304
+ else:
305
+ x = (x0 + vx * brake_t + vx * t_ramp - 0.5 * brake_a * t_ramp ** 2
306
+ + v_min * (tb - t_ramp))
307
+ return x, -LANE_W, v, 0.0, 0.0
308
+ return fn
309
+
310
+
311
+ ALWAYS_GOOD = lambda t: "good" # noqa: E731
312
+ NEVER_SEEN = lambda t: "missed" # noqa: E731
313
+ TELEMETRY_GAP = lambda t: "absent" # noqa: E731
314
+
315
+
316
+ # ------------------------------------------------------------ templates
317
+ #
318
+ # Each template returns the scenario pieces given (rng, committed).
319
+ # `cause` is the planted causal layer for the committed variant.
320
+
321
+ VEH_DIMS = [4.5, 1.9, 1.6]
322
+ PED_DIMS = [0.6, 0.6, 1.75]
323
+
324
+
325
+ def _tpl_fail_yield_ped(rng, committed: bool, cause: str) -> Dict:
326
+ ctx = _ctx_uncontrolled()
327
+ obs_mode = NEVER_SEEN if (committed and cause == "perception") else \
328
+ (TELEMETRY_GAP if (committed and cause == "data_label") else ALWAYS_GOOD)
329
+ predicted = None if cause == "data_label" else \
330
+ ("WAITING" if (committed and cause == "prediction") else "CROSSING")
331
+ ped = _actor(rng, "ped-1", "pedestrian", PED_DIMS, "HAS_ROW", "CROSSING",
332
+ predicted, _crossing_ped_fn(), obs_mode)
333
+ if committed:
334
+ exec_pts = _traj_const()
335
+ plan_pts = exec_pts
336
+ else:
337
+ exec_pts = _traj_stop(STOP_LINE_X - 0.5, resume_t=6.5)
338
+ plan_pts = exec_pts
339
+ ego = _ego_states(exec_pts, ctx)
340
+ if committed and cause == "data_label":
341
+ # Planted record-integrity fault: disordered timestamps mid-approach.
342
+ for k in range(30, 34):
343
+ ego[k].t = ego[k].t - 0.35
344
+ return {"map_context": ctx, "actual_context": ctx, "ego": ego,
345
+ "planned": _planned(plan_pts), "actors": [ped],
346
+ "expected_rule": "R-YIELD-PED-01",
347
+ "description": "pedestrian crossing at uncontrolled intersection; "
348
+ + ("ego proceeds without yielding" if committed
349
+ else "ego yields correctly")}
350
+
351
+
352
+ def _tpl_restricted_path(rng, committed: bool, cause: str) -> Dict:
353
+ actual = _ctx_bus(restricted=True)
354
+ mapped = _ctx_bus(restricted=(cause != "map"))
355
+ if committed:
356
+ pts = _traj_lane_change(2.0, 3.5, 0.0, -LANE_W)
357
+ else:
358
+ pts = _traj_const()
359
+ return {"map_context": mapped, "actual_context": actual,
360
+ "ego": _ego_states(pts, actual), "planned": _planned(pts),
361
+ "actors": [],
362
+ "expected_rule": "R-PATH-RESTRICT-02",
363
+ "description": "bus-restricted lane; "
364
+ + ("ego enters restricted path" if committed
365
+ else "ego stays in general lane")
366
+ + (" (restriction missing from map)"
367
+ if cause == "map" else "")}
368
+
369
+
370
+ def _tpl_wrong_lane(rng, committed: bool, cause: str) -> Dict:
371
+ ctx = _ctx_turnlane()
372
+ # Ego actually drives the left-turn-only lane straight through; its
373
+ # localization believes it is in the through lane (offset -LANE_W).
374
+ pts = _traj_const(y=LANE_W)
375
+ ego = _ego_states(pts, ctx, loc_offset_y=-LANE_W)
376
+ return {"map_context": ctx, "actual_context": ctx, "ego": ego,
377
+ "planned": _planned(pts), "actors": [],
378
+ "expected_rule": "R-LANE-MANEUVER-03",
379
+ "description": "ego proceeds straight from a left-turn-only lane "
380
+ "(wrong lane association from localization error)"}
381
+
382
+
383
+ def _tpl_signal_conflict(rng, committed: bool, cause: str) -> Dict:
384
+ actual = _ctx_signal(mapped_signal=True)
385
+ mapped = _ctx_signal(mapped_signal=(cause != "map"))
386
+ obs_mode = NEVER_SEEN if (committed and cause == "perception") else ALWAYS_GOOD
387
+ veh = _actor(rng, "xveh-1", "vehicle", VEH_DIMS, "HAS_ROW", "CROSSING",
388
+ "CROSSING", _crossing_vehicle_fn(), obs_mode)
389
+ if committed:
390
+ pts = _traj_const()
391
+ else:
392
+ pts = _traj_stop(STOP_LINE_X - 0.5)
393
+ return {"map_context": mapped, "actual_context": actual,
394
+ "ego": _ego_states(pts, actual), "planned": _planned(pts),
395
+ "actors": [veh],
396
+ "expected_rule": "R-INT-CONFLICT-04",
397
+ "description": "red signal with conflicting cross traffic; "
398
+ + ("ego enters on red" if committed
399
+ else "ego stops at the line")
400
+ + (" (signal unmapped)" if cause == "map" else "")}
401
+
402
+
403
+ def _tpl_unsafe_merge(rng, committed: bool, cause: str) -> Dict:
404
+ ctx = _ctx_merge()
405
+ if committed:
406
+ veh = _actor(rng, "mveh-1", "vehicle", VEH_DIMS, "HAS_ROW",
407
+ "PROCEEDING", "PROCEEDING",
408
+ _trailing_vehicle_fn(-31.5, brake_t=4.6), ALWAYS_GOOD)
409
+ pts = _traj_lane_change(3.0, 4.5, 0.0, -LANE_W)
410
+ else:
411
+ veh = _actor(rng, "mveh-1", "vehicle", VEH_DIMS, "HAS_ROW",
412
+ "PROCEEDING", "PROCEEDING",
413
+ _trailing_vehicle_fn(-58.0), ALWAYS_GOOD)
414
+ pts = _traj_const()
415
+ return {"map_context": ctx, "actual_context": ctx,
416
+ "ego": _ego_states(pts, ctx), "planned": _planned(pts),
417
+ "actors": [veh],
418
+ "expected_rule": "R-MERGE-GAP-05",
419
+ "description": "merge ahead of right-of-way traffic with "
420
+ + ("insufficient gap" if committed else "safe gap")}
421
+
422
+
423
+ def _tpl_stop_overshoot(rng, committed: bool, cause: str) -> Dict:
424
+ ctx = _ctx_stopsign()
425
+ ped = _actor(rng, "ped-w1", "pedestrian", PED_DIMS, "NONE", "WAITING",
426
+ "WAITING", _waiting_ped_fn(), ALWAYS_GOOD)
427
+ plan_pts = _traj_stop(STOP_LINE_X - 0.5, resume_t=5.6)
428
+ if committed:
429
+ exec_pts = _traj_rolling(STOP_LINE_X) # actuation under-brakes
430
+ else:
431
+ exec_pts = plan_pts
432
+ return {"map_context": ctx, "actual_context": ctx,
433
+ "ego": _ego_states(exec_pts, ctx), "planned": _planned(plan_pts),
434
+ "actors": [ped],
435
+ "expected_rule": "R-STOP-06",
436
+ "description": "stop sign; " + ("planned stop, executed roll-through "
437
+ "(control tracking error)"
438
+ if committed else "full stop made")}
439
+
440
+
441
+ def _tpl_legal_merge(rng, committed: bool, cause: str) -> Dict:
442
+ d = _tpl_unsafe_merge(rng, committed=False, cause="")
443
+ # Assertive but legal: the merge happens, with a sufficient gap.
444
+ ctx = d["actual_context"]
445
+ pts = _traj_lane_change(3.0, 4.5, 0.0, -LANE_W)
446
+ d.update({"ego": _ego_states(pts, ctx), "planned": _planned(pts),
447
+ "expected_rule": None,
448
+ "description": "legal assertive merge (sufficient gap; planted "
449
+ "NON-violation that superficially resembles one)"})
450
+ return d
451
+
452
+
453
+ def _tpl_yield_with_row(rng, committed: bool, cause: str) -> Dict:
454
+ ctx = _ctx_uncontrolled()
455
+ ped = _actor(rng, "ped-c1", "pedestrian", PED_DIMS, "NONE", "WAITING",
456
+ "WAITING", _waiting_ped_fn(), ALWAYS_GOOD)
457
+ pts = _traj_stop(STOP_LINE_X - 0.5, resume_t=5.8)
458
+ return {"map_context": ctx, "actual_context": ctx,
459
+ "ego": _ego_states(pts, ctx), "planned": _planned(pts),
460
+ "actors": [ped],
461
+ "expected_rule": None,
462
+ "description": "ego yields although it holds right-of-way (curb "
463
+ "pedestrian is waiting; planted NON-violation)"}
464
+
465
+
466
+ def _tpl_green_proceed(rng, committed: bool, cause: str) -> Dict:
467
+ ctx = _ctx_signal(mapped_signal=True)
468
+ ctx.signal_state_for_ego = "green"
469
+ pts = _traj_const()
470
+ return {"map_context": ctx, "actual_context": ctx.model_copy(deep=True),
471
+ "ego": _ego_states(pts, ctx), "planned": _planned(pts),
472
+ "actors": [],
473
+ "expected_rule": None,
474
+ "description": "normal green-light transit (planted NON-violation)"}
475
+
476
+
477
+ # (template fn, planted kind, cause layer or None for non-violations)
478
+ TEMPLATES = [
479
+ (_tpl_fail_yield_ped, "fail_yield_pedestrian", "perception"),
480
+ (_tpl_fail_yield_ped, "fail_yield_pedestrian", "planning"),
481
+ (_tpl_fail_yield_ped, "fail_yield_pedestrian", "prediction"),
482
+ (_tpl_restricted_path, "restricted_path_entry", "planning"),
483
+ (_tpl_restricted_path, "restricted_path_entry", "map"),
484
+ (_tpl_wrong_lane, "wrong_lane_association", "localization"),
485
+ (_tpl_signal_conflict, "intersection_conflict", "map"),
486
+ (_tpl_signal_conflict, "intersection_conflict", "perception"),
487
+ (_tpl_unsafe_merge, "unsafe_merge", "planning"),
488
+ (_tpl_stop_overshoot, "stop_overshoot", "control"),
489
+ (_tpl_fail_yield_ped, "fail_yield_pedestrian", "data_label"),
490
+ (_tpl_legal_merge, "legal_assertive_merge", None),
491
+ (_tpl_yield_with_row, "yield_with_right_of_way", None),
492
+ (_tpl_green_proceed, "green_proceed", None),
493
+ ]
494
+
495
+ VISIBILITIES = ["low", "clear", "clear"]
496
+ LIGHTINGS = ["day", "dusk", "night"]
497
+ WEATHERS = ["clear", "clear", "rain"]
498
+
499
+
500
+ def generate_bank(n_scenarios: int = 28, seed: int = 7,
501
+ model_version: str = DEFAULT_MODEL) -> List[ROTRScenario]:
502
+ """Deterministic scenario bank; same inputs -> identical output."""
503
+ if model_version not in MODEL_PROFILES:
504
+ raise ValueError(f"unknown model_version {model_version!r}; known: "
505
+ f"{sorted(MODEL_PROFILES)}")
506
+ profile = MODEL_PROFILES[model_version]
507
+ bank_id = bank_id_for(n_scenarios, seed, model_version)
508
+ out: List[ROTRScenario] = []
509
+ for i in range(n_scenarios):
510
+ # Same per-sequence seeding discipline as bevfusion.scenes.
511
+ rng = np.random.default_rng(seed * 100003 + i * 7919)
512
+ tpl_fn, kind, cause = TEMPLATES[i % len(TEMPLATES)]
513
+ is_opportunity = cause is not None
514
+ committed = bool(is_opportunity
515
+ and rng.random() < profile.get(cause, 1.0))
516
+ parts = tpl_fn(rng, committed, cause or "")
517
+ env = Environment(visibility=VISIBILITIES[i % 3],
518
+ lighting=LIGHTINGS[i % 3],
519
+ weather=WEATHERS[i % 3])
520
+ sid = f"{bank_id}-sc{i:03d}"
521
+ out.append(ROTRScenario(
522
+ scenario_id=sid, bank_id=bank_id, seed=seed,
523
+ description=parts["description"], environment=env,
524
+ map_context=parts["map_context"],
525
+ actual_context=parts["actual_context"],
526
+ ego=parts["ego"], planned=parts["planned"],
527
+ actors=parts["actors"],
528
+ planted=PlantedTruth(
529
+ kind=kind, is_violation_opportunity=is_opportunity,
530
+ committed=committed,
531
+ expected_rule_id=parts["expected_rule"] if committed else None,
532
+ cause_layer=cause if committed else None,
533
+ notes=parts["description"]),
534
+ provenance=Provenance(
535
+ scenario_id=sid, dataset_version=DATASET_VERSION,
536
+ model_version=model_version,
537
+ software_version=f"{SOFTWARE_VERSION}/{GENERATOR_VERSION}",
538
+ source="SYNTHETIC", confidence=1.0)))
539
+ return out
540
+
541
+
542
+ def bank_id_for(n_scenarios: int, seed: int, model_version: str) -> str:
543
+ blob = f"{n_scenarios}|{seed}|{model_version}|{GENERATOR_VERSION}"
544
+ return "bank-" + hashlib.sha256(blob.encode()).hexdigest()[:10]
sensorflow/rotr/stopship.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic catastrophic-case stop-ship trigger.
2
+
3
+ Explicitly NOT LLM-driven: the trigger is a versioned, hash-addressed
4
+ CONJUNCTION of independent evidence — a vulnerable road user was involved
5
+ AND the perception layer has positive missed-detection evidence AND the
6
+ counterfactual consequence is SAFETY_CRITICAL. Any two of the three do not
7
+ fire (tested). Surrogate measures alone can never fire it.
8
+
9
+ Integration: when the agentic release machinery is importable (in-progress
10
+ workstream — guarded import), each NO_GO event is FORWARDED to
11
+ agentic.policy.evaluate as an advisory PolicyInput and the policy engine's
12
+ outcome is attached; the local gate record is always written either way,
13
+ so the gate never depends on the concurrent workstream landing.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ from typing import Dict, List, Optional
21
+
22
+ from sensorflow.rotr import SOFTWARE_VERSION
23
+ from sensorflow.rotr.models import Provenance, ReleaseGateResult
24
+
25
+ STOPSHIP_POLICY: Dict = {
26
+ "policy_name": "rotr-catastrophic-conjunction",
27
+ "policy_semver": "1.0.0",
28
+ "conjunction": {
29
+ "vru_involved": True,
30
+ "perception_missed_detection_supported": True,
31
+ "consequence_class": "SAFETY_CRITICAL",
32
+ },
33
+ "outcome_on_match": "NO_GO",
34
+ "notes": "Deterministic conjunction; NOT LLM-driven. Each leg is an "
35
+ "independent evidence stream (taxonomy, attribution, "
36
+ "counterfactual replay). Surrogate thresholds alone never fire.",
37
+ }
38
+
39
+
40
+ def policy_version(doc: Optional[Dict] = None) -> str:
41
+ blob = json.dumps(doc or STOPSHIP_POLICY, sort_keys=True)
42
+ return hashlib.sha256(blob.encode()).hexdigest()[:16]
43
+
44
+
45
+ def _leg_vru(signature: Dict[str, str]) -> bool:
46
+ return signature.get("vulnerability") == "VRU"
47
+
48
+
49
+ def _leg_perception_missed(attribution: Dict) -> bool:
50
+ le = (attribution.get("layers") or {}).get("perception") or {}
51
+ return le.get("status") == "SUPPORTED" and \
52
+ "missed detection" in (le.get("evidence") or "")
53
+
54
+
55
+ def _leg_safety_critical(consequence: Dict) -> bool:
56
+ return consequence.get("consequence_class") == "SAFETY_CRITICAL"
57
+
58
+
59
+ def evaluate_gate(run_id: str, items: List[Dict],
60
+ forward_to_agentic: bool = True) -> ReleaseGateResult:
61
+ """items: [{violation_id, signature, attribution, consequence}]."""
62
+ version = policy_version()
63
+ events: List[Dict] = []
64
+ for it in items:
65
+ legs = {
66
+ "vru_involved": _leg_vru(it.get("signature", {})),
67
+ "perception_missed_detection_supported":
68
+ _leg_perception_missed(it.get("attribution", {})),
69
+ "consequence_safety_critical":
70
+ _leg_safety_critical(it.get("consequence", {})),
71
+ }
72
+ fired = all(legs.values())
73
+ if fired or sum(legs.values()) >= 2:
74
+ events.append({"violation_id": it["violation_id"],
75
+ "legs": legs, "fired": fired,
76
+ "note": "conjunction fired -> NO_GO" if fired
77
+ else "partial match only — does NOT fire "
78
+ "(conjunction is strict)"})
79
+ outcome = "NO_GO" if any(e["fired"] for e in events) else "GO"
80
+
81
+ advisory = None
82
+ if forward_to_agentic and outcome == "NO_GO":
83
+ advisory = _forward_to_agentic(run_id, events)
84
+
85
+ return ReleaseGateResult(
86
+ gate_id=f"{run_id}-gate", run_id=run_id, policy_version=version,
87
+ outcome=outcome, events=events, agentic_advisory=advisory,
88
+ provenance=Provenance(
89
+ software_version=f"{SOFTWARE_VERSION}/stopship-{version}",
90
+ source="SYNTHETIC"))
91
+
92
+
93
+ def _forward_to_agentic(run_id: str, events: List[Dict]) -> Optional[Dict]:
94
+ """Advisory forwarding to the platform's deterministic policy engine."""
95
+ try:
96
+ from sensorflow.agentic.policy import PolicyInput, evaluate
97
+
98
+ fired = next(e for e in events if e["fired"])
99
+ inp = PolicyInput(
100
+ failure_id=f"rotr-{fired['violation_id']}",
101
+ safety_critical_class=True,
102
+ behavioral_evidence="observed_unsafe",
103
+ functional_impact=True,
104
+ significant=True,
105
+ evidence_confidence="CONFIRMED",
106
+ downstream_consequence="unsafe",
107
+ novelty="novel",
108
+ )
109
+ result = evaluate(inp, actor="rotr-stopship")
110
+ return {"engine": "sensorflow.agentic.policy",
111
+ "outcome": result.get("outcome"),
112
+ "severity": result.get("severity"),
113
+ "policy_version": result.get("policy_version"),
114
+ "recommended_option": result.get("recommended_option")}
115
+ except Exception as e:
116
+ return {"engine": "unavailable",
117
+ "note": f"agentic policy engine not importable/usable ({e}); "
118
+ "local gate record stands alone"}
sensorflow/rotr/store.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File persistence under runs/rotr/ (platform convention: request-scoped
2
+ FastAPI over JSON files; no services). `set_rotr_root` is the test hook,
3
+ mirroring set_safety_root / set_nextgen_root etc."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import threading
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ _ROOT = os.path.join("runs", "rotr")
13
+ _ROOT_LOCK = threading.Lock()
14
+
15
+
16
+ def rotr_root() -> str:
17
+ return _ROOT
18
+
19
+
20
+ def set_rotr_root(path: str) -> None:
21
+ global _ROOT
22
+ with _ROOT_LOCK:
23
+ _ROOT = str(path)
24
+
25
+
26
+ def _path(*parts: str) -> str:
27
+ return os.path.join(rotr_root(), *parts)
28
+
29
+
30
+ def write_json(obj: Any, *parts: str) -> str:
31
+ path = _path(*parts)
32
+ os.makedirs(os.path.dirname(path), exist_ok=True)
33
+ with open(path, "w") as f:
34
+ json.dump(obj, f, indent=1, default=str)
35
+ return path
36
+
37
+
38
+ def read_json(*parts: str) -> Optional[Dict]:
39
+ path = _path(*parts)
40
+ if not os.path.exists(path):
41
+ return None
42
+ with open(path) as f:
43
+ return json.load(f)
44
+
45
+
46
+ def list_dir(*parts: str) -> List[str]:
47
+ path = _path(*parts)
48
+ if not os.path.isdir(path):
49
+ return []
50
+ return sorted(os.listdir(path))
51
+
52
+
53
+ def exists(*parts: str) -> bool:
54
+ return os.path.exists(_path(*parts))
sensorflow/rotr/taxonomy.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Six-axis violation taxonomy, structured query, and clustering.
2
+
3
+ Axes: Actor / Vulnerability / Legality / Environment / Interaction /
4
+ Behavior, plus extensions RoadGeometry / TrafficControl / Visibility.
5
+
6
+ The structured query turns a natural-language request such as "failed to
7
+ yield to pedestrian at uncontrolled intersection during low visibility"
8
+ into a filterable query object via a DETERMINISTIC keyword map (no LLM in
9
+ the retrieval path), then filters violation signatures exactly.
10
+
11
+ Clustering mirrors raremine's mining semantics conceptually (signature ->
12
+ dedup -> diversity-aware exemplar) over ROTR-native structured signatures —
13
+ own implementation, raremine's store is untouched. Exact structured
14
+ matching over ~10^3 records needs a dict, not a vector DB (see the
15
+ DO-NOT-BUILD decision in the architecture doc).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from typing import Dict, List, Optional
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from sensorflow.rotr.models import ROTRScenario, ROTRViolation
26
+
27
+ TAXONOMY_VERSION = "rotr-taxonomy-1.0.0"
28
+
29
+ AXES = ["actor", "vulnerability", "legality", "environment", "interaction",
30
+ "behavior", "road_geometry", "traffic_control", "visibility"]
31
+
32
+ RULE_TO_LEGALITY = {
33
+ "R-YIELD-PED-01": "YIELD",
34
+ "R-PATH-RESTRICT-02": "RESTRICTED_PATH",
35
+ "R-LANE-MANEUVER-03": "LANE_ASSOCIATION",
36
+ "R-INT-CONFLICT-04": "SIGNAL",
37
+ "R-MERGE-GAP-05": "MERGE",
38
+ "R-STOP-06": "STOP",
39
+ }
40
+
41
+ RULE_TO_BEHAVIOR = {
42
+ "R-YIELD-PED-01": "proceed_without_yield",
43
+ "R-PATH-RESTRICT-02": "enter_restricted_path",
44
+ "R-LANE-MANEUVER-03": "unpermitted_maneuver",
45
+ "R-INT-CONFLICT-04": "run_red_signal",
46
+ "R-MERGE-GAP-05": "insufficient_gap_merge",
47
+ "R-STOP-06": "rolling_stop",
48
+ }
49
+
50
+
51
+ def signature(violation: ROTRViolation, scenario: ROTRScenario) -> Dict[str, str]:
52
+ material = [a for a in scenario.actors
53
+ if a.actor_id in set(violation.actor_ids)]
54
+ actor_cls = material[0].class_name if material else "none"
55
+ vru = any(a.class_name in ("pedestrian", "cyclist") for a in material)
56
+ interaction = "NONE"
57
+ if material:
58
+ intent = material[0].intent
59
+ interaction = {"CROSSING": "CROSSING", "MERGING": "MERGING",
60
+ "PROCEEDING": "FOLLOWING"}.get(intent, "OTHER")
61
+ env = scenario.environment
62
+ ctx = scenario.actual_context
63
+ return {
64
+ "actor": actor_cls,
65
+ "vulnerability": "VRU" if vru else "NON_VRU",
66
+ "legality": RULE_TO_LEGALITY.get(violation.rule_id, "OTHER"),
67
+ "environment": f"{env.lighting}/{env.weather}",
68
+ "interaction": interaction,
69
+ "behavior": RULE_TO_BEHAVIOR.get(violation.rule_id, "other"),
70
+ "road_geometry": ctx.intersection_type,
71
+ "traffic_control": ctx.control,
72
+ "visibility": env.visibility,
73
+ }
74
+
75
+
76
+ # ------------------------------------------------------------ structured query
77
+
78
+
79
+ class ROTRQuery(BaseModel):
80
+ """Filterable query object over the taxonomy axes (all optional)."""
81
+
82
+ actor: Optional[str] = None
83
+ vulnerability: Optional[str] = None
84
+ legality: Optional[str] = None
85
+ environment: Optional[str] = None
86
+ interaction: Optional[str] = None
87
+ behavior: Optional[str] = None
88
+ road_geometry: Optional[str] = None
89
+ traffic_control: Optional[str] = None
90
+ visibility: Optional[str] = None
91
+ lighting: Optional[str] = None
92
+ weather: Optional[str] = None
93
+ consequence_class: Optional[str] = None
94
+ primary_layer: Optional[str] = None
95
+ text: Optional[str] = None # original text, for provenance
96
+
97
+
98
+ # Deterministic keyword -> axis map (retrieval path is LLM-free).
99
+ _KEYWORDS = [
100
+ ("pedestrian", "actor", "pedestrian"),
101
+ ("cyclist", "actor", "cyclist"),
102
+ ("vulnerable", "vulnerability", "VRU"),
103
+ ("yield", "legality", "YIELD"),
104
+ ("restricted", "legality", "RESTRICTED_PATH"),
105
+ ("bus lane", "legality", "RESTRICTED_PATH"),
106
+ ("wrong lane", "legality", "LANE_ASSOCIATION"),
107
+ ("lane association", "legality", "LANE_ASSOCIATION"),
108
+ ("red light", "legality", "SIGNAL"),
109
+ ("signal", "legality", "SIGNAL"),
110
+ ("merge", "legality", "MERGE"),
111
+ ("stop sign", "legality", "STOP"),
112
+ ("rolling stop", "legality", "STOP"),
113
+ ("uncontrolled intersection", "road_geometry", "uncontrolled"),
114
+ ("controlled intersection", "road_geometry", "controlled"),
115
+ ("intersection", "road_geometry", None), # weak; only if none set
116
+ ("low visibility", "visibility", "low"),
117
+ ("night", "lighting", "night"),
118
+ ("dusk", "lighting", "dusk"),
119
+ ("rain", "weather", "rain"),
120
+ ("crossing", "interaction", "CROSSING"),
121
+ ("safety critical", "consequence_class", "SAFETY_CRITICAL"),
122
+ ]
123
+
124
+
125
+ def parse_query(text: str) -> ROTRQuery:
126
+ low = text.lower()
127
+ fields: Dict[str, str] = {}
128
+ for kw, axis, value in _KEYWORDS:
129
+ if kw in low and value is not None and axis not in fields:
130
+ fields[axis] = value
131
+ return ROTRQuery(text=text, **fields)
132
+
133
+
134
+ def matches(query: ROTRQuery, sig: Dict[str, str],
135
+ consequence_class: Optional[str],
136
+ primary_layer: Optional[str],
137
+ environment: Dict[str, str]) -> bool:
138
+ for axis in AXES:
139
+ want = getattr(query, axis)
140
+ if want is not None and sig.get(axis) != want:
141
+ return False
142
+ if query.lighting is not None and environment.get("lighting") != query.lighting:
143
+ return False
144
+ if query.weather is not None and environment.get("weather") != query.weather:
145
+ return False
146
+ if query.consequence_class is not None and \
147
+ consequence_class != query.consequence_class:
148
+ return False
149
+ if query.primary_layer is not None and primary_layer != query.primary_layer:
150
+ return False
151
+ return True
152
+
153
+
154
+ # ------------------------------------------------------------ clustering
155
+
156
+
157
+ CLUSTER_KEY_AXES = ["legality", "actor", "traffic_control", "behavior"]
158
+
159
+
160
+ def cluster_key(sig: Dict[str, str], primary_layer: Optional[str]) -> str:
161
+ parts = [sig.get(a, "?") for a in CLUSTER_KEY_AXES] + [primary_layer or "?"]
162
+ return "|".join(parts)
163
+
164
+
165
+ def cluster_id_for(key: str) -> str:
166
+ return "cl-" + hashlib.sha256(
167
+ (TAXONOMY_VERSION + "|" + key).encode()).hexdigest()[:8]
168
+
169
+
170
+ def build_clusters(items: List[Dict]) -> List[Dict]:
171
+ """items: [{violation_id, signature, primary_layer, consequence_class,
172
+ environment}] -> recurring-pattern clusters with a diversity-aware
173
+ exemplar (max environment spread, raremine-style selection concept)."""
174
+ groups: Dict[str, List[Dict]] = {}
175
+ for it in items:
176
+ key = cluster_key(it["signature"], it.get("primary_layer"))
177
+ groups.setdefault(key, []).append(it)
178
+ clusters = []
179
+ for key, members in sorted(groups.items()):
180
+ envs = sorted({m["signature"].get("environment", "") +
181
+ "/" + m["signature"].get("visibility", "")
182
+ for m in members})
183
+ cons: Dict[str, int] = {}
184
+ for m in members:
185
+ c = m.get("consequence_class") or "UNCLASSIFIED"
186
+ cons[c] = cons.get(c, 0) + 1
187
+ # Exemplar: the member in the least-represented environment cell
188
+ # (diversity-aware pick, not just the first).
189
+ env_counts: Dict[str, int] = {}
190
+ for m in members:
191
+ e = m["signature"].get("environment", "")
192
+ env_counts[e] = env_counts.get(e, 0) + 1
193
+ exemplar = min(members,
194
+ key=lambda m: (env_counts[m["signature"].get("environment", "")],
195
+ m["violation_id"]))
196
+ clusters.append({
197
+ "cluster_id": cluster_id_for(key),
198
+ "key": key,
199
+ "count": len(members),
200
+ "member_violation_ids": [m["violation_id"] for m in members],
201
+ "exemplar_violation_id": exemplar["violation_id"],
202
+ "environment_spread": envs,
203
+ "consequence_distribution": cons,
204
+ "taxonomy_version": TAXONOMY_VERSION,
205
+ })
206
+ clusters.sort(key=lambda c: -c["count"])
207
+ return clusters