Spaces:
Paused
Paused
Deploy Sensorflow Studio (part 2)
Browse files- sensorflow/rotr/__init__.py +11 -0
- sensorflow/rotr/api.py +427 -0
- sensorflow/rotr/attribution.py +326 -0
- sensorflow/rotr/consequence.py +315 -0
- sensorflow/rotr/flywheel.py +230 -0
- sensorflow/rotr/metrics.py +313 -0
- sensorflow/rotr/models.py +296 -0
- sensorflow/rotr/pipeline.py +188 -0
- sensorflow/rotr/rules.py +379 -0
- sensorflow/rotr/scenes.py +544 -0
- sensorflow/rotr/stopship.py +118 -0
- sensorflow/rotr/store.py +54 -0
- sensorflow/rotr/taxonomy.py +207 -0
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
|